fix(mail): gracefully fall back when the IMAP server lacks UIDPLUS #428

Merged
JMR-dev merged 2 commits from fix-319-uidplus-fallback into main 2026-07-08 12:09:10 +00:00
JMR-dev commented 2026-07-08 04:19:50 +00:00 (Migrated from github.com)

What & why

Closes #319. Follow-up to #295/#318.

The targeted UID EXPUNGE (IMAPFolder.expunge(Message[])) introduced for #295 throws
MessagingException("UID EXPUNGE not supported") on an IMAP server that does not advertise the
UIDPLUS extension (RFC 4315). That was the data-loss-safe outcome, but it meant delete/expunge and
move stopped working entirely
on rare self-hosted/legacy servers. Gmail/Outlook/most modern servers
advertise UIDPLUS, so the common case was already fine.

The fix (ImapClient.expungeTargeted)

  1. Probe UIDPLUS from the folder's own already-open protocol (IMAPFolder.doCommand { it.hasCapability("UIDPLUS") }).
    • Critically, this does not use IMAPStore.hasCapability, which internally calls
      getStoreProtocol() and opens a second connection + LOGIN while the folder holds the primary one
      — that would break the one-connection-per-batch invariant from #125/#295 (and was caught locally by
      ImapFolderOpenLatencyTest). Reading the capability from the folder's existing protocol issues no
      extra network round trip.
  2. With UIDPLUS -> unchanged: targeted UID EXPUNGE, sparing other \Deleted mail (#295).
  3. Without UIDPLUS -> fall back to a plain, untargeted EXPUNGE, but only when provably safe: the
    messages we just flagged are the only \Deleted ones in the folder (countForeignDeletedMessages == 0).

Fallback-semantics tradeoff (the important design call)

A plain EXPUNGE removes every \Deleted message in the mailbox. There is no UIDPLUS-free way to
expunge one specific UID while sparing others — that is exactly the capability UIDPLUS provides. So:

  • No other \Deleted mail present (the normal case — LibreMail flags-and-expunges atomically, so it
    never leaves lingering \Deleted mail): the plain EXPUNGE removes exactly the target(s). Safe, no throw.
  • Unrelated \Deleted mail present (a second client, or a partial earlier move left mail flagged):
    a plain EXPUNGE would destroy it. Rather than touch mail the user never selected, we refuse and fail
    loud
    (a clear MessagingException -> Result.failure upstream; the optimistically-removed local row
    reappears on the next sync). This preserves the #295 invariant — never touch unrelated \Deleted mail —
    which the targeted-expunge design exists to guarantee. The alternative (temporarily clearing the foreign
    \Deleted flags around the expunge) was rejected because it mutates unrelated messages, violating that
    same invariant.

Net effect: delete/move now works on non-UIDPLUS servers in the common case; the narrow
foreign-\Deleted case degrades to a clear error instead of today's blanket failure — a strict improvement,
still data-loss-safe.

Logging (PII-free)

AppLog breadcrumbs at the fallback decision (tag ImapExpunge), counts only — no emails/hosts/UIDs/content:

  • server lacks UIDPLUS; no other \Deleted mail present, using safe plain EXPUNGE
  • server lacks UIDPLUS and N other \Deleted message(s) present; refusing plain EXPUNGE so unrelated mail is not destroyed

Tests (GreenMail, both branches)

GreenMail always advertises UIDPLUS, so the no-UIDPLUS branch is driven by an injected capability probe on
the existing internal test constructor, while every EXPUNGE still runs for real against GreenMail:

  • deleteMessages falls back to a safe plain expunge when the server lacks UIDPLUS — no throw, target
    removed, unflagged mail spared, fallback log asserted.
  • deleteMessages without UIDPLUS refuses to expunge when other Deleted mail is present — throws, and
    both the target and the bystander survive (no data loss); refusal log asserted.
  • moveMessages falls back to a safe plain expunge when the server lacks UIDPLUS.
  • The with-UIDPLUS/targeted path is covered by the existing default-client delete/move tests
    (deleteMessages ... spares other Deleted-flagged mail, the move counterpart, and ImapFolderOpenLatencyTest).

No instrumented test was touched (the UIDPLUS logic is a server-integration concern the fakes-based UI E2E
can't reach); compileDebugAndroidTestKotlin passes and E2E is validated by CI's matrix.

Local gate (all green)

assembleDebug + testDebugUnitTest (1388 tests) + jacocoTestCoverageVerification (0.84 floor) +
compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt — BUILD SUCCESSFUL.

## What & why Closes #319. Follow-up to #295/#318. The targeted `UID EXPUNGE` (`IMAPFolder.expunge(Message[])`) introduced for #295 throws `MessagingException("UID EXPUNGE not supported")` on an IMAP server that does **not** advertise the UIDPLUS extension (RFC 4315). That was the data-loss-safe outcome, but it meant **delete/expunge and move stopped working entirely** on rare self-hosted/legacy servers. Gmail/Outlook/most modern servers advertise UIDPLUS, so the common case was already fine. ## The fix (`ImapClient.expungeTargeted`) 1. **Probe UIDPLUS from the folder's own already-open protocol** (`IMAPFolder.doCommand { it.hasCapability("UIDPLUS") }`). - Critically, this does **not** use `IMAPStore.hasCapability`, which internally calls `getStoreProtocol()` and opens a **second connection + LOGIN** while the folder holds the primary one — that would break the one-connection-per-batch invariant from #125/#295 (and was caught locally by `ImapFolderOpenLatencyTest`). Reading the capability from the folder's existing protocol issues no extra network round trip. 2. **With UIDPLUS** -> unchanged: targeted `UID EXPUNGE`, sparing other `\Deleted` mail (#295). 3. **Without UIDPLUS** -> fall back to a plain, untargeted `EXPUNGE`, **but only when provably safe**: the messages we just flagged are the *only* `\Deleted` ones in the folder (`countForeignDeletedMessages == 0`). ## Fallback-semantics tradeoff (the important design call) A plain `EXPUNGE` removes **every** `\Deleted` message in the mailbox. There is no UIDPLUS-free way to expunge one specific UID while sparing others — that is exactly the capability UIDPLUS provides. So: - **No other `\Deleted` mail present** (the normal case — LibreMail flags-and-expunges atomically, so it never leaves lingering `\Deleted` mail): the plain `EXPUNGE` removes exactly the target(s). Safe, no throw. - **Unrelated `\Deleted` mail present** (a second client, or a partial earlier move left mail flagged): a plain `EXPUNGE` would destroy it. Rather than touch mail the user never selected, we **refuse and fail loud** (a clear `MessagingException` -> `Result.failure` upstream; the optimistically-removed local row reappears on the next sync). This preserves the #295 invariant — *never touch unrelated `\Deleted` mail* — which the targeted-expunge design exists to guarantee. The alternative (temporarily clearing the foreign `\Deleted` flags around the expunge) was rejected because it mutates unrelated messages, violating that same invariant. Net effect: delete/move now works on non-UIDPLUS servers in the common case; the narrow foreign-`\Deleted` case degrades to a clear error instead of today's blanket failure — a strict improvement, still data-loss-safe. ## Logging (PII-free) `AppLog` breadcrumbs at the fallback decision (tag `ImapExpunge`), counts only — no emails/hosts/UIDs/content: - `server lacks UIDPLUS; no other \Deleted mail present, using safe plain EXPUNGE` - `server lacks UIDPLUS and N other \Deleted message(s) present; refusing plain EXPUNGE so unrelated mail is not destroyed` ## Tests (GreenMail, both branches) GreenMail always advertises UIDPLUS, so the no-UIDPLUS branch is driven by an injected capability probe on the existing internal test constructor, while every EXPUNGE still runs for real against GreenMail: - `deleteMessages falls back to a safe plain expunge when the server lacks UIDPLUS` — no throw, target removed, unflagged mail spared, fallback log asserted. - `deleteMessages without UIDPLUS refuses to expunge when other Deleted mail is present` — throws, and **both** the target and the bystander survive (no data loss); refusal log asserted. - `moveMessages falls back to a safe plain expunge when the server lacks UIDPLUS`. - The **with-UIDPLUS/targeted** path is covered by the existing default-client delete/move tests (`deleteMessages ... spares other Deleted-flagged mail`, the move counterpart, and `ImapFolderOpenLatencyTest`). No instrumented test was touched (the UIDPLUS logic is a server-integration concern the fakes-based UI E2E can't reach); `compileDebugAndroidTestKotlin` passes and E2E is validated by CI's matrix. ## Local gate (all green) `assembleDebug` + `testDebugUnitTest` (1388 tests) + `jacocoTestCoverageVerification` (0.84 floor) + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt` — **BUILD SUCCESSFUL**.
mergify[bot] commented 2026-07-08 12:09:04 +00:00 (Migrated from github.com)

Merge Queue Status

  • ✅ Entered queue — 2026-07-08 12:09 UTC · Rule: default · triggered by merge protections
  • ✅ Checks skipped · PR is already up-to-date
  • ✅ Merged — 2026-07-08 12:09 UTC · at b5b789f6c7256db7bbeacc3e7207c06c0671adc2 · merge

This pull request spent 9 seconds in the queue, including 2 seconds running CI.

Required conditions to merge
<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "merged", "queue_rule_name": "default", "queued_at": "2026-07-08T12:09:02.391444+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-08 12:09 UTC` · Rule: `default` · triggered by merge protections - ✅ **Checks skipped** · PR is already up-to-date - ✅ **Merged** — `2026-07-08 12:09 UTC` · at `b5b789f6c7256db7bbeacc3e7207c06c0671adc2` · merge This pull request spent **9 seconds** in the queue, including **2 seconds** running CI. <details> <summary>Required conditions to merge</summary> - `-conflict` - [X] #428 - `-draft` - [X] #428 - [X] `base = main` - [X] `check-success = CI passed` - `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] #428 - `label != broken` - [X] #428 - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Debug build` - [ ] `check-neutral = Debug build` - [ ] `check-skipped = Debug build` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Unit tests` - [ ] `check-neutral = Unit tests` - [ ] `check-skipped = Unit tests` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = CI passed` - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [X] any of [🛡 GitHub repository ruleset rule `main`]: - [X] `check-success = @github-actions/CI passed` - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` </details>
Sign in to join this conversation.