Commit Graph
62 Commits
Author SHA1 Message Date
JMR-devandClaude Opus 4.8 291c9a2b4d Merge branch 'main' into feat-debug-reporting
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>
2026-07-01 10:18:28 -05:00
Jason Ross f47efadfa6 Merge pull request #44 from JMR-dev/feat-onboarding-flow
feat(onboarding): first-run flow, vendor picker, and app-password setup (#26-#31)
2026-07-01 10:10:58 -05:00
JMR-dev 781966e0a0 Merge remote-tracking branch 'origin/main' into feat-onboarding-flow 2026-07-01 09:59:41 -05:00
Jason Ross 085475bf93 Merge pull request #41 from JMR-dev/feat-backup-optin
feat(backup): opt-in Android Backup for settings only (#21)
2026-07-01 09:59:39 -05:00
JMR-dev df5b99aff9 Merge remote-tracking branch 'origin/main' into feat-backup-optin 2026-07-01 09:49:24 -05:00
Jason Ross 4504d34fc6 Merge pull request #40 from JMR-dev/fix-remove-gmail-oauth
refactor(auth): remove dead Gmail OAuth code path (#39)
2026-07-01 09:41:41 -05:00
JMR-devandClaude Opus 4.8 25e1a8b83c test(onboarding): scroll app-password fields/button into view before tapping
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>
2026-07-01 09:27:29 -05:00
JMR-devandClaude Opus 4.8 876539b514 test(onboarding): widen onboarding E2E waitForText timeout to 15s
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>
2026-07-01 08:41:05 -05:00
JMR-devandClaude Opus 4.8 3ea166607c test(reporting): compile instrumented SettingsScreen test with onReportProblem
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>
2026-07-01 08:41:01 -05:00
JMR-devandClaude Opus 4.8 e2e2c36d1d feat(onboarding): first-run flow, vendor picker, and app-password setup
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>
2026-07-01 00:13:32 -05:00
JMR-devandClaude Opus 4.8 d1b0593a8c feat(reporting): opt-in debug reporting client (capture + review/submit)
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 #32
Closes #33

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:03:13 -05:00
JMR-devandClaude Opus 4.8 ffbb882227 feat(backup): opt-in Android Backup for settings only
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>
2026-06-30 23:56:18 -05:00
JMR-devandClaude Opus 4.8 657af48052 refactor(auth): remove dead Gmail OAuth code path
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>
2026-06-30 23:44:17 -05:00
Jason Ross 64fb82ebd9 Merge pull request #8 from JMR-dev/chore/kotlin-linting
chore: Kotlin linting (ktlint + detekt), CLAUDE.md tooling, and CI enforcement
2026-06-30 22:01:37 -05:00
JMR-dev c129701590 claude settings and gradle config 2026-06-30 21:48:03 -05:00
JMR-devandClaude Opus 4.8 9ce88a881d build: pin the Gradle daemon to JDK 21
AGP 9.2 does not support JDK 25; committing the daemon-JVM criteria makes
every contributor's Gradle daemon run on JDK 21 regardless of JAVA_HOME.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:44:12 -05:00
JMR-devandClaude Opus 4.8 0754ad4ebf ci: enforce ktlint + detekt on pull requests
Add a `static-analysis` job (JDK 21 + Android SDK) that runs
`:app:ktlintCheck :app:detekt` and uploads the reports, and add it to the
`ci-passed` aggregating gate so lint regressions block merges like the
other checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:44:12 -05:00
JMR-devandClaude Opus 4.8 921a9a0668 chore(lint): adopt ktlint + detekt, format and fix all findings
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>
2026-06-30 21:42:52 -05:00
JMR-devandClaude Opus 4.8 c24c53ea01 chore: add CLAUDE.md, SPDX-header hook, and preflight skill
- CLAUDE.md: build gotchas (JDK 17-21, AGP built-in Kotlin, KSP-not-KAPT),
  test stack, the SPDX header rule, Conventional Commits, and commands.
- .claude/settings.json + hooks/check-spdx.py: PostToolUse hook that warns
  (non-blocking) when a .kt/.kts file lacks the SPDX license header.
- .claude/skills/preflight: runs the fast CI gate (assembleDebug +
  testDebugUnitTest + lintDebug) locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:42:31 -05:00
Jason Ross 0adf1319f6 Merge pull request #7 from JMR-dev/feat-per-account-settings
feat: per-account settings for signatures and notifications
2026-06-30 20:28:54 -05:00
JMR-devandClaude Opus 4.8 97950d0b6c fix(test): stop closing the in-memory DB under the still-active AccountSettings VM
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>
2026-06-30 20:20:30 -05:00
JMR-devandClaude Opus 4.8 40290660ae feat: per-account settings for signatures and notifications
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>
2026-06-30 20:05:37 -05:00
Jason Ross 3bcbf15e14 Merge pull request #6 from JMR-dev/fix-dark-mode-colors
Fix dark-mode reader readability + make API 37 E2E required
2026-06-30 18:35:08 -05:00
Jason Ross 9083042f2f Merge branch 'main' into fix-dark-mode-colors 2026-06-30 18:26:01 -05:00
JMR-devandClaude Opus 4.8 7987333149 ci: require the API 37 preview E2E job to merge
The custom-provisioned API 37 preview emulator has been stable, so fold its E2E
job into the aggregating "CI passed" gate's needs. Because branch protection
requires only that single check, no settings change is needed.

Drop the now-inaccurate "non-blocking" wording from the job name and comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:17:15 -05:00
JMR-devandClaude Opus 4.8 8e29aebc2e fix: render reader emails readably in dark mode
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>
2026-06-30 18:17:06 -05:00
Jason Ross b44e0a7887 Merge pull request #5 from JMR-dev/feat-long-press-select
feat: long-press multi-select, aggressive prefetch, and download indicators
2026-06-30 17:29:53 -05:00
JMR-devandClaude Opus 4.8 96111b40df feat: long-press multi-select, aggressive prefetch, and download indicators
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>
2026-06-30 17:20:22 -05:00
Jason Ross d090f721e6 Merge pull request #4 from JMR-dev/fix/e2e-preview
ci: fix hung API 37 preview emulator job
2026-06-30 13:17:18 -05:00
JMR-devandClaude Opus 4.8 fe0593dba1 ci: pin ANDROID_AVD_HOME so the API 37 preview emulator finds its AVD
The preview emulator failed every boot with "Unknown AVD name [api37]" and
exited immediately (no device -> the bounded wait timed out). avdmanager had
created the AVD under $ANDROID_SDK_HOME/.android/avd while the emulator searched
$ANDROID_SDK_HOME/avd and $HOME/.android/avd. Pin ANDROID_AVD_HOME to one path
both tools use, carry it to the boot step via $GITHUB_ENV, and list AVDs after
creation to verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:59:35 -05:00
JMR-devandClaude Opus 4.8 b8086f89df ci: make API 37 preview emulator boot fail-fast and diagnosable
The custom-provisioned preview job hung in 'Boot emulator and run E2E' until
the 35-min cap: adb wait-for-device had no timeout and the emulator's own
output was never captured, so a failed boot was both invisible and unbounded.
Bound the wait with a single 300s timeout covering device registration + full
boot, retry once, capture the emulator log, and dump it (plus logcat) on
failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:45:41 -05:00
Jason Ross e175ac13be Merge pull request #3 from JMR-dev/feat-folder-view
Add IMAP folder navigation drawer + per-API E2E CI matrix
2026-06-30 12:36:30 -05:00
JMR-devandClaude Opus 4.8 957da0c46f ci: run debug-build and unit-tests on x86_64
Linux-arm64 runners can't set up the SDK here: android-actions/setup-android's
sdkmanager fails (exit 1) on the android-37.0 preview platform, and the emulator
package has no arm64-Linux build. These are pure build/JVM-unit jobs whose
results are host-arch-independent, so x86_64 loses no device coverage (real
arm64 ABI coverage would require arm64 emulators, i.e. macOS hosts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:28:55 -05:00
JMR-devandClaude Opus 4.8 13a32df873 Add IMAP folder navigation drawer + per-API E2E CI matrix
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>
2026-06-30 12:23:00 -05:00
Jason Ross 7e427e1295 Merge pull request #2 from JMR-dev/fix-outlook-login-flow
Fix Outlook sign-in crash and complete the OAuth login flow
2026-06-29 23:26:09 -05:00
JMR-devandClaude Opus 4.8 317ce24054 Fix Outlook sign-in crash and complete the OAuth login flow
"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>
2026-06-29 23:15:14 -05:00
Jason Ross 12598629f5 Merge pull request #1 from JMR-dev/fix/code-review-findings
Fix/code review findings
v0.1.0
2026-06-29 22:02:15 -05:00
JMR-devandClaude Opus 4.8 669946ea34 Add CI/release workflows and first Compose UI tests
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>
2026-06-29 21:36:41 -05:00
JMR-devandClaude Opus 4.8 96c9f71a26 Harden transport security and add opt-in encrypted local cache
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>
2026-06-29 19:50:27 -05:00
JMR-devandClaude Opus 4.8 038cfd6153 Fix correctness, security, and concurrency issues from code review
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>
2026-06-29 18:15:53 -05:00
JMR-devandClaude Opus 4.8 f0b4ee6f57 Remove the "Sign in with Google" account-setup option
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>
2026-06-27 20:55:15 -05:00
JMR-devandClaude Opus 4.8 3d93f76fc1 Send Outlook mail via Microsoft Graph (SMTP fallback)
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>
2026-06-27 20:18:10 -05:00
JMR-devandClaude Opus 4.8 1f63773faa Add Outlook / Microsoft account support (OAuth)
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>
2026-06-27 18:20:41 -05:00
JMR-devandClaude Opus 4.8 23afe78fe0 Enable R8 for release builds
- 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>
2026-06-27 17:06:50 -05:00
JMR-devandClaude Opus 4.8 57b628f90f Add server-side IMAP search
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>
2026-06-27 17:00:28 -05:00
JMR-devandClaude Opus 4.8 f65226a4d2 Send attachments
- 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>
2026-06-27 16:54:39 -05:00
JMR-devandClaude Opus 4.8 5c98f3ba1c Add an outbox folder: view, retry, and cancel queued sends
- 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>
2026-06-27 16:39:36 -05:00
JMR-devandClaude Opus 4.8 dc3147a138 Add drafts: save-for-later and resume
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>
2026-06-27 16:24:15 -05:00
JMR-devandClaude Opus 4.8 85ba9d9846 Add an outbox: reliable, WorkManager-backed send
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>
2026-06-27 15:46:49 -05:00
JMR-devandClaude Opus 4.8 fcd84fb933 Add inbox search over cached mail
- 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>
2026-06-27 15:20:19 -05:00