feat(mail): add a raw IMAP layer for ENABLE, tagged codes, and VANISHED #401

Merged
KofTwentyTwo merged 2 commits from story/imap-raw-command-layer into dev 2026-08-14 19:38:52 +00:00
2 Commits
Author SHA1 Message Date
james.maes fcf62e1105 Merge remote-tracking branch 'origin/dev' into story/imap-raw-command-layer 2026-08-14 14:10:28 -05:00
james.maes bf19b9f3a6 feat(mail): add a raw IMAP layer for ENABLE, tagged codes, and VANISHED
The typed async-imap API cannot express the four responses that decide whether
a sync saw everything or whether a mutation actually happened. ENABLE does not
exist, and RFC 7162 3.2.3 makes a server answer BAD to a QRESYNC SELECT without
it. select()'s parser has no arm for VANISHED, so a deleted message is simply
never learned about. And run_command_and_check_ok discards the tagged response
code, which is exactly where RFC 4315 puts COPYUID and RFC 7162 puts MODIFIED.

Hand-rolled rather than forked. run_command and read_response are public and
imap-proto already parses Vanished and CopyUid, so what was missing is a
collector that reads to the tagged response and keeps what arrived on the way.
That is a hundred lines against maintaining a fork for the same result.

Two subtleties the collector exists to get right. COPYUID arrives in an
UNTAGGED OK for MOVE (RFC 6851 4.3 advises exactly that), so a collector
reading only the tagged response misses it on every conforming server and then
has no proof the move happened. And UID EXPUNGE answers with VANISHED rather
than EXPUNGE once QRESYNC is enabled (4.4), so counting EXPUNGE alone reports
failure on every successful delete; both are gathered.

MODIFIED is the one real gap. imap-proto 0.16.7 has no ResponseCode variant for
it, and ResponseData's raw bytes are behind a private module, so it cannot be
read from the parse tree at all. Its resp_text is opt(resp_text_code) followed
by free text, so an unrecognised code is not an error -- the whole
"[MODIFIED 7,9]" survives in the information string, and that is where it is
recovered from. Parsing a response code out of a human-readable field is not
how this should work forever; upstreaming the variant is the real fix. It is
deterministic, it is pinned by a test against a server emitting the real
syntax, and the alternative is no conflict detection at all.

Absent COPYUID is deliberately not treated as failure. A UID set matching
nothing succeeds having done nothing (RFC 3501 6.4.8), and a conforming server
may also omit the code for a UIDNOTSTICKY destination, so absence means unknown.

Also fixes the mock server reading the first parenthesised group of a STORE as
its flag list, which made a conditional store apply UNCHANGEDSINCE and the
mod-sequence as flag names. The flag list is always the final argument.

Refs #388
2026-08-10 16:08:45 -05:00