Commit Graph
10 Commits
Author SHA1 Message Date
JMR-devandClaude Opus 4.8 f72c66d291 fix(mail): targeted + batch expunge (stop deleting unrelated \Deleted mail)
ImapClient.deleteMessage and moveMessages flagged the target \Deleted then
called the untargeted Folder.expunge(), which permanently removes EVERY
\Deleted-flagged message in the folder — not just the intended UIDs. That is a
data-loss window whenever a second client, Gmail, or a partial earlier move has
left other messages flagged \Deleted. The repository's batch delete/expunge and
trash-fallback paths also looped single-UID deleteMessage, paying N logins + N
expunges for an N-message selection.

Add a batch deleteMessages(uids) that opens the folder once, flags the matched
messages \Deleted, and issues a single targeted UID EXPUNGE (RFC 4315) via
IMAPFolder.expunge(Message[]) through a shared expungeTargeted() helper. Route
moveMessages through the same helper, delegate single-UID deleteMessage to
deleteMessages, and route MailRepositoryImpl.expunge and the moveByRole trash
fallback through the batch method. moveToFolder already batches via moveMessages,
so it inherits the targeted expunge.

On a server without UIDPLUS, Angus raises "UID EXPUNGE not supported" rather than
silently falling back to the unrelated-mail-destroying untargeted expunge — a
loud failure is the safe outcome. Gmail, Outlook, and GreenMail all advertise
UIDPLUS.

Tests (GreenMail, no emulator): deleteMessages/moveMessages expunge only the
given UIDs and spare other \Deleted-flagged mail; a batch delete of three
messages opens exactly one connection and pays one LOGIN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 02:24:40 -05:00
JMR-devandClaude Opus 4.8 be0e699fcc test(coverage): lane 2 — sync, workers, transport & auth to >=95%
Test-only (zero production changes). Raises JVM unit-test LINE coverage
for the sync/worker, IMAP/SMTP/Graph transport, and OAuth packages:
data/sync 98.6%, mail 96.7%, auth 100.0% LINE.

New/extended cover:
- SendWorker outbox drain (SMTP/Graph, may-have-sent, SMTP fallback, staged
  attachments), MailConnectionFactory token cache/refresh, MailSyncer.syncAll,
  SendScheduler, MailBackfiller pre-existing-row refresh.
- ImapConnectionCache reuse + drop-retry, ImapClient fetchAttachment/setFlag/
  deleteMessage/idle + edge cases, GraphSender.send transport.
- OutlookAuthManager token exchange/refresh + failure branches, OAuth models.

Instruction/branch coverage stays lower (coroutine suspend-state synthetics
under synchronous mocks) — a known JaCoCo x coroutines limitation, not
untested logic; JaCoCo config is untouched (owned by the capstone lane).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:54:12 -05:00
JMR-devandClaude Fable 5 3971e89d1e fix(reader): render inline cid: images in HTML emails
Inline images in rich HTML emails (embedded via Content-ID and
<img src="cid:...">, e.g. USPS Informed Delivery digests) were listed
under Attachments with a download button and never rendered in the body.
Two bugs combined; both are fixed here.

1. Misclassification: ImapClient classified any part with a filename as
   an attachment, sweeping inline images (which carry a filename AND a
   Content-ID under Content-Disposition: inline) into the list. A part is
   now a downloadable attachment only when its disposition is attachment,
   or it has a filename but no Content-ID; an inline image is collected
   separately and excluded from the displayed list (AttachmentDao filters
   contentId IS NULL). The Content-ID is read via MimePart.getContentID()
   so it resolves from IMAP BODYSTRUCTURE rather than a per-part header
   fetch that Angus leaves unpopulated.

2. No rendering path: HtmlBody's WebViewClient now overrides
   shouldInterceptRequest to resolve cid:<id> to the matching part's
   bytes (backing the CSP's existing cid: allowance). Content-ID is
   threaded end-to-end through AttachmentPart, Attachment,
   AttachmentEntity, and MailRepository.inlineImages(); ReaderViewModel
   surfaces the cid->bytes map to the WebView.

Schema: adds attachments.contentId (v16 -> v17, MIGRATION_16_17).

Tests: MIME-part classification (inline+cid excluded, real/disposition/
filename-only kept), a GreenMail multipart/related round-trip, the
cid->bytes resolver, repository inlineImages(), the DAO display filter,
and the v16->v17 migration.

Closes #133

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:44:05 -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 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
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
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 d81e94717b Add message reading: body fetch + hardened WebView + flag actions
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>
2026-06-26 19:05:55 -05:00
JMR-devandClaude Opus 4.8 5386ae76d3 Add IMAP receive: background sync of inbox into Room
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>
2026-06-26 18:25:54 -05:00
JMR-devandClaude Opus 4.8 9c74510832 Add account setup: Gmail OAuth + IMAP/SMTP with encrypted credentials
Increment 2 — authentication and account management.

- Gmail OAuth 2.0 via AppAuth (Authorization Code + PKCE, restricted
  https://mail.google.com/ scope); redirect scheme derived from the client id.
- Generic IMAP/SMTP manual setup (host/port/security) with an Advanced section.
- Angus/Jakarta Mail IMAP client (password + XOAUTH2); "test connection" logs in
  and lists folders before an account is saved.
- Android Keystore-backed AES-256-GCM credential store (encrypts the OAuth
  AuthState / IMAP password); accounts + secrets persisted in Room (schema v2).
- AccountRepository + Hilt wiring; Settings accounts list (add / remove).
- Tests: GreenMail-backed IMAP client unit test; instrumented Keystore round-trip
  and Angus Mail provider-resolution tests (green on the Android 17 emulator).
- Remove the placeholder Compose smoke test (the API 37 Compose-UI-test library
  hits InputManager.getInstance); on-device rendering verified via screenshots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:26:57 -05:00