Reorders SettingsScreen's top-level (non-Advanced) sections per #158:
Accounts, Message downloading, Contacts, Appearance, Settings Backup,
Notifications, Storage on this device, then a header-less trailing
Report a Problem row (mirrors AccountSettingsScreen's headerless
"Remove account" row now that Diagnostics is down to one item).
- Move "Message downloading" up to directly follow Accounts.
- Add a two-line descriptive subtext under the Appearance header
("Match device theme" / "Material You theming (Android 12+)"); no
new toggle, since LibreMailTheme already always follows the system
light/dark setting via isSystemInDarkTheme().
- Rename settings_backup "Backup" -> "Settings Backup" and
settings_new_mail "New-mail notifications" -> "New mail
notifications" (drop hyphen).
- Drop the now-single-item settings_diagnostics header string; keep
Contacts positioned directly before Appearance (its prior relative
spot), per the ticket's suggested safest default for the
unresolved placement question.
Advanced's internal order is untouched (out of scope; tracked by
#162).
Closes#158
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Replace the small inline "Report sent. Thank you!" text with an AlertDialog
carrying the fuller thank-you/no-guarantee message, and gate the screen's
auto-navigate-on-delete LaunchedEffect so it no longer fires while a submit
is in flight or has just succeeded — the dialog's acknowledgement is what
calls onDone() for that path instead. This closes the race where
ReportUploadWorker deletes the report row (and thus flips state.exists to
false) moments after SubmitUiState.SUCCEEDED, which could previously
navigate the user away before the confirmation was ever visible. Discard
and the other non-success paths (FAILED/UNAVAILABLE) are unaffected and
still auto-navigate immediately.
Closes#161
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A message with several attachments used to render every AttachmentRow
stacked vertically, pushing the message body arbitrarily far down. Now
only the first attachment shows by default; when there is more than one,
the extras collapse behind a "See x more attachments" control that
expands and collapses with an animated, rotating chevron. A single
attachment renders exactly as before (no accordion).
The count uses a plurals resource (quantity one/other) so it reads
"See 1 more attachment" / "See 2 more attachments" correctly. The toggle
is one clickable Role.Button whose label and chevron contentDescription
expose the expanded state to screen readers. Download/open behavior of
each row is unchanged.
Refs #134
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recipient autocomplete's READ_CONTACTS permission was requested lazily on
every compose-screen open (a LaunchedEffect(Unit)), re-prompting users who
had declined. Move the request to a dedicated, skippable onboarding step and
add a Settings entry to turn it on later, each with an in-context rationale.
- #127: new skippable ONBOARDING_CONTACTS step (mirrors the battery step),
requested once. ComposeScreen no longer prompts; it only reads the current
grant on resume, so a grant made later (e.g. from Settings) still takes
effect the next time compose opens.
- #128: the onboarding step and the Settings request show a short rationale
(contacts are used only for on-device autocomplete, never uploaded) and
handle shouldShowRequestPermissionRationale so a re-request explains itself.
docs/play-permissions.md updated to match.
- #129: Settings -> Contacts -> Recipient autocomplete reflects on / off /
blocked-in-settings; requests in-app when grantable, deep-links to the app's
system settings when permanently denied.
Graceful degradation is preserved: ContactsRepository.search still runCatch-es,
ComposeViewModel.searchContacts() still guards on contactsAllowed, and the
suggestion list still renders only when non-empty.
Adds a pure ContactPermissionDecision (JVM unit-tested), extends the onboarding
view-model tests, and adds Compose UI tests for the onboarding step
(skip / grant / deny / rationale) and the Settings row states.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three code-quality cleanups from the PR #54 review, all in the folder-label
plumbing so they ship as one change (adapted to the post-#108/#117 code):
#69 providerLabel: consolidate provider-brand host matching. Host->brand
knowledge now lives solely in MailProvider: forImapHost matches an entry's
imapHost plus new hostAliases (Gmail gains legacy imap.googlemail.com), and a
new companion brandFor(account) is the single seam that also recognizes
Outlook (by OAuth auth type or a precise office365.com / outlook.office.com
host, not any substring). MailProvider stays the app-password preset registry
(Outlook is not an entry). providerLabel() drops its ad-hoc host substrings.
#68 i18n: move folder-label disambiguation patterns into strings.xml. The
"base - provider", "base (parent)", and "base [path]" grammars become
folder_label_with_provider/parent/path resources, threaded into the pure
resolver as a LabelPatterns bundle whose defaults match the old literals; the
composable resolves the localized strings and passes them down.
#67 FolderDrawer: memoize label resolution, resolver early-return, fail-fast
lookups. resolvedFolderLabels hoists the role->string and pattern lookups out
of a remember() so the resolved map is rebuilt only when folders/accounts/
strings change (not every recomposition of the idle drawer). resolveDrawerLabels
returns baseLabels unchanged when nothing collides, and both map lookups use
getValue so a key miss fails loudly instead of silently un-deduplicating.
Behavior is unchanged: existing FolderLabelsTest and FolderDrawerTest
assertions (from #60/#61/#64/#108) stay green. Adds unit tests for host->brand
matching and its over-match guard, pattern-driven formatting, the early-return
identity, and fail-fast on a missing base label.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a live unread-count signal shared by two navigation-drawer indicators:
- #83: each folder row shows a trailing unread-count badge (capped at
"99+"), hidden when zero. Screen readers announce the exact count via a
plurals content description.
- #84: accounts with unread mail render their email in bold in the drawer
account switcher and the mailbox account-filter chips.
Both derive from one efficient Room aggregate, MessageDao.observeUnreadCounts():
a COUNT(*) ... GROUP BY accountId, folder over folder-synced rows
(inInbox = 1 AND isRead = 0) that pulls no message rows into memory. Its
GROUP BY is served by the existing (accountId, folder, uid) index, so no
schema change or migration is needed. MailboxViewModel derives
folderUnreadCounts (drawer account, per folder) and accountsWithUnread
(any folder) from the one shared flow.
Unread scope is folder-synced mail in any folder, kept consistent across
both features: an account reads as bold exactly when one of its folders
shows a badge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gmail's app-passwords page rejects accounts that don't have 2-Step
Verification enabled, and the setup screen's intro text names that
prerequisite without giving the user any way to act on it. Add a
nullable MailProvider.twoFactorHelpUrl (set only for Gmail, to
Google's "Turn on 2-Step Verification" article) and surface it as a
second outlined button under the existing app-password link, reusing
the same UriHandler + snackbar failure plumbing. Yahoo and iCloud
screens are unchanged.
Closes#98
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shared core: BatteryStatusProvider (BatteryManager one-shot +
ACTION_BATTERY_CHANGED flow) feeds SyncResourcePolicy, a pure,
unit-tested decision object; all gates are runtime-only and
self-reverting - no setting is ever mutated.
- #88: FetchPolicy now defaults to WIFI_ONLY in both the AppSettings
default and the DataStore-read fallback, so fresh installs and
never-touched existing installs stop bulk-downloading full content
over cellular. An explicitly chosen policy is unaffected.
- #89: the aggressive body/attachment prefetch pauses for every
FetchPolicy at <=20% battery in BOTH content-prefetch paths -
MailSyncer's recent-window prefetch and MailBackfiller's
full-history prefetch (#12) - resuming on the next sync once above
the threshold; charging exempts. Header sync and backfill header
paging (new-mail detection, notifications, history) are untouched.
- #90: IdleService watches battery and proactively closes its IDLE
connections at <=20%, flipping the foreground notification to say
mail is checked every 15 minutes (the always-scheduled periodic
sync, re-asserted on entry); IDLE resumes at >=25% or on charger
(hysteresis prevents threshold flapping) and catches up missed mail
via idle()'s on-connect sync.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses 14 of the 15 confirmed findings from the max-effort review of the
screen-lock app gate. The remaining one (accounts/credentials share the
auth-bound cache DB) needs a device-tested Room migration and is filed
separately; its blast radius is reduced here by eliminating the spurious wipes.
- Cold-start deadlock: LibreMailApplication injects AccountRepository lazily so
the Room DB is never built on the main thread before unlock.
- Passphrase source of truth: DatabaseKeyStore.resolvePassphrase() keys off
which seal exists, not the app-lock setting; passphrase() refuses to mint a
master key while an auth seal exists.
- Toggle-order strand: disabling app-lock reseals under the master key whenever
an auth seal exists (not gated on the encryptCache setting).
- Crash-safe clear protocol: wipe + reset seals, then clear the flag last; set
clear-pending before flipping app-lock off.
- isInvalidated(): treats a lapsed auth window (UserNotAuthenticated) as valid,
and onForeground short-circuits when app-lock is off.
- unwrapSealedPassphrase: classifies all decrypt failures — no crash after a
successful auth.
- Headless entry points: SyncWorker/SendWorker/IdleService fail fast via
EncryptedCacheGuard instead of blocking DB construction while locked.
- sealWithMaster: deletes the orphaned auth key (no spurious later wipe).
- Lock-bypass race: AppLockGate ignores a background recorded after a foreground
pass began; the ViewModel captures the foreground timestamp synchronously.
- FLAG_SECURE: set while app-lock is on (recents/screenshot protection).
- Resume + re-lock: the gate covers content with an opaque overlay instead of
removing it, so no stale frame renders and in-progress state (nav, drafts)
survives re-lock.
- Retry feedback: lock emissions carry a nonce so a retry updates the UI.
Tests: AppLockGate stale-foreground race cases + an exhaustive
KeyInvalidationPolicy table. Fast gate green + androidTest compiles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolve the build.gradle.kts conflict by keeping both additions: the
androidTest Room-schema srcDir (this branch) and main's F-Droid
dependenciesInfo block. Full fast gate + androidTest compile green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Send now scans the subject and body for "attach" and its variants
(word-bounded, case-insensitive). When the text mentions one but the
message carries no attachment, an AlertDialog asks "Need to attach
anything?" — Yes returns to composing and pulses the attach button,
No sends the message as-is, and dismissing cancels the send. (#79)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RichStyle becomes a sealed interface (Bold/Italic/Underline/Strikethrough +
FontFamily/FontSize/FontColor/Highlight); RichTextContent gains alignments,
images, and baseStyle channels. The HTML serializer emits merged <span style>
runs, text-align on <p>/<li> (splitting merged paragraphs at alignment
boundaries), <img src="cid:…"> over the visible [image: name] token, and a
single outer <div style> for the base style. The parser is a faithful inverse
and additionally tolerates <del>/<strike>, px font sizes, #rgb colors, and
start/end alignment synonyms; unknown CSS is ignored without dropping text.
hasFormatting() covers every new channel so ComposeViewModel.normalizedHtml()
never silently drops serialized formatting. The editor carries parameterized
style identity via string annotations (libremail:style / libremail:image), maps
alignment onto ParagraphStyle ranges, holds baseStyle in separate field state,
and RichTextEditing.toggleStyle now replaces a different value of the same kind
while styleAt() answers "current value over the selection" for pickers.
ColorSwatchRow is added for the upcoming color/highlight dialogs. No UI change.
Closes#70
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings the screen-lock app gate (#22) up to date with 25 commits of main
(signatures, backup opt-in, battery optimization, rich compose, reporting).
Conflicts resolved as a union of both features:
- SettingsRepository: adopt main's top-level Keys + shared toAppSettings()
refactor and thread appLock through it; keep both appLock and includeInBackup
- DatabaseModule: keep provideSignatureDao; keep DatabaseFiles.NAME for DB_NAME
- MainActivity: wrap LibreMailApp(pendingCompose=...) inside AppLockGateHost
- SettingsViewModel/SettingsScreen: union app-lock and battery state/effects;
keep LocalResources for the app-lock toast (LocalContextGetResourceValueCall lint)
- SettingsScreenTest: construct SettingsViewModel with the merged 5 args
- strings.xml: keep both the app-lock and battery/diagnostics string blocks
Fast gate green with JDK 21: assembleDebug + testDebugUnitTest + lintDebug +
compileDebugAndroidTestKotlin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the fixed 50-message-per-folder header cap with a background,
resumable full-history backfill, and add a user-configurable device-only
retention limit that prunes local mail beyond it without ever deleting from
the server.
- ImapClient.fetchOlderThan pages a folder backwards in bounded batches,
locating the boundary by binary search over message numbers (O(log n) tiny
UID fetches, memory bounded to one batch).
- MailBackfiller + BackfillWorker page each synced folder newest→oldest,
persisting a per-folder boundary in a new backfill_progress table so a run
interrupted by process death / network loss resumes exactly where it stopped.
Runs off the sync mutex, so foreground sync / pull-to-refresh stay responsive.
- MailSyncer now reconciles server deletions only within the recent UID window
(deleteSyncedInWindowNotIn) instead of wiping everything outside the recent
50, so backfilled history survives each foreground sync. A materialized
messages.uid column powers the windowed reconcile and backfill boundary.
- Body/attachment prefetch still honours FetchPolicy (headers first).
- Per-account count/age overrides (nullable) with a global default; 0 = keep
everything (the default, matching #12).
- MailPruner + PruneWorker delete local rows beyond the limit (cascading
attachment rows + on-disk cache), never issuing a server delete. Deletes are
chunked under SQLite's 999-parameter limit.
- Precedence with backfill: backfill pauses (does not complete) at the
retention floor and both jobs share a maintenance mutex, so they never
contend; foreground fetch is also capped by the count so it can't re-download
what pruning just trimmed.
- Settings UI for the global default and per-account override, with copy making
clear it is device-only, not the server.
Room schema v9→v10 (migration + exported schema + MigrationTestHelper test).
GreenMail tests prove the backfill caches >50 and resumes after interruption;
pruning tests cover count/age limits and never touch the server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a guided, F-Droid-safe onboarding step and an Advanced Settings recovery
row that let users move LibreMail to "Unrestricted" battery usage, so IMAP
IDLE push (IdleService) and periodic WorkManager sync aren't throttled or
killed by Doze. Deep-links to the system app-details screen rather than the
restricted REQUEST_IGNORE_BATTERY_OPTIMIZATIONS dialog, so it needs no new
permission and is safe on Play (#17) and F-Droid (#16).
- BatteryPromptDecision: pure, unit-tested gate (supported && !unrestricted && !handled)
- BatteryOptimizationManager: reads isIgnoringBatteryOptimizations, builds the deep-link intent
- Onboarding step shown after the first account is added; skipped when already
unrestricted or already handled; re-checks status on resume
- Advanced Settings row shows current status and re-opens the system screen
- battery_prompt_handled flag persisted in the settings DataStore (kept out of AppSettings)
- Unit tests for the decision + view model; Espresso E2E for the step
Closes#49
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve LibreMailApp.kt: union the compose function params so mailto prefill
(pendingCompose/onComposeHandled) coexists with onboarding start-gating
(appViewModel) and the crash dialog (startupViewModel); keep LaunchedEffect +
getValue/remember imports. Verified locally: assembleDebug + testDebugUnitTest +
lintDebug + ktlintCheck + detekt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts from #40/#41/#44:
- build.gradle.kts: keep DEBUG_REPORT_ENDPOINT field + val, take #40's
outlookRedirectScheme (gmailRedirectScheme was deleted).
- LibreMailApp.kt: function takes BOTH appViewModel (start-dest gating, #44)
and startupViewModel (crash dialog, #42); use renamed AccountPickerScreen.
- SettingsScreen.kt: keep both the Diagnostics (#42) and Backup (#41) sections.
Verified locally: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring rich composition to LibreMail (issues #36, #37, #38, and #23).
#36 HTML editor + toolbar
- New pure, JVM-testable rich-text model (`richtext/`): RichTextContent with
inline styles + links and block markers ("• ", "N. ", "> "), serializing to a
narrow email-safe HTML subset and back (fromHtml is a faithful inverse).
- Rich editor in ComposeScreen with a bold/italic/underline, bulleted/numbered
list, block-quote, and link toolbar, backed by AnnotatedString. Unformatted
text stays plaintext-only (null HTML) so it feels unchanged and is accessible.
- #23: rounded corners on the compose fields/body via MaterialTheme.shapes.
#37 multipart/alternative + reply/forward quoting
- SmtpSender builds multipart/alternative (text/plain + text/html), nested in
multipart/mixed when there are attachments; GraphSender sends HTML content.
- HtmlToText produces a readable text/plain fallback; ReplyBuilder quotes HTML
originals as clean blockquotes (tags stripped) without corruption.
- HTML body persists/restores through drafts and the outbox (new nullable
bodyHtml columns; Room v9->v10 migration + schema).
#38 signatures
- New signatures table (multiple per account, one default) + repository/DAO;
migration backfills the existing per-account signature as the default.
- Rich signatures reuse the #36 editor; a Signatures management screen (list,
add/edit/delete, set default) is linked from per-account settings.
- The account's default signature auto-inserts on new compose / reply / forward
(honoring the enable toggle), placed above the quote, and stays editable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an opt-in "Require screen lock" setting that gates the whole app behind
BiometricPrompt (strong biometric with device-credential fallback) and binds
the encrypted cache's SQLCipher passphrase to user authentication.
- App-lock gate: AppLockGateHost wraps the app; a pure AppLockGate state machine
locks on cold start / resume-after-timeout and unlocks on auth.
- Auth-bound decrypt: DatabaseKeyCipher seals the DB passphrase with a Keystore
key requiring user auth (setUserAuthenticationRequired, time-bound validity,
setInvalidatedByBiometricEnrollment). PassphraseSession holds the unwrapped
passphrase in memory; provideDatabase reads it only after auth.
- The non-auth master key (KeystoreCrypto) is unchanged, so background credential
access (IDLE push) still works.
- Invalidation / lock removal: KeyInvalidationPolicy decides clear-vs-disable;
the cache is wiped only at cold start in provideDatabase (never while Room holds
it open) via a persisted flag + process restart, then re-synced. No corruption.
- Enabling requires a secure device lock; disabling reseals the passphrase back
under the master key first (guarded to avoid a passphrase mismatch).
Adds androidx.biometric; MainActivity becomes a FragmentActivity (required by
BiometricPrompt). JVM tests cover the gate state machine, invalidation policy,
and passphrase session; the Keystore/BiometricPrompt/restart paths are
device-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the onboarding epic (#26–#31) as a single feature:
- #26 First-run nav scaffold: gate the start destination on the account
count (no accounts -> onboarding, else mailbox) via AppViewModel, holding
render until the count is known so there is no cold-start flash. Onboarding
is a nested nav graph with a graph-scoped OnboardingViewModel tracking the
first account added this session. Removes NoAccountState in favour of a
shared welcome/empty state.
- #28 Provider registry: MailProvider presets for Gmail/Yahoo/iCloud
(IMAP+SMTP host/port/security, help URL) mirroring Account.outlook, biased
to STARTTLS where documented; host/port/security unit-tested.
- #27 Vendor picker: AccountPickerScreen replaces the two-button setup screen
as the single entry point (onboarding + Settings/mailbox "Add account"),
routing Outlook -> OAuth, Gmail/Yahoo/iCloud -> app-password, Other -> manual.
- #29 App-password guided screen: one reusable screen per provider key with
explanation + security warning + help link; verifies/persists via
addImapAccount. ViewModel unit tests cover valid/invalid/failure paths.
- #30 "Add another?" prompt + first-account landing: after each onboarding
add, offer Yes (back to picker) / No (open the first account's inbox,
per-account filtered via a MAILBOX account nav arg). Only inside onboarding.
- #31 Instrumented onboarding E2E (welcome -> picker -> app-password add ->
add-another -> first inbox); managed-device list and CI matrix already in
lockstep. All new files carry the SPDX header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add intent filters so LibreMail handles mailto: (ACTION_VIEW / SENDTO)
and email SEND / SEND_MULTIPLE intents, opening a prefilled compose screen.
- MailtoParser: pure RFC 6068 parser (multiple recipients, to/cc/bcc/
subject/body, percent-encoding; preserves a literal '+'); JVM-tested.
- IntentComposeParser: builds a ComposePrefill from a mailto: URI or the
EXTRA_EMAIL/CC/BCC/SUBJECT/TEXT share extras.
- MainActivity parses the launch/new intent and hands a one-shot prefill to
the NavHost (guarded against config-change duplication).
- Compose form gains a Bcc field; Routes carry cc/bcc/body deep-link args.
- bcc wired end-to-end: OutgoingMessage, SMTP + Graph senders, and outbox +
drafts persistence via Room migration v9 -> v10.
- Multi-account send is served by the existing From picker on compose.
Default mail app: Android exposes no public RoleManager email role, so the
intent filters are what make LibreMail appear on the system "Open by default"
/ default-apps screen where the platform/OEM supports it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements #32 and #33: a strictly opt-in, F-Droid-safe debug reporting
client. Nothing ever leaves the device unless the user taps Submit.
#32 capture:
- CrashReporter installs a Thread.setDefaultUncaughtExceptionHandler (wired in
LibreMailApplication) that persists a structured crash record (stack trace +
app/version/device metadata + recent log ring buffer) locally, then delegates
to the previous handler. Never auto-sent.
- RingLogBuffer + AppLog: a bounded in-memory, non-PII log ring buffer.
- DiagnosticsCollector assembles a minimal bundle: app version, Android/device,
a fixed non-PII settings allow-list, and the log buffer.
- ReportStore persists pending reports as JSON files (not Room, so crash-time
saves are robust and independent of the encrypted/migrating DB).
- "Report a problem" entry point in Settings creates a report on demand.
#33 review & submit:
- ReportReviewScreen shows the full payload verbatim (exactly what would be
sent), a free-text comment field, and a prominent PII disclaimer, with
explicit Submit / Discard and Copy / Save-to-file alternatives.
- Submission is user-initiated only: ReportUploadWorker (WorkManager, queue +
retry, success/failure surfaced) POSTs to BuildConfig.DEBUG_REPORT_ENDPOINT,
which is EMPTY by default (ingest server #34 is out of scope for this repo).
- On next launch a saved crash report is offered for review via a dialog.
Tests: 23 JVM unit tests covering crash capture + persistence (offered next
launch), diagnostic-bundle assembly (minimal, non-PII), JSON round-trip, and
the "nothing sent without Submit" invariant.
Closes#32Closes#33
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an opt-in (off by default) toggle to include app data in system
Android Backup / Auto Backup, gated so only re-creatable user
preferences are ever backed up.
- Flip allowBackup to true and add LibreMailBackupAgent, which enforces
the runtime opt-in: onFullBackup runs only when the user enables
"Include settings in Android Backup" (default off), so no data leaves
the device otherwise. allowBackup is a manifest flag and can't be
toggled at runtime, hence the agent.
- Rewrite data_extraction_rules.xml (API 31+) and add backup_rules.xml
(API 29-30) as strict allowlists that back up ONLY the
libremail_settings DataStore. The Keystore-sealed cache passphrase
(libremail_dbkey) and the encrypted credentials + mail-cache database
(libremail.db) are excluded by omission; the cache re-downloads on
next sync.
- Add includeInBackup preference + setter (nudges BackupManager on
change) and a "Backup" settings section with F-Droid-honest copy
(off by default, uses Google infrastructure).
- BackupPolicy is the single source of truth for eligible/excluded
paths; unit tests cover the toggle default and assert the shipped XML
resources include only settings and never the secrets/DB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a per-account settings area (reached by tapping an account in
Settings), starting with signatures and notifications.
- Storage: new Room `account_settings` table (schema v9, MIGRATION_8_9)
with a cascading foreign key to `accounts`; AccountSettings model and
AccountSettingsRepository (a missing row resolves to defaults).
- Signatures: plain-text per-account signature (RFC 3676 "-- "
delimiter) auto-inserted below new messages and reply/forward drafts,
and swapped when the From-account changes.
- Notifications: one notification channel + channel group per account so
Android manages sound/vibration/importance (deep-linked from the app)
and the shade bundles per account, plus an in-app per-account on/off
gate. minSdk 29 >= API 26, so channels are always available (no
pre-channel fallback needed).
- UI: per-account settings screen (signature field/toggle, notification
toggle, system deep-link, remove account); shared settings components.
Verified: JVM unit tests, lintVitalRelease (NewApi), and the full
instrumented suite (30/30) on the API 37 emulator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a long-press contextual action bar to the mailbox: Archive, Delete,
Spam, Move, Select All, and (single-selection only) Reply, Reply All, and
Forward. Reply All/Spam/Delete are confirmed first; deleting from Trash or
Spam warns that it is permanent.
- Delete moves to Trash and Spam moves to the Spam folder; only deletes
already in Trash/Spam do a permanent IMAP expunge. Archive/Spam/Move
resolve the destination per account so the unified inbox works.
- Reply/Reply All/Forward force a fresh server fetch of the original
(recipients + body via BODY.PEEK), build a quoted draft, and open compose;
a spinner shows during the fetch and they error via snackbar if offline.
Add a top-level "Message downloading" setting (Always fetch all / Fetch all
on Wi-Fi / Always on-demand; default Always) that aggressively pre-caches
full bodies and all attachment bytes during sync (outside the sync lock,
without marking mail read), into a persistent per-part attachment cache so
messages and attachments open instantly and offline.
Show an "available offline" mark on cached list rows and a per-attachment
"downloaded" check in the reader.
Extract a Syncer interface (MailSyncer implements it) so the mailbox can be
driven end-to-end in tests.
Tests: 66 unit + 27 instrumented, all passing on the API 37 emulator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Folder view: a left navigation drawer lists each account's IMAP folders;
tapping one browses and caches that folder's mail. IMAP UIDs are unique only
within a folder, so message identity, the fetch/read/flag/delete paths, sync,
and the Room cache all became folder-aware (id = "accountId:folder:uid"; new
`folder` column; schema v7->v8). Standard folders (Inbox/Sent/Drafts/Spam/Trash/
Archive) surface with friendly names + icons via RFC 6154 SPECIAL-USE attributes
with a case-insensitive name fallback; the multi-account drawer adds an account
switcher and a unified "All Inboxes". INBOX stays the only auto-synced,
IDLE-watched, notifying folder; other folders sync on demand.
Lower minSdk 33 -> 29 for a rolling ~7-year Android support window; guard the
API-33 POST_NOTIFICATIONS runtime request accordingly.
Tests and CI:
- Bump espresso-core 3.6.1 -> 3.7.0 so Compose UI tests run on API 37
(3.6.1's InputManagerEventInjectionStrategy reflects a removed hidden method).
- New coverage across layers: FolderRoleTest, ImapClientTest folder cases,
MailboxViewModelTest, MailRepositoryImplTest folder routing, a FolderDrawer
Compose UI test, and LibreMailDatabaseTest folder DAO/reconcile tests.
- Gradle Managed Devices + a CI E2E matrix over every API 29-36; a single
"CI passed" gate job fans in all jobs and is required by branch protection.
- Non-blocking, custom-provisioned API 37 (preview) E2E job with image caching.
- Build + unit-test jobs run on arm64 (ubuntu-24.04-arm); emulators stay x86_64.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
- 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>
- 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>
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>
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>