Prepare for F-Droid publication (issue #16):
- docs/fdroid-compliance.md: full dependency license audit (release
runtime classpath + buildscript classpath — all FOSS, no Play
Services/Firebase, no non-free Gradle plugins), an anti-feature
review of actual app behavior (none to declare: debug reporting is
opt-in/local-only with no endpoint by default, Android Backup is
gated off by default, Outlook OAuth is optional per-account with a
public client id), a complete network-surface inventory, and the
clean-room build verification (assembleRelease succeeds with no
secrets.properties).
- app/build.gradle.kts: stop embedding AGP's dependency-info block (a
Google-Play-encrypted dependency list in the APK signing block) in
APKs/bundles — a known F-Droid inclusion/reproducibility blocker.
- fastlane/metadata/android/en-US/: store listing (title, short/full
description, changelog for versionCode 1) that F-Droid reads from
the repo; listing .txt files deliberately carry no license headers.
- docs/fdroid/org.libremail.app.yml: commented template + instructions
for the eventual fdroiddata build recipe (submission out of scope).
- README.md: F-Droid section pointing at the above.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code-review fixes: an all-empty paragraph group (a blank line isolated by an
alignment split) emitted <p></p>, which the parser collapses — it now emits one
<br> per line so blank lines round-trip. The identical span-merge helper that
existed in both the parser and RichTextEditing is now a single shared
mergeSameValueSpans() in RichText.kt, and the private applyBlock/applyLink drop
their never-used default font resolver.
Co-Authored-By: Claude Fable 5 <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>
The Cc and Bcc fields now start collapsed into small left-aligned link
buttons under the To box, freeing about two field heights of vertical
space for the message body. Tapping a link expands it into the regular
input field and focuses it; a field also expands on its own when it
already carries recipients (reply-all/mailto prefill, resumed drafts)
and never re-collapses once shown, so it cannot vanish mid-edit. The
expansion state lives in the UI via rememberSaveable and survives
rotation. Moving the Bcc field also gives it the medium shape every
sibling field already had.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
saveOrDeleteDraft persists the Bcc line, but the init-block restore
never copied it back, so reopening a draft silently dropped its Bcc
recipients (and re-saving then lost them for good).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tapping a new-mail notification only brought the app to the foreground:
the content PendingIntent was a bare launch intent shared by every
notification, and nothing on the activity side handled a message target.
Per-message notifications now carry an explicit open-message intent —
action + id extra + a per-message data URI, so each message keeps its
own PendingIntent under filterEquals instead of all collapsing onto one
FLAG_UPDATE_CURRENT entry. MainActivity parses the id on fresh launch
and in onNewIntent and hands it to the NavHost as pending state (the
pendingCompose handoff pattern) to navigate to the reader. The group
summary keeps the plain open-the-app intent.
Fixes#56
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Body expression on the signature line (function-signature) and one
argument per wrapped line (argument-list-wrapping) in the tests added
for drawer folder de-duplication.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The drawer rendered every standard-role folder with a generic friendly
name (e.g. "Drafts") and discarded the server name, so a Gmail account
with both a provider built-in folder and a same-named user folder showed
two identical entries (Drafts, Archive, Spam).
De-duplicate labels provider-agnostically: when 2+ folders would render
the same name, the provider's built-in special folder (identified by RFC
6154 SPECIAL-USE flags, now persisted on the folder cache) gets the
provider name appended ("Archive - Gmail"), a nested user folder gets its
parent location ("Reports (Work)"), and a top-level user folder keeps its
plain name. Only triggers on a real collision, so stock accounts are
unchanged.
Adds a `specialUse` column to the folders table (Room v11 -> v12).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MessageDao.observeAll() ran `SELECT * FROM messages` and returned full
MessageEntity rows — including the potentially large body/isHtml columns —
for every cached message at once. Dragging big HTML bodies through SQLite's
shared ~2 MB CursorWindow overflowed it once enough bodies were cached,
crashing with "Couldn't read row N from CursorWindow" (#51).
Replace it with observeSummaries(), a body-less column projection into a new
lightweight MessageSummary POJO. The list never renders or searches the body,
and the reader already loads it lazily per-message via getById when a message
is opened, so nothing else needs it.
Add a regression test that reads back rows whose bodies exceed the window.
Closes#51
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>
Real cause of the API 29-36 E2E timeout (the earlier 5s->15s bump didn't help,
proving it wasn't slowness): AppPasswordSetupScreen is a scrolling Column and the
"Test and add" button sits below the fold on the short default matrix emulator, so
the positional performClick was a silent no-op -> no add -> no navigation -> the
add-another wait never resolved. It passed on API 37 only because that job uses a
taller pixel_2 AVD. performScrollTo() each field + the button before interacting,
matching the existing pattern in SettingsScreenTest. Verified compileDebugAndroid
TestKotlin + ktlintCheck on JDK 21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OnboardingFlowTest passed on the API-37 job but timed out (ComposeTimeoutException
after 5000ms) across the animation-disabled API 29-36 matrix, at the single
async-gated transition: click -> viewModelScope coroutine -> addImapAccount ->
DONE -> LaunchedEffect -> navigate -> AddAnother render. The flow is correct
(green on API 37; ManualSetupScreenTest proves the add-callback path); the 5s cap
was just too tight for that compound step on slower matrix emulators. waitUntil
returns as soon as the text appears, so the happy path is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The #32/#33 change added a required onReportProblem parameter to SettingsScreen
but left the existing instrumented SettingsScreenTest calling the old signature,
so :app:compileDebugAndroidTestKotlin failed in every E2E job (the local fast
gate never compiles the androidTest variant). Pass onReportProblem = {} in the
test. Verified with :app:compileDebugAndroidTestKotlin on JDK 21.
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>
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>
Gmail authenticates via app password + preconfigured IMAP/SMTP (decision
in #9), never OAuth, so the Gmail-OAuth implementation was unreachable
dead code. Remove it while keeping Outlook's AppAuth OAuth path intact.
- Delete auth/GmailAuthManager.kt (shared OAuthResult/FreshToken kept).
- Drop AuthType.OAUTH_GMAIL and its exhaustive `when` branch, the
gmailAuthManager injection, and the SCOPE_GMAIL constant in
MailConnectionFactory.
- Remove GMAIL_OAUTH_* BuildConfig fields, gmailOAuthClientId, and the
gmailRedirectScheme val from app/build.gradle.kts.
- Repoint the AppAuth manifestPlaceholders["appAuthRedirectScheme"] to
the Outlook scheme (org.libremail.outlook). AppAuth's bundled manifest
now registers that scheme on RedirectUriReceiverActivity, so the app
manifest's now-redundant Outlook intent-filter is removed; the
AppCompat theme override (crash fix) is preserved. Verified the merged
manifest.
- Drop GMAIL_OAUTH_CLIENT_ID from secrets.properties.example.
authType persists as the enum name in a TEXT column, so removing a
constant needs no Room schema change or migration.
Closes#39
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire ktlint-gradle 14.2.0 and detekt 2.0.0-alpha.5 (the only detekt line
with Gradle 9 support) through the version catalog.
- .editorconfig: official Kotlin style, 120-col limit, @Composable exempt
from function-naming.
- config/detekt/detekt.yml: slim overrides on detekt's defaults —
@Composable exemptions for the OOP-era metrics, sane ReturnCount /
ThrowsCount / TooManyFunctions thresholds, and TooGenericExceptionCaught
off at the resilient network/push boundaries (which now log).
Findings fixed in code (behaviour-preserving; 81 unit tests still pass):
- SwallowedException: SendWorker / IdleService now log the caught exception.
- roleOf (Folder) and extractBody (ImapClient) split into named helpers.
- MailSyncer: hoisted a 4-condition `if` into a named val.
- TopDest extracted to its own file; ~14 magic numbers -> named constants.
The remainder is the ktlint auto-format across the module.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AccountSettingsScreenTest closed its Room in-memory database in @After while
the ViewModel's `settings` Flow (kept alive by stateIn/WhileSubscribed) was
still querying it. That race surfaced as "connection pool has been closed"
(API 29) and "attempt to re-open an already-closed object" (API 36) on the
slower CI legs, while passing on 30-35/37 and locally.
Leave the in-memory DB unclosed (reclaimed with the test process) so the
lingering flow never hits a closed connection.
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>
The reader rendered HTML emails as black-on-black in dark mode: the WebView
background was transparent (so the near-black app surface showed through) and the
injected CSS set no text or background color, so the WebView fell back to its
default black text.
Wrap each email with explicit, theme-derived background, text, and link colors
(surface / onSurface / primary) plus a matching color-scheme, set the WebView
background to the surface color, and allow WebView algorithmic darkening where
supported as a backstop for emails that hardcode their own foreground colors.
Add JVM contrast guards: HtmlBodyTest pins the wrapper's readability contract
(explicit colors meeting WCAG AA), and ColorSchemeContrastTest audits the
fallback light/dark Material schemes' role pairs.
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>
"Sign in with Microsoft" crashed on the redirect back from the browser and
never completed a login. Verified end-to-end on a real Outlook account after
three fixes, in flow order:
- Redirect crash (the reported symptom): AppAuth's RedirectUriReceiverActivity
extends AppCompatActivity, so it needs a Theme.AppCompat theme. This app is
pure Compose (framework Theme.Material), and AppAuth declares that activity
with no theme of its own, so it inherited the Material app theme and threw
"You need to use a Theme.AppCompat theme" the instant Android launched it to
deliver the redirect. Give it the translucent AppCompat theme AppAuth itself
applies to AuthorizationManagementActivity. (Not an R8 issue.)
- Token exchange rejected with AADSTS70011 ("must include a 'scope' input
parameter"): one consent spans two Microsoft resources (Graph for send,
Exchange Online for IMAP), so Microsoft mints one token per resource and the
code-to-token exchange must name a single resource. AppAuth's
createTokenExchangeRequest() sends no scope; build the request explicitly
with a single-resource scope.
- "Invalid ID Token" / nonce mismatch: the hand-built exchange request must
replicate every field createTokenExchangeRequest() sets, including the nonce
AppAuth validates the id_token against (and the PKCE code verifier).
Also harden the account-setup screen: guard the previously unguarded
createAuthIntent() launch (AppAuth throws ActivityNotFoundException when no
browser is available) so it surfaces an error instead of crashing, dispose the
AuthorizationService that createAuthIntent() leaked, and log sign-in failures
via Log.d (stripped from release builds by the existing ProGuard rule).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GitHub Actions (every action pinned to its commit SHA, version in a comment):
- ci.yml, on pull_request to main, runs three jobs: a debug build, the
unit tests, and the instrumented suite on a headless emulator.
- release.yml, on workflow_dispatch, builds the release APK, archives the
source as zip + tar.gz, and publishes a GitHub release.
Compose UI tests (app/src/androidTest), driving the real screens with
fake-backed view models so they need no network, database, or Hilt graph:
- ManualSetupScreen: submit-button validation, advanced-options toggle,
and add-account success/failure.
- ComposeScreen: send-button enablement and the send -> close flow.
- LibreMailBottomBar: tab rendering and selection callback.
Mark gradlew executable (100755) so it runs on the Linux CI runners.
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>
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>