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)
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.
With UIDPLUS -> unchanged: targeted UID EXPUNGE, sparing other \Deleted mail (#295).
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.
## 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**.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What & why
Closes #319. Follow-up to #295/#318.
The targeted
UID EXPUNGE(IMAPFolder.expunge(Message[])) introduced for #295 throwsMessagingException("UID EXPUNGE not supported")on an IMAP server that does not advertise theUIDPLUS 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)IMAPFolder.doCommand { it.hasCapability("UIDPLUS") }).IMAPStore.hasCapability, which internally callsgetStoreProtocol()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 noextra network round trip.
UID EXPUNGE, sparing other\Deletedmail (#295).EXPUNGE, but only when provably safe: themessages we just flagged are the only
\Deletedones in the folder (countForeignDeletedMessages == 0).Fallback-semantics tradeoff (the important design call)
A plain
EXPUNGEremoves every\Deletedmessage in the mailbox. There is no UIDPLUS-free way toexpunge one specific UID while sparing others — that is exactly the capability UIDPLUS provides. So:
\Deletedmail present (the normal case — LibreMail flags-and-expunges atomically, so itnever leaves lingering
\Deletedmail): the plainEXPUNGEremoves exactly the target(s). Safe, no throw.\Deletedmail present (a second client, or a partial earlier move left mail flagged):a plain
EXPUNGEwould destroy it. Rather than touch mail the user never selected, we refuse and failloud (a clear
MessagingException->Result.failureupstream; the optimistically-removed local rowreappears on the next sync). This preserves the #295 invariant — never touch unrelated
\Deletedmail —which the targeted-expunge design exists to guarantee. The alternative (temporarily clearing the foreign
\Deletedflags around the expunge) was rejected because it mutates unrelated messages, violating thatsame invariant.
Net effect: delete/move now works on non-UIDPLUS servers in the common case; the narrow
foreign-
\Deletedcase degrades to a clear error instead of today's blanket failure — a strict improvement,still data-loss-safe.
Logging (PII-free)
AppLogbreadcrumbs at the fallback decision (tagImapExpunge), counts only — no emails/hosts/UIDs/content:server lacks UIDPLUS; no other \Deleted mail present, using safe plain EXPUNGEserver lacks UIDPLUS and N other \Deleted message(s) present; refusing plain EXPUNGE so unrelated mail is not destroyedTests (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, targetremoved, unflagged mail spared, fallback log asserted.
deleteMessages without UIDPLUS refuses to expunge when other Deleted mail is present— throws, andboth 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.(
deleteMessages ... spares other Deleted-flagged mail, the move counterpart, andImapFolderOpenLatencyTest).No instrumented test was touched (the UIDPLUS logic is a server-integration concern the fakes-based UI E2E
can't reach);
compileDebugAndroidTestKotlinpasses 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.Merge Queue Status
2026-07-08 12:09 UTC· Rule:default· triggered by merge protections2026-07-08 12:09 UTC· atb5b789f6c7256db7bbeacc3e7207c06c0671adc2· mergeThis pull request spent 9 seconds in the queue, including 2 seconds running CI.
Required conditions to merge
-conflict-draftbase = maincheck-success = CI passedgithub-review-approved[🛡 GitHub repository ruleset rulemain]label != brokencheck-success = Debug buildcheck-neutral = Debug buildcheck-skipped = Debug buildcheck-success = Unit testscheck-neutral = Unit testscheck-skipped = Unit testscheck-success = CI passedcheck-neutral = CI passedcheck-skipped = CI passedmain]:check-success = @github-actions/CI passedcheck-neutral = @github-actions/CI passedcheck-skipped = @github-actions/CI passed