v2.0.4
Six findings (5 P1, 1 P2) from a fourth independent external review, fixed and verified with new regression tests (241 β 264). All are edge cases in the UIDVALIDITY-safe id scheme and send-claim mechanism landed in 2.0.3 β integration gaps between that new format/mechanism and the existing index, CLI, bulk operations, and delivery queue.
Fixed
- Indexing the same message under the old and new id formats created a duplicate row. The
messagestable's primary key is the full id string, which changed for every message once ids started embedding UIDVALIDITY β a message already indexed under the pre-2.0.3 format got a second row once a normal sync produced its id in the new format, inflatingstoredMessageCountand letting search/dedup arbitrarily surface the stale old row. Reconciled via a single indexed lookup per upsert (not a table scan), preserving previously-captured content across the transition. - UIDVALIDITY protection was opt-in per caller instead of intrinsic to the id.
deleteEmailand 5 sibling mutation methods discarded the id's own parsed generation and relied entirely on a separate, external parameter for the actual check β any caller that didn't explicitly pass it (all ofsrc/cli.ts's shortcuts did not) got zero protection even for an id that itself encoded a valid, checkable generation. All 6 methods now derive their expected generation from the id itself by default. - Reading a stale id silently returned a different message's content under a freshly-relabeled new id.
getParsedMailDetail(backingget_email_by_id, shared by quote/forward/reply content reads) deliberately enforced nothing β a documented but unenforced risk. Now enforces the same generation check every mutation already does. - Bulk operations lost the expected generation between id resolution and the actual mutation.
bulkDelete/bulkUpdateFlags/bulkUpdateLabelsaccepted pre-resolved UIDs but never re-verified the generation those UIDs were resolved under inside the mailbox lock the real mutation runs under β only at resolution time, before that lock was even acquired. A generation change in that window meant resolved UIDs got mutated under a different generation with no re-check. Now re-verified inside the same lock as the mutation itself. - A draft-store finalization failure after a successful queued send re-unlocked the draft for resending.
checkDue()'s single try/catch spanned the SMTP call, the queue-record write, and the draft's ownmarkSent()β ifmarkSent()failed independently after SMTP had already succeeded, the catch treated it as a delivery failure and reverted the draft's claim, and double-counted the item as both sent and failed.markSent()failure is now handled independently (retried, then left in a non-resendable state rather than reverted) and never reaches the delivery-failure path. - A small search result
limitcaused a cascade of single-message FETCH calls. The local-filter search path used the caller's result limit directly as the network batch size βlimit:1with no matches issued one IMAP command per candidate. Batch size is now decoupled from result count, andhasAttachmentreuses data already fetched in an earlier pass instead of re-fetching.