From a whole-repo security review (no critical/high issues; TLS cert and
hostname validation were already intact):
- Don't offer the plaintext "None" transport in manual account setup; it
would send credentials in the clear. The enum value stays only for local
test servers.
- Relabel the advanced toggle "Allow insecure STARTTLS fallback" with a
warning subtitle: it relaxes (does not enable) STARTTLS and permits a
plaintext downgrade when on. Default stays off/secure.
- Set mail.<proto>.ssl.checkserveridentity=true explicitly on IMAP/SMTP as
insurance over the (already-true) Angus default.
- Add a Content-Security-Policy meta to the reader WebView (JavaScript is
already disabled).
- Strip Log.d/Log.v in release builds and drop the account address from the
IDLE log; mark new-mail notifications VISIBILITY_PRIVATE.
Add opt-in at-rest encryption of the Room cache (Settings -> "Encrypt local
cache", off by default) using SQLCipher. The DB passphrase is a random key
sealed by the existing Keystore crypto and kept in a separate DataStore.
DatabaseEncryption performs a self-healing, atomic plaintext<->encrypted
migration at startup that preserves PRAGMA user_version, so toggling applies
on next launch without data loss.
Verified end-to-end on an API 37 emulator (DatabaseEncryptionTest round-trip,
7 instrumented tests) plus 12 unit tests and a release R8 build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses findings from a full-repo review across the mail, sync, persistence,
auth, and UI layers.
Send / outbox:
- Stop the Graph->SMTP fallback from duplicating a message when a Graph send may
already have been accepted; leave indeterminate sends queued for the user.
- Parse RFC822 display-name recipients on the Graph path.
- Preserve attachment order (staged in indexed subdirectories).
Data safety (schema v7):
- Disable cloud/device backup of the Keystore-encrypted credential DB.
- Add the missing v1->v2 migration and drop the destructive migration fallback.
- Normalize the messages.isHtml default and add an attachments->messages
ON DELETE CASCADE foreign key (no more orphaned attachment rows).
Concurrency:
- Serialize syncing and per-account OAuth token refresh; cache tokens by expiry.
- Synchronize Android Keystore key creation.
- Make a sync's persist+notify non-cancellable so an IDLE renewal can't drop it.
Notifications / UI:
- Per-message notifications under a group + summary instead of one overwriting id.
- Wire the "load remote images" and "allow STARTTLS" settings.
- Harden the reader WebView (scheme allowlist + user gesture; no reload on
recomposition); refresh headers without reverting optimistic read/star flags.
- Persist draft attachments; keep server-search hits out of the inbox; encode
the reader navigation argument.
Build / test:
- Export Room schemas; support a real release keystore; add unit/db tests.
- Remove dead Gmail account-setup code left after the sign-in removal.
Verified: debug + release (R8) + androidTest compile; unit tests pass;
MIGRATION_6_7 matches the generated v7 schema.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gmail's restricted https://mail.google.com/ scope needs a paid CASA assessment to ship a
public release, so a dedicated Google OAuth button is a dead end. Drop it from the setup
screen — Gmail is still reachable via "Other (IMAP/SMTP)" with an app password.
- Removes the Google button and its now-unused launcher, coroutine scope, imports, and
strings. The setup screen now offers Microsoft and Other (IMAP/SMTP).
- The underlying Gmail OAuth plumbing (GmailAuthManager, addGmailAccount, the ViewModel's
Gmail methods) is left intact but unexposed; it can be ripped out entirely or re-surfaced
later. assemble/lint/test green; verified on the emulator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Microsoft is steadily restricting OAuth SMTP, and Graph sendMail is their first-class send
path, so Outlook now sends through Graph with SMTP/XOAUTH2 as a fallback.
Graph (graph.microsoft.com) and Exchange Online (outlook.office.com) are separate OAuth
resources, so one consent requests all scopes (Graph Mail.Send + IMAP + SMTP) and
OutlookAuthManager mints per-resource access tokens from the single refresh token on demand
(freshGraphToken / freshOutlookToken).
- GraphSender POSTs me/sendMail with a JSON message (recipients, text body, base64
fileAttachments, saveToSentItems); a unit test covers the payload building.
- SendWorker tries Graph first for Outlook accounts and falls back to SmtpSender on failure;
Gmail/IMAP accounts are unchanged. MailConnectionFactory.graphTokenFor supplies the token.
- Verified: assemble/lint/test green; on the emulator the two-resource consent is accepted
(Microsoft renders its sign-in page, no AADSTS multi-resource error). The post-login token
exchange + actual Graph send need a real Outlook account.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Outlook signs in through the Microsoft identity platform via AppAuth (Authorization Code +
PKCE, no secret). One consent requests the outlook.office.com IMAP and SMTP scopes; because
they share a single resource, the resulting access token authenticates both IMAP receive and
SMTP send over XOAUTH2 — reusing the existing ImapClient and SmtpSender, with no Graph call or
second token. The "common" tenant covers personal and work/school accounts.
- OutlookAuthManager (mirrors GmailAuthManager) + AuthType.OAUTH_OUTLOOK + Account.outlook()
with the unified outlook.office365.com / smtp.office365.com endpoints.
- MailConnectionFactory refreshes either OAuth provider's token; XOAUTH2 now applies to any
non-password account. AccountRepository.addOutlookAccount verifies via IMAP, then persists.
- "Sign in with Microsoft" on the account-setup screen; the manifest registers the
org.libremail.outlook:// redirect. The client id ships in the build, overridable via
secrets.properties (OUTLOOK_OAUTH_CLIENT_ID); README documents the Azure app registration.
- Verified: assemble/lint/test green; on the emulator the button launches AppAuth and
Microsoft renders its live sign-in page (client id, redirect, and scopes all accepted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Turn on minification for the release build and sign it with the debug key so it is
installable for testing (a public release would use a dedicated keystore).
- proguard-rules.pro keeps the reflection-heavy mail/auth stack: Jakarta/Angus Mail
(IMAP/SMTP providers resolved via reflection + service files) and AppAuth.
- Verified on the Android 17 emulator: the release APK builds with R8, installs, and
syncs mail over IMAP — confirming Angus Mail's provider resolution survives shrinking.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Search previously only filtered the cached inbox. Now a query also runs an IMAP SEARCH
on the server and folds the matches into the cache, so messages beyond the synced
window surface in the results.
- ImapClient.search(query) ORs SUBJECT/FROM/BODY terms and fetches matching headers
(extracted a shared toFetchedMessage mapper, reused by fetchRecentInbox).
- MailRepository.searchServer inserts/updates matches into the message cache (no
pruning); MailboxViewModel triggers it from a debounced, deduplicated search query.
- assemble/test/lint green, including a new ImapClient test asserting SEARCH returns
only the matching message against GreenMail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Compose gains an "Attach file" picker (OpenMultipleDocuments) and shows each pick as
a removable chip; OutgoingMessage carries the picked URIs.
- On send the repository copies the picked files into the outbox message's own cache
directory; SendWorker passes them to SmtpSender, which builds a multipart message
(text body + a part per file via attachFile). Files are cleaned up on success/cancel.
- assemble/test/lint green, including a new SmtpSender test that sends an attachment and
asserts GreenMail received a multipart message containing it; the compose "Attach file"
affordance verified on the Android 17 emulator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New Outbox screen lists queued messages with status (Queued, or "Couldn't send" in
red after a failed attempt), an app-bar Retry, and a per-message cancel.
- The inbox shows an "Outbox (N)" entry while anything is queued. Repository gains
observeOutbox/cancelOutboxMessage/retryOutbox; OutboxDao.observeAll + OutboxMessage.
- SendScheduler now enqueues the drain with REPLACE rather than APPEND_OR_REPLACE so
newly-queued mail and manual retries run promptly, overriding a pending retry-backoff
(previously a queued message could sit behind an exponential backoff for minutes).
- assemble/test/lint green; verified on the Android 17 emulator — a message stuck from an
earlier offline send showed as failed in the outbox, and tapping Retry (server back up)
drained it to "Outbox is empty".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Composing now auto-saves a draft when you leave with anything entered, and sending
deletes it.
- New `drafts` Room table (entity + DAO + Draft model + MIGRATION_5_6, DB v6), with
repository observe/get/save/delete.
- ComposeViewModel loads a draft by id (resume), saves/updates one on exit (or deletes
it when emptied), and deletes it after sending; the screen closes via a finished event
so the save completes before navigating away.
- New Drafts screen (list with per-row delete, resume on tap); the inbox shows a
"Drafts (N)" entry when any exist. Compose gains a draft nav arg.
- assemble/test/lint green; verified on the Android 17 emulator — the v5->v6 migration
kept existing mail, a backed-out compose saved a draft, the draft listed and reopened
pre-filled, and sending it removed the draft.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sending was synchronous and failed outright if the network or server hiccupped.
Compose now enqueues to an outbox and a worker delivers in the background.
- New `outbox` Room table (entity + DAO + MIGRATION_4_5, DB v5) holds queued mail.
- MailRepository.sendMessage inserts into the outbox and triggers SendScheduler instead
of sending inline, so compose returns immediately.
- SendWorker (@HiltWorker) drains the outbox over SMTP, deleting each row on success and
returning Result.retry() on failure so WorkManager reattempts with backoff (under a
network constraint); a removed account's queued mail is dropped.
- assemble/test/lint green; verified on the Android 17 emulator — the v4->v5 migration
preserved existing mail, and a composed message was queued, sent by the worker over
SMTP, and round-tripped back into the inbox.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- A search icon in the inbox app bar opens an in-bar search field (autofocused, with a
Back/close handler); typing filters the message list by sender, address, subject, and
snippet (case-insensitive), within the current account filter.
- Filtering is reactive over the cached list, so results update live as mail syncs, and
a "No results" state shows when nothing matches.
- assemble/test/lint green; verified on the Android 17 emulator — searching "IMAP"
narrowed three messages to the two whose subject matched, and a non-matching query
showed the empty state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
IdleService read the account list once at startup, so a removed account's IDLE
loop kept reconnecting (backing off harmlessly) until the service restarted, and a
newly-added account wasn't watched until then either.
- IdleService now observes the accounts and reconciles one IDLE watcher per account:
it starts a watcher for an added account and cancels the watcher for a removed one,
which closes that account's IDLE connection promptly via the existing cancellation
path.
- The app runs the service only while push is enabled AND at least one account exists,
so it auto-starts on the first account and stops on the last.
- Verified on the Android 17 emulator: two accounts held two IDLE connections; removing
one dropped to a single connection with no retry loop, and the other kept idling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The data layer, background sync, and IDLE already handled N accounts; this makes
the UI account-aware.
- Mailbox: filter chips (All + one per account) appear once 2+ accounts exist, and
each message in the unified view is labelled with its account. The filter resets to
All if the selected account is removed.
- Reply now carries the receiving account through to compose, so From defaults to the
account that received the message rather than just the first account.
- Removing an account now also deletes its cached messages and attachments, so they
leave the unified inbox.
- assemble/test/lint green; verified on the Android 17 emulator — added a second
GreenMail account, saw both accounts' mail unified + attributed, filtered to one
account, and replied from the correct account.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Parse attachment metadata while fetching a message body (ImapClient walks the MIME
tree, collecting parts with a filename or attachment disposition in a stable order);
a new fetchAttachment(uid, partIndex) downloads one part's bytes on demand.
- Persist attachment metadata in a new Room `attachments` table (entity + DAO +
MIGRATION_3_4, DB v4), populated when a message is opened so it survives re-opens.
- Reader shows an Attachments section (filename, size, type badge); tapping downloads
the part to a cache file and opens it in a system viewer via a FileProvider content
URI (ACTION_VIEW), with a snackbar when the download fails or no app can open it.
- assemble/test/lint green; verified on the Android 17 emulator against GreenMail —
a PNG-attachment message rendered the attachment, and tapping it fetched the exact
1049-byte file into the cache and dispatched an image/png VIEW intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single IDLE connection held indefinitely gets dropped by servers after ~29 min
(RFC 2177) and severed by NAT/firewalls sooner — silently stranding push.
- IdleService bounds each IDLE session with withTimeoutOrNull(IDLE_RENEWAL_MS = 9 min)
and reconnects, re-issuing IDLE well within those limits. Each reconnect catch-up
syncs, so no mail is missed across renewals.
- ImapClient.idle() now closes the store from an awaitCancellation() child that runs at
cancellation *start*. A Job completion handler never runs while idle()'s blocking read
is stuck cancelling, so it could not unblock idle(); this can, so both renewal and
service stop break out of idle() promptly.
- Verified on the Android 17 emulator against GreenMail: the IDLE connection
re-established on schedule (each cycle = fresh connect + IDLE), and a message delivered
mid-run still pushed a notification within ~2s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Increment 7 — IMAP IDLE push.
- ImapClient.idle() holds a long-lived IMAP connection in IDLE. The server pushes
new-mail notifications during the blocking idle() call, which Jakarta dispatches to a
MessageCountListener (idle() does not itself return), so each push is forwarded to a
sync via a conflated channel. It syncs once on connect to catch up, and closes the
store from the cancellation handler to unblock idle().
- IdleService: a dataSync foreground service running one reconnecting IDLE loop per
account (exponential backoff) that triggers MailSyncer on each push, with an ongoing
"Watching for new mail" status notification.
- IdlePushManager starts/stops the service; LibreMailApplication observes the pushIdle
setting (the existing Advanced toggle) and reacts. Adds FOREGROUND_SERVICE and
FOREGROUND_SERVICE_DATA_SYNC permissions plus the service declaration.
- assemble/test/lint green; verified on the Android 17 emulator against GreenMail —
delivering a message while the app idled pushed an on-device notification within ~2s,
with no polling and no user action.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Increment 6 — notifications and settings.
- Local new-mail notifications (no push service): MailNotifier posts a notification
when background sync finds newly-arrived unread mail, and tapping it opens the app.
Adds a POST_NOTIFICATIONS request on launch.
- MailSyncer detects genuinely new messages (diff against cached ids, skipped on an
account's first sync) and notifies when the setting is enabled.
- SettingsRepository (Preferences DataStore) persists settings; the Settings screen
gains a Notifications section, and "Use wallpaper colors" now actually drives the
Material You theme (MainActivity collects it reactively).
- assemble/test/lint green; verified on the Android 17 emulator — delivered a new
message and the on-device notification appeared in the shade.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Increment 5 — send.
- SmtpSender (Angus Mail; password/XOAUTH2) builds a MimeMessage and sends over
SMTP/SMTPS. New OutgoingMessage + SmtpParams.
- MailConnectionFactory now resolves both IMAP and SMTP params (shared credential
and token refresh); MailRepository.sendMessage.
- Compose screen wired to send: From account (a selector when there are several),
To with device-contacts autocomplete (ContactsContract, runtime READ_CONTACTS),
Cc, Subject, Body, with progress and error handling.
- Reply from the reader prefills To and a "Re:" subject (compose route gains optional
to/subject args).
- Tests: GreenMail SmtpSender unit test. assemble/test/lint green; verified end-to-end
on the Android 17 emulator — composed a message, sent it over SMTP to a local
GreenMail server, and it round-tripped back into the inbox on re-sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Increment 4 — read.
- ImapClient.fetchBodyMarkingSeen extracts the best body part (HTML preferred,
else plain text) and marks the message \Seen; setFlag and deleteMessage (expunge)
back the star/read/delete actions.
- MailConnectionFactory shares credential/token resolution between sync and reader.
- Cached bodies survive sync: schema v3 (isHtml column via a data-preserving
Migration 2->3); sync is now insert-new + update-header + delete-absent instead of
replace-all, so fetched bodies are not clobbered.
- Reader fetches and caches the body on open (marking it read), renders HTML in a
hardened WebView (JavaScript off, file/content access off, remote content blocked
with an opt-in "Show images") and plain text in selectable Text; star + delete in
the app bar; a snippet is derived from the fetched body.
- Tests: GreenMail fetchBodyMarkingSeen unit test (body + read flag). assemble/test/
lint green; verified end-to-end on the Android 17 emulator against a local GreenMail
server (HTML rendered in the WebView, mark-read, snippet).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Increment 3 — receive.
- ImapClient.fetchRecentInbox pulls recent INBOX headers (ENVELOPE/FLAGS/UID)
over IMAP (password or XOAUTH2) into FetchedMessage.
- MailSyncer orchestrates per-account fetch -> Room (replace-per-account),
refreshing and re-persisting the Gmail OAuth token when needed.
- WorkManager background sync via a @HiltWorker (periodic 15-min + an expedited
one-shot after adding an account); Application supplies the HiltWorkerFactory
and the default WorkManager initializer is removed.
- Mailbox renders real cached mail with pull-to-refresh and proper empty states
(welcome/add-account vs no-messages); the sample-data crutch is removed.
- Shared entity mappers; MessageDao.replaceAccountMessages transaction.
- Tests: GreenMail-backed fetchRecentInbox unit test (deliver via SMTP, read via
IMAP, newest-first). Instrumented Keystore + Angus-provider tests stay green on
the Android 17 emulator, where the SyncWorker also runs to SUCCESS.
- Add error_prone_annotations to the compile classpath (Hilt/Dagger codegen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Increment 2 — authentication and account management.
- Gmail OAuth 2.0 via AppAuth (Authorization Code + PKCE, restricted
https://mail.google.com/ scope); redirect scheme derived from the client id.
- Generic IMAP/SMTP manual setup (host/port/security) with an Advanced section.
- Angus/Jakarta Mail IMAP client (password + XOAUTH2); "test connection" logs in
and lists folders before an account is saved.
- Android Keystore-backed AES-256-GCM credential store (encrypts the OAuth
AuthState / IMAP password); accounts + secrets persisted in Room (schema v2).
- AccountRepository + Hilt wiring; Settings accounts list (add / remove).
- Tests: GreenMail-backed IMAP client unit test; instrumented Keystore round-trip
and Angus Mail provider-resolution tests (green on the Android 17 emulator).
- Remove the placeholder Compose smoke test (the API 37 Compose-UI-test library
hits InputManager.getInstance); on-device rendering verified via screenshots.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keeps gradlew as LF (Unix shell), *.bat as CRLF, and treats jars/images as
binary, independent of each contributor's core.autocrlf setting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Initial scaffold for LibreMail, a free and open-source (GPL-3.0) Android email
client. This increment delivers a buildable, runnable, themed app shell on top
of the full architecture skeleton; account sign-in, IMAP/SMTP sync and sending
arrive in later increments.
- Gradle 9.6 + AGP 9.2 + Kotlin 2.4.0 (AGP built-in Kotlin via the buildscript
classpath; KSP, no KAPT); version catalog; minSdk 33, target/compile SDK 37
- Jetpack Compose + Material 3 with Material You dynamic color, light/dark and
edge-to-edge; adaptive, themed launcher icon
- Navigation across Inbox, Reader, Compose, Settings (with an Advanced Settings
group) and Account Setup
- Hilt DI, Room cache (entities/DAOs/database), domain models, and a
MailRepository as single source of truth with a sample-data fallback
- Unit tests (repository + sample data) and a Compose smoke test
- GPL-3.0 LICENSE, SPDX headers, README with build and Gmail OAuth setup steps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>