fix(mail): targeted + batch expunge (stop deleting unrelated \Deleted mail) #318

Merged
JMR-dev merged 2 commits from fix-295-targeted-batch-expunge into main 2026-07-04 07:44:21 +00:00
JMR-dev commented 2026-07-04 07:25:39 +00:00 (Migrated from github.com)

Closes #295

The bug (MEDIUM — data loss + efficiency)

ImapClient.deleteMessage / 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. Any message a second client, Gmail, or a partial earlier move left flagged \Deleted gets destroyed. 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.

The fix

  • New ImapClient.deleteMessages(uids): 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.
  • moveMessages now expunges through the same targeted helper.
  • Single-UID deleteMessage delegates to deleteMessages (so a lone delete is targeted too); its external behavior is preserved.
  • MailRepositoryImpl.expunge and the moveByRole trash-fallback route through the batch method; moveToFolder already batched via moveMessages, so it inherits the fix.

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 2.1.9 all advertise UIDPLUS.

Tests (GreenMail — no emulator)

  • deleteMessages expunges only the given uids and spares other Deleted-flagged mail — flags a bystander \Deleted via a second connection, deletes a different target, asserts the bystander (and an unflagged message) survive. This fails against the old untargeted expunge().
  • moveMessages expunges only the moved uids and spares other Deleted-flagged mail — same targeting proof for the move path.
  • deleteMessages removes every uid in the batch — batch correctness.
  • a batch delete opens one connection and pays one LOGIN for the whole selection — via the existing CountingImapProxy: a 3-message batch delete opens exactly 1 connection / 1 LOGIN, proving the N-login inefficiency is gone.
  • Updated the repo tests whose batch paths now call deleteMessages.

Validation

:app:testDebugUnitTest + :app:compileDebugAndroidTestKotlin + :app:ktlintCheck + :app:detekt all green (JDK 21).

🤖 Generated with Claude Code

Closes #295 ## The bug (MEDIUM — data loss + efficiency) `ImapClient.deleteMessage` / `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. Any message a second client, Gmail, or a partial earlier move left flagged `\Deleted` gets destroyed. 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. ## The fix - New `ImapClient.deleteMessages(uids)`: 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. - `moveMessages` now expunges through the same targeted helper. - Single-UID `deleteMessage` delegates to `deleteMessages` (so a lone delete is targeted too); its external behavior is preserved. - `MailRepositoryImpl.expunge` and the `moveByRole` trash-fallback route through the batch method; `moveToFolder` already batched via `moveMessages`, so it inherits the fix. 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 2.1.9 all advertise UIDPLUS. ## Tests (GreenMail — no emulator) - `deleteMessages expunges only the given uids and spares other Deleted-flagged mail` — flags a bystander `\Deleted` via a second connection, deletes a different target, asserts the bystander (and an unflagged message) survive. This fails against the old untargeted `expunge()`. - `moveMessages expunges only the moved uids and spares other Deleted-flagged mail` — same targeting proof for the move path. - `deleteMessages removes every uid in the batch` — batch correctness. - `a batch delete opens one connection and pays one LOGIN for the whole selection` — via the existing `CountingImapProxy`: a 3-message batch delete opens exactly **1** connection / **1** LOGIN, proving the N-login inefficiency is gone. - Updated the repo tests whose batch paths now call `deleteMessages`. ## Validation `:app:testDebugUnitTest` + `:app:compileDebugAndroidTestKotlin` + `:app:ktlintCheck` + `:app:detekt` all green (JDK 21). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.