Assorted IMAP client robustness fixes - #118
Conversation
Adds UIDPLUS extension support so callers can recover the UIDs the server assigns on APPEND/COPY and target expunges by UID: - appendFullUID: like appendFull, returns the APPENDUID response code - copyUID / copyUIDs / copyUIDR: UID COPY returning the COPYUID code - uidExpunge / uidExpungeR: UID EXPUNGE over a UID set or range New types AppendUID and CopyUID, the UIDSet alias, and the APPENDUID/COPYUID/UIDNOTSTICKY status codes with their parsers. sendCommandWithResponse exposes the tagged ServerResponse so the response codes can be read. The existing appendFull/copyFull keep their old signatures by discarding the UID result. Covered by new parser cases in baseTest and a dedicated imapUIDPlusTest group exercising the API against scripted server responses.
RFC 3501 keywords, response codes, flag names and status attributes are case-insensitive, but the parser matched them with case-sensitive `string`, so any server replying with non-canonical casing (e.g. `ok`, `* search`, `[uidvalidity ...]`, `\seen`) caused a parse error. Adds `stringCI`/`charCI` and applies them throughout the response parser. Along the way the tagged/fatal response handling is factored into `pWithTaggedOrFatal`, `pDone` is split into `pRespCode`/`pRespText`/`pStatusCode`, and string parsing is unified (`pQuotedString`/`pLiteralString`/`pAString`/`pMailboxName`). This also: - surfaces an untagged `* BYE` as a fatal response instead of failing - accepts an empty `* SEARCH` reply (no matches) - accepts a `NIL` hierarchy separator in LIST/LSUB - stops `atomChar` from running past CR/LF Covered by a new caseInsensitiveTest group.
IMAP SEARCH string keys (BCC/BODY/CC/FROM/HEADER/SUBJECT/TEXT/TO/
X-GM-RAW) were interpolated unquoted, so any value containing a space
or special character produced a malformed command. They are now wrapped
with quoteIMAPString.
LARGER/SMALLER wrongly wrapped their octet count in `{}` (the literal
syntax), e.g. `LARGER {100}`; the size is a plain number (`LARGER 100`).
SEARCH now auto-detects non-ASCII text in a query and prepends
`CHARSET UTF-8`, and command bytes are sent UTF-8 encoded
(sendCommandNoResponse) so the non-ASCII bytes survive instead of being
truncated to Latin-1.
Covered by a new imapSearchApiTest group.
A handful of independent fixes to the IMAP client:
- connectStream accepts a PREAUTH greeting and matches the greeting
status case-insensitively (was: only exact "* OK")
- AUTHENTICATE now loops over '+' challenge continuations via
getAuthResponse, so multi-step SASL mechanisms (e.g. XOAUTH2) that
emit an error continuation before the tagged reply complete instead
of stalling; the challenge prefix is matched as "+" not "+ "
- getResponse understands non-synchronising literals ({n+}) and the
binary literal prefix (~{n}), not just plain {n}
- fetch/fetchPeek/fetchR/fetchRPeek normalize a NIL body to empty
- storeFull reads result flags from the FLAGS key (was "FLAG", which
never matched) and quotes X-GM-LABELS values
- escapeLogin is unified with quoteIMAPString (drops the invalid
backslash-escaping of { and } inside a quoted string)
Covered by a new imapRobustnessTest group.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bf49490226
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| -- body so callers don't see the literal atom. Real bodies arrive as IMAP | ||
| -- literals and are never the bare atom NIL. | ||
| nilToEmpty :: ByteString -> ByteString | ||
| nilToEmpty bs = if bs == BS.pack "NIL" then BS.empty else bs |
There was a problem hiding this comment.
Normalize NIL bodies case-insensitively
Treat the NIL atom case-insensitively here. IMAP protocol terminals are case-insensitive, and the streaming FETCH parser preserves an atom's original casing, so a valid response such as * 1 FETCH (UID 42 BODY[] nil) makes fetch and fetchR return the bytes nil instead of the intended empty body.
Useful? React with 👍 / 👎.
| char ')' | ||
| return attrs | ||
| parseSep = space >> char '"' >> anyChar `manyTill` char '"' | ||
| parseSep = space >> ((string "NIL" >> return "") <|> pQuotedString) |
There was a problem hiding this comment.
Accept case-insensitive NIL hierarchy delimiters
Parse the LIST hierarchy delimiter's NIL token case-insensitively. A conforming server may return * LIST () nil INBOX, but this exact-case match rejects that response even though the rest of this change makes protocol keywords case-insensitive, causing list/lsub to fail while parsing a valid response.
Useful? React with 👍 / 👎.
Stacked on #117 → #116 → #115. Part of the series upstreaming the belege.ai fork's IMAP fixes. The diff shows the earlier commits in the stack until they merge; the commit for this PR is "Assorted IMAP client robustness fixes". Merge order: #115 → #116 → #117 → this.
A handful of small, independent client fixes (each is a one-liner-to-a-few-liner):
connectStreamonly accepted an exact* OK; it now also accepts* PREAUTHand matches the greeting status case-insensitively.AUTHENTICATEnow loops over+challenge continuations (getAuthResponse), so mechanisms like XOAUTH2 that emit an error continuation before the tagged response complete instead of hanging. The challenge prefix is matched as+rather than+.getResponse. Understands non-synchronising literals ({n+}) and the binary literal prefix (~{n}), not just{n}.fetch/fetchPeek/fetchR/fetchRPeeknormalize aNILbody to empty.storeFullread result flags from the key"FLAG", which never matches the actualFLAGSdata item; fixed to"FLAGS", andX-GM-LABELSvalues are now quoted.quoteIMAPString— drops the invalid backslash-escaping of{/}inside a quoted string (only"and\are quoted-specials).Tests
New
imapRobustnessTest: PREAUTH + lowercase greeting accepted, empty greeting rejected, multi-step XOAUTH2 continuation consumed, login credential quoting, NIL body normalized.🤖 Generated with Claude Code