CX · Reference guideimap
PlaygroundDownloadsAboutv0.18.0-pre.1

imap

Both halves of IMAP4rev2 (RFC 9051) on one token grammar: the client an agent or a human client reads a mailbox with, and the server core a CX process serves a mailbox from. The client surface is UID-only, because a sequence number renumbers on every EXPUNGE and an agent that computed a set and then acted on it has acted on the wrong messages; the server half speaks both, because the protocol requires it. connect is one call on purpose — the greeting, the post-upgrade second CAPABILITY, the SASL-IR AUTHENTICATE and the ENABLE without which a rev2 server hands a client rev1 semantics are each a step a caller can forget, and forgetting any of them is a silent downgrade. sync is the agent verb: a [sync-state] in, the changes plus the next state out, with a first run returning a BASELINE rather than the whole mailbox. serve drives a pure state machine per connection and hands a handler a store OPERATION — [op kind=…] in, [op-result] out — while this module renders every wire byte, so a mailbox store never learns IMAP syntax. Everything that decides meaning is pure — parse-response, format-command, parse-bodystructure, sequence-set, format-sequence-set, search-query, mailbox-encode, mailbox-decode, server-init, server-step — so an entire hostile IMAP session replays in a fixture with no listener, no port and no capability. A literal's length is validated before it is honored, so a four-gigabyte announcement costs one refusal; a sequence set is refused by cardinality before expansion, because expanding it is the attack; a search query is an AST and never a string, because a string key composed from user input is IMAP's injection vector; and credentials never cross an unprotected connection under any tls= setting. The message it carries is cx-stdlib/email's [message], unchanged, and the credential is cx-stdlib/sasl's [sasl-auth], unchanged.

imap:connect

[$imap:connect] -> element — Dial, read the greeting, read capabilities, authenticate over SASL and ENABLE IMAP4rev2 in one call, returning an [imap-session …]. There is no starttls verb, no login verb and no enable verb, because each would be a step a caller can forget and forgetting any of the three is a silent downgrade.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:connect "tcp://imap.example:993" [sasl-auth mechanism=:plain authcid="a" secret="b"]]]
  [?match $r [case [err] [$string $r@code]] [else "CONNECTED"]]]
    
      'cx-err:CXER5815'
    

imap:logout

[$imap:logout] -> null — Send LOGOUT, read the * BYE, and close. Sending it matters: a session dropped without LOGOUT costs the server a timeout slot, and on a provider that counts them it costs the account.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:logout [imap-session handle="0"]]]
  [?match $r [case [err] [$string $r@code]] [else "CLOSED"]]]
    
      'cx-err:CXER5815'
    

imap:capabilities

[$imap:capabilities] -> element — Read the [imap-caps] a session learned at connect time. Pure: a caller branching on CONDSTORE does not touch the socket to ask, and cannot hold a second capability list for one connection — and a capability list read before a TLS handshake was advertised by whoever was in the middle.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:capabilities [not-a-session]]]
  [?match $r [case [err] [$string $r@code]] [else "READ"]]]
    
      'cx-err:CXER5815'
    

imap:list

[$imap:list] -> [sequence element] — rev2's LIST with LIST-EXTENDED and LIST-STATUS folded in: opts.subscribed selects subscriptions, opts.special-use requests the \\Drafts / \\Sent / \\Junk / \\Trash / \\Archive attributes, and opts.status names STATUS items returned in the same round trip. Special-use is how an agent finds the Trash folder without guessing its name in the user's language.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:list [imap-session handle="0"] "*" {special-use: true}]]
  [?match $r [case [err] [$string $r@code]] [else "LISTED"]]]
    
      'cx-err:CXER5815'
    

imap:select

[$imap:select] -> element — Select a mailbox and return [mailbox …] with uidvalidity, uidnext, highest-modseq, exists and the permanent flags. opts.read-only issues EXAMINE, so an agent under a "may read, may not modify" grant is held to it by the protocol as well as by the policy engine.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:select [imap-session handle="0"] "INBOX" {read-only: true}]]
  [?match $r [case [err] [$string $r@code]] [else "SELECTED"]]]
    
      'cx-err:CXER5815'
    

imap:unselect

[$imap:unselect] -> null — Leave the mailbox WITHOUT the implicit expunge CLOSE performs. There is no close verb in this module, because CLOSE's "and permanently delete everything flagged \\Deleted" is the most surprising side effect in IMAP.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:unselect [imap-session handle="0"]]]
  [?match $r [case [err] [$string $r@code]] [else "UNSELECTED"]]]
    
      'cx-err:CXER5815'
    

imap:status

[$imap:status] -> element — Ask for a mailbox's counters without selecting it, returning the same [mailbox …] value select yields. :unseen is the portable answer to the question \\Recent was misused for.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:status [imap-session handle="0"] "INBOX" {items: (:messages, :unseen)}]]
  [?match $r [case [err] [$string $r@code]] [else "READ"]]]
    
      'cx-err:CXER5815'
    

imap:create

[$imap:create] -> element — Create a mailbox. The name is validated before it reaches the wire: a CR, an LF, a NUL or a wildcard outside a list pattern is refused rather than escaped.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:create [imap-session handle="0"] "Archive/2026"]]
  [?match $r [case [err] [$string $r@code]] [else "CREATED"]]]
    
      'cx-err:CXER5815'
    

imap:delete

[$imap:delete] -> null — Delete a mailbox. Destructive, and named so: under the mail feature's policy ladder an agent granted label is not granted delete, and this module has no notion of authority at all — authority is decided once, in one place.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:delete [imap-session handle="0"] "Archive/2026"]]
  [?match $r [case [err] [$string $r@code]] [else "DELETED"]]]
    
      'cx-err:CXER5815'
    

imap:rename

[$imap:rename] -> null — Rename a mailbox. Both names go through the same validation, in both directions.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:rename [imap-session handle="0"] "Archive/2025" "Archive/2026"]]
  [?match $r [case [err] [$string $r@code]] [else "RENAMED"]]]
    
      'cx-err:CXER5815'
    

imap:subscribe

[$imap:subscribe] -> null — Subscribe to a mailbox, or unsubscribe with opts.subscribe false. rev2 removed LSUB; against a rev1 server the wire shim sends it and the value model does not change.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:subscribe [imap-session handle="0"] "Archive/2026"]]
  [?match $r [case [err] [$string $r@code]] [else "SUBSCRIBED"]]]
    
      'cx-err:CXER5815'
    

imap:fetch

[$imap:fetch] -> [iterator element] — Fetch the named items for a UID set, yielding one [msg] at a time. It is an iterator because a FETCH 1:* over a large mailbox is unbounded server data, and opts.peek is the DEFAULT: a fetch that silently sets \\Seen is the single most-complained-about bug in mail agents, and an agent triaging an inbox must not mark it read as a side effect of looking.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:fetch [imap-session handle="0"] [$imap:sequence-set "1:3"] [items :uid :flags]]]
  [?match $r [case [err] [$string $r@code]] [else "FETCHING"]]]
    
      'cx-err:CXER5815'
    

imap:store

[$imap:store] -> [sequence element] — Add, remove or set flags on a UID set. opts.unchanged-since is CONDSTORE's conditional store, which is how two agents editing one mailbox do not clobber each other: the failed UIDs come back in the result rather than being silently overwritten.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:store [imap-session handle="0"] [$imap:sequence-set "1"] [flags-add [flag "$label-work"]]]]
  [?match $r [case [err] [$string $r@code]] [else "STORED"]]]
    
      'cx-err:CXER5815'
    

imap:copy

[$imap:copy] -> element — Copy a UID set into another mailbox, returning UIDPLUS's [copyuid] so the caller knows the new addresses without searching for them.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:copy [imap-session handle="0"] [$imap:sequence-set "1:3"] "Archive"]]
  [?match $r [case [err] [$string $r@code]] [else "COPIED"]]]
    
      'cx-err:CXER5815'
    

imap:move

[$imap:move] -> element — rev2's ATOMIC MOVE. There is no copy-then-delete fallback against a server without it: that sequence is not atomic, it loses messages on a disconnect, and a move that sometimes duplicates and sometimes deletes is worse than a refusal.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:move [imap-session handle="0"] [$imap:sequence-set "1:3"] "Archive"]]
  [?match $r [case [err] [$string $r@code]] [else "MOVED"]]]
    
      'cx-err:CXER5815'
    

imap:expunge

[$imap:expunge] -> [sequence int] — Permanently remove the messages flagged \\Deleted, or exactly the UID set opts.uids names (UIDPLUS). It is a separate verb precisely so no other verb has to carry the side effect silently.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:expunge [imap-session handle="0"]]]
  [?match $r [case [err] [$string $r@code]] [else "EXPUNGED"]]]
    
      'cx-err:CXER5815'
    

imap:append

[$imap:append] -> element — Append a message to a mailbox and return UIDPLUS's [appenduid uidvalidity= uid=], so the caller knows what it just created without searching for it. The bytes path exists for the same reason smtp's does: re-emitting a signed message from its parse breaks the signature.

      [?lib 'cx-platform/imap']
[?lib 'cx-stdlib/bytes']
[?let [= $r [$imap:append [imap-session handle="0"] "Drafts" [$bytes:from-string-utf8 "Subject: hi\r\n\r\nx\r\n"]]]
  [?match $r [case [err] [$string $r@code]] [else "APPENDED"]]]
    
      'cx-err:CXER5815'
    

imap:sync

[$imap:sync] -> element — The agent verb: a [sync-state] in, [changes] plus the next [sync-state] out. With no state it returns a BASELINE — the state to persist and zero changes — rather than materializing the mailbox, because a return value whose size depended on a mailbox's size would do it on exactly the call an agent makes first and least defensively. A UIDVALIDITY change refuses rather than returning changes computed against a mailbox that no longer exists.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:sync [imap-session handle="0"] [sync-state mailbox="INBOX" uidvalidity=1 uidnext=9 modseq=42]]]
  [?match $r [case [err] [$string $r@code]] [else "SYNCED"]]]
    
      'cx-err:CXER5815'
    

imap:idle

[$imap:idle] -> [iterator element] — RFC 2177 IDLE as an iterator of untagged updates, re-issuing DONE/IDLE before the 29-minute limit the RFC advises. The iterator terminates on unselect, logout, or the caller dropping it.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:idle [imap-session handle="0"]]]
  [?match $r [case [err] [$string $r@code]] [else "IDLING"]]]
    
      'cx-err:CXER5815'
    

imap:noop

[$imap:noop] -> [sequence element] — The poll for a caller that will not hold a connection idle: it returns the untagged updates that had queued.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:noop [imap-session handle="0"]]]
  [?match $r [case [err] [$string $r@code]] [else "POLLED"]]]
    
      'cx-err:CXER5815'
    

imap:parse-response

[$imap:parse-response] -> element — Read one complete server response — tagged, untagged or a continuation, literals included — and report how many octets it consumed. A partial feed yields the absence channel rather than a guess, because "no complete response has arrived" and "the empty response" are different facts.

      [?lib 'cx-platform/imap']
[?lib 'cx-stdlib/bytes']
[?let [= $r [$imap:parse-response [$bytes:from-string-utf8 "* 4 EXISTS\r\n"]]]
  [probe [tag [$string $r@tag]] [name [$string $r@name]] [number [$string $r@number]]]]
    
      [probe [tag '*'] [name EXISTS] [number '4']]
    

imap:format-command

[$imap:format-command] -> bytes — Build one CRLF-terminated command, choosing atom / quoted-string / literal by a fixed rule — atom when the value is an IMAP atom, quoted when it is 7-bit, at most 1024 octets and free of quote, backslash, CR and LF, literal otherwise — so an equal command yields byte-identical octets. A CR, LF or NUL in the tag or verb is refused rather than escaped.

      [?lib 'cx-platform/imap']
[?lib 'cx-stdlib/bytes']
[$bytes:to-string-utf8 [$imap:format-command "A001" "SELECT" [args [mailbox "INBOX"]]]]
    
      'A001 SELECT INBOX\r\n'
    

imap:parse-bodystructure

[$imap:parse-bodystructure] -> element — Read a FETCH response's BODYSTRUCTURE into [bodystructure …] with every part's SECTION number computed, including through a message/rfc822 part — the corner every implementation gets wrong. Depth and arity are bounded on the way down, because a parser that built the tree and then measured it has already paid for the attack.

      [?lib 'cx-platform/imap']
[?lib 'cx-stdlib/bytes']
[?let [= $bs [$imap:parse-bodystructure [$imap:parse-response
        [$bytes:from-string-utf8 "* 1 FETCH (BODYSTRUCTURE (\"TEXT\" \"PLAIN\" NIL NIL NIL \"7BIT\" 5 1))\r\n"]]]]
  [probe [type [$string $bs@type]] [subtype [$string $bs@subtype]] [section [$string [$first $bs/part]@section]]]]
    
      [probe [type text] [subtype plain] [section '1']]
    

imap:sequence-set

[$imap:sequence-set] -> element — Read a sequence set into [uid-set …], sorting and coalescing so a set is a set. It refuses BEFORE expansion: a set whose expanded cardinality or whose textual length exceeds the policy bound, and a number that is not one. 1:4294967295 costs one refusal, because expanding it is the attack.

      [?lib 'cx-platform/imap']
[?let [= $set [$imap:sequence-set "3,1,2,9"]]
  [probe [ranges [$count [?for [in $r $set/range] [yield $r]]]] [card [$string $set@cardinality]]]]
    
      [probe [ranges 2] [card '4']]
    

imap:format-sequence-set

[$imap:format-sequence-set] -> string — The exact inverse: render a [uid-set] back to wire text. Exactness is the point — the sequence-set grammar is one of the two places, with literals, where an IMAP implementation is attacked.

      [?lib 'cx-platform/imap']
[$imap:format-sequence-set [$imap:sequence-set "3,1,2,9"]]
    
      '1:3,9'
    

imap:search-query

[$imap:search-query] -> element — Build a [search …] AST from a map of keys. A string query is not accepted anywhere in this module: a string search key composed from user input is IMAP's injection vector, and every string that reaches the wire from an AST leaf is quoted or sent as a literal, never concatenated.

      [?lib 'cx-platform/imap']
[?lib 'cx-stdlib/bytes']
[$bytes:to-string-utf8 [$imap:format-command "A001" "SEARCH"
  [args [$imap:search-query {from: "acme.com", unseen: true}]] {uid: true}]]
    
      'A001 UID SEARCH FROM "acme.com" NOT SEEN\r\n'
    

imap:mailbox-encode

[$imap:mailbox-encode] -> string — Convert a CX mailbox name to its wire form: UTF-8 directly when UTF8=ACCEPT was enabled, modified UTF-7 under opts.utf7 for a rev1 server that did not. It refuses a name carrying CR, LF, NUL, or a wildcard outside a list pattern — the CRLF-injection-in-a-mailbox-name class, which reaches a CREATE, a log line and a LIST response.

      [?lib 'cx-platform/imap']
[probe [utf8 [$imap:mailbox-encode "Archiv/2026"]]
       [utf7 [$imap:mailbox-encode "台北" {utf7: true}]]]
    
      [probe [utf8 Archiv/2026] [utf7 '&U,BTFw-']]
    

imap:mailbox-decode

[$imap:mailbox-decode] -> string — The inbound direction, and it refuses the same octets: a server is not trusted with the names it reports, because the CR that would have been injection on the way out is injection on the way in, into whatever log, path or interface the name reaches next.

      [?lib 'cx-platform/imap']
[$imap:mailbox-decode "&U,BTFw-" {utf7: true}]
    
      '台北'
    

imap:server-init

[$imap:server-init] -> element — Open a receive session over an [imap-policy], resolving every bound to its default and rendering them back onto the state so the wall that refuses a peer is readable off the value.

      [?lib 'cx-platform/imap']
[?let [= $st [$imap:server-init [imap-policy hostname="imap.test" tls=:none]]]
  [probe [phase [$string $st@phase]] [max-literal-bytes [$string [$first $st/policy]@max-literal-bytes]]]]
    
      [probe [phase greeting] [max-literal-bytes '26214400']]
    

imap:server-step

[$imap:server-step] -> element — Fold received octets into the session and return [imap-step [state …] [write …] [op …] action=…]: the new state, the responses to write, a store operation when the command needs the mailbox to answer, and the one thing a pure function cannot do — upgrade the socket or close it. Octets may split a line, a literal or a CRLF pair.

      [?lib 'cx-platform/imap']
[?lib 'cx-stdlib/bytes']
[?let [= $st [$imap:server-init [imap-policy hostname="imap.test" tls=:none]]]
      [= $r  [$imap:server-step $st [$bytes:from-string-utf8 "A1 NOOP\r\n"]]]
  [probe [action [$string $r@action]] [write [$string [$last $r/write]]]]]
    
      [probe [action continue] [write 'A1 OK NOOP completed\r\n']]
    

imap:serve

[$imap:serve] -> element — Bind a listener, drive the pure state machine per connection, and call the handler with each [op], taking back an [op-result]. A returned err or a panic becomes a tagged NO stamped with this module's handler code and the operation is NOT performed — never a BAD, because BAD tells the client its command was malformed and invites a different retry, which is a lie when the fault is ours.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:serve "imaps://0.0.0.0:993" [?fn ($op) [op-result]] {block: false}]]
  [?match $r [case [err] [$string $r@code]] [else "SERVING"]]]
    
      'cx-err:CXER5815'
    

imap:listen

[$imap:listen] -> element — Bind a listener and return [imap-server …] for the low-level loop. Bind URLs are net transport URLs — tls://0.0.0.0:993 for implicit TLS, tcp://0.0.0.0:143 for a cleartext listener — because a client URL names a service and a bind URL names a socket.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:listen "imap://0.0.0.0:143"]]
  [?match $r [case [err] [$string $r@code]] [else "BOUND"]]]
    
      'cx-err:CXER5815'
    

imap:accept-iter

[$imap:accept-iter] -> [iterator element] — Yield one [imap-exchange] per store operation — not per connection, because a connection carries a whole dialogue. Consume it with [?for]; it is a server loop, never fully materialised.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:accept-iter [imap-server handle="0"]]]
  [?match $r [case [err] [$string $r@code]] [else "ITERATOR"]]]
    
      'cx-err:CXER5815'
    

imap:exchange-op

[$imap:exchange-op] -> element — Read the [op …] off one exchange — the same value serve hands its handler: a named operation over values, with no IMAP syntax in it at all.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:exchange-op [imap-exchange handle="0"]]]
  [?match $r [case [err] [$string $r@code]] [else "READ"]]]
    
      'cx-err:CXER5815'
    

imap:respond

[$imap:respond] -> null — Write one [op-result] and mark the exchange answered. A second respond on one exchange is refused: two answers to one command desynchronizes the tag discipline, and it is the caller's fault.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:respond [imap-exchange handle="0"] [op-result]]]
  [?match $r [case [err] [$string $r@code]] [else "ANSWERED"]]]
    
      'cx-err:CXER5815'
    

imap:notify

[$imap:notify] -> null — The push seam: when a delivery queue or any other writer changes a mailbox it calls notify, and every session holding that mailbox selected receives the untagged data — immediately if it is in IDLE, at its next command otherwise. This module never polls the store, which is the design that would make IDLE useless and the store hot.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:notify [imap-server handle="0"] [mailbox-changed name="INBOX" uidnext=9 highest-modseq=42]]]
  [?match $r [case [err] [$string $r@code]] [else "NOTIFIED"]]]
    
      'cx-err:CXER5815'
    

imap:stop

[$imap:stop] -> null — Drain and close: selected sessions are sent * BYE and closed after the grace period, which is the orderly IMAP shutdown and lets a client reconnect rather than treating the drop as an error.

      [?lib 'cx-platform/imap']
[?let [= $r [$imap:stop [imap-server handle="0"]]]
  [?match $r [case [err] [$string $r@code]] [else "STOPPED"]]]
    
      'cx-err:CXER5815'