smtp
Both halves of ESMTP on one wire grammar: the submission client a CX program sends mail with (RFC 6409) and the receive server core it accepts mail with (RFC 5321 §4). submit is the supported client path and is one call on purpose, because the common way an SMTP client is broken is a forgotten step — EHLO not re-issued after STARTTLS, AUTH attempted before the upgrade, the SIZE advertisement not checked, QUIT never sent. serve drives a pure ESMTP state machine per connection and hands a handler the parsed envelope and body, taking back accept / defer / reject, with handler faults fail-closed to a temporary 451 rather than an accept. Everything that decides meaning is pure — parse-reply, parse-ehlo, format-command, enhanced-status, dot-stuff, dot-unstuff, server-init, server-step — so an entire hostile SMTP session replays in a fixture with no listener, no port and no capability; everything that touches a socket is impure by net and by nothing else. TLS 1.2+ is required by default and the opt-out is spelled in the policy value, never a boolean; AUTH never crosses an unprotected connection under any setting; bare LF is refused in both directions rather than normalized, because normalizing is how a sender and a receiver come to disagree about where a message ends. The module carries cx-stdlib/email's [message] unchanged and cx-stdlib/sasl's [sasl-auth] credential unchanged, adds exactly one Received: header and modifies nothing else, and has no forwarding path at all — so there is nothing to misconfigure into an open relay.
smtp:test-server
[$smtp:test-server] () -> element — An in-process SMTP server for tests (smtp.md §3.8): loopback, a port the OS picks, cleartext (submit to its url= with {policy: [smtp-policy tls=:none]}), every message accepted (250, id test-N) and recorded. Answers [smtp-test-server handle= port= url=].
smtp:test-server-messages
[$smtp:test-server-messages] ($server::element) -> [sequence element] — The [delivery …] of every message the test server accepted, in arrival order (smtp.md §3.8).
smtp:test-server-stop
[$smtp:test-server-stop] ($server::element) -> null — Stop the test server: drain in-flight exchanges and close its listener (smtp.md §3.8).
smtp:pump-sink
[$smtp:pump-sink] ($fabric::element $sink::element $opts::map) -> [sequence element] — Drain a [mail-sink name= stream= pattern= from= to= subject= url=] (smtp.md §3.9): the events on its stream since the last pump (a consumer group per sink) are each rendered to a message, submitted, recorded as [mail-sent …] on the sink's record stream and acked; a failed submission answers [mail-failed …], stops the pump and is not acked, so the next pump mails it again. opts: auth (a [sasl-auth …]), submit (submit opts), max, record-stream, authority.
smtp:submit
[$smtp:submit] -> element — Dial, greet, EHLO, upgrade, authenticate, send and QUIT in one call, returning [smtp-result accepted= [reply …] [envelope …]] with a per-recipient row for every recipient. Partial acceptance is a value, not an exception.
[?lib 'cx-platform/smtp']
[?lib 'cx-stdlib/bytes']
[?let [= $r [$smtp:submit "tcp://mx.example:25" [envelope [mail-from "a@b.example"] [rcpt-to "c@d.example"]] [$bytes:from-string-utf8 "Subject: hi\r\n\r\nhello\r\n"]]]
[?match $r [case [err] [$string $r@code]] [else "SUBMITTED"]]]
'cx-err:CXER5710'
smtp:connect
[$smtp:connect] -> element — Open a session: dial, greeting, EHLO, STARTTLS and the second EHLO, then AUTH when a credential is supplied. There is no starttls verb and no auth verb, so neither can be forgotten.
[?lib 'cx-platform/smtp']
[?let [= $r [$smtp:connect "tcp://mx.example:25"]]
[?match $r [case [err] [$string $r@code]] [else "CONNECTED"]]]
'cx-err:CXER5710'
smtp:send
[$smtp:send] -> element — Send one message on an open session, with a RSET before it so the previous transaction's recipients cannot leak into this one.
[?lib 'cx-platform/smtp']
[?lib 'cx-stdlib/bytes']
[?let [= $r [$smtp:send [smtp-session handle="0"] [envelope [mail-from "a@b.example"] [rcpt-to "c@d.example"]] [$bytes:from-string-utf8 "x"]]]
[?match $r [case [err] [$string $r@code]] [else "SENT"]]]
'cx-err:CXER5710'
smtp:reset
[$smtp:reset] -> null — Abort the current transaction on a session with RSET, returning it to the post-EHLO state.
[?lib 'cx-platform/smtp']
[?let [= $r [$smtp:reset [smtp-session handle="0"]]]
[?match $r [case [err] [$string $r@code]] [else "RESET"]]]
'cx-err:CXER5710'
smtp:quit
[$smtp:quit] -> null — Send QUIT and close. Sending it matters: a client that drops the TCP connection instead has the server count it against the sender's reputation.
[?lib 'cx-platform/smtp']
[?let [= $r [$smtp:quit [smtp-session handle="0"]]]
[?match $r [case [err] [$string $r@code]] [else "CLOSED"]]]
'cx-err:CXER5710'
smtp:capabilities
[$smtp:capabilities] -> element — Read the [smtp-caps] a session learned at EHLO time. Pure: a caller branching on PIPELINING does not touch the socket to ask, and cannot hold a second capability list for one connection.
[?lib 'cx-platform/smtp']
[?let [= $r [$smtp:capabilities [not-a-session]]]
[?match $r [case [err] [$string $r@code]] [else "READ"]]]
'cx-err:CXER5710'
smtp:parse-reply
[$smtp:parse-reply] -> element — Read exactly one complete reply — a single line or a multiline group — out of exactly the octets given. More than one reply, less than one, a mid-group code change or a bare LF are each refused, because each shifts a pipelined group's alignment by one.
[?lib 'cx-platform/smtp']
[?lib 'cx-stdlib/bytes']
[?lib 'cx-stdlib/strings']
[?let [= $r [$smtp:parse-reply [$bytes:from-string-utf8 "250-mx.example\r\n250 OK queued as ABC\r\n"]]]
[probe [code [$string $r@code]] [lines [$strings:join [?for [in $l $r/line] [yield [$string $l]]] "|"]]]]
[probe [code '250'] [lines mx.example|OK queued as ABC]]
smtp:format-command
[$smtp:format-command] -> bytes — Build one CRLF-terminated command line, ordering ESMTP parameters deterministically (SIZE, BODY, SMTPUTF8, RET, ENVID on MAIL; NOTIFY, ORCPT on RCPT; then caller extras in input order) so an equal envelope yields byte-identical octets. A CR, LF or NUL in any input is refused rather than escaped.
[?lib 'cx-platform/smtp']
[?lib 'cx-stdlib/bytes']
[probe [ehlo [$bytes:to-string-utf8 [$smtp:format-command "EHLO" "client.example"]]]
[quit [$bytes:to-string-utf8 [$smtp:format-command "QUIT" ""]]]
[rset [$bytes:to-string-utf8 [$smtp:format-command "RSET" ""]]]
[data [$bytes:to-string-utf8 [$smtp:format-command "DATA" ""]]]]
[probe [ehlo 'EHLO client.example\r\n'] [quit 'QUIT\r\n'] [rset 'RSET\r\n'] [data 'DATA\r\n']]
smtp:parse-ehlo
[$smtp:parse-ehlo] -> element — Read a 250 EHLO group into [smtp-caps domain= size= [ext name=… …] …]. An extension this module has never heard of is carried verbatim rather than filtered, because filtering is how a client quietly loses the mechanism it should have picked.
[?lib 'cx-platform/smtp']
[?lib 'cx-stdlib/bytes']
[?lib 'cx-stdlib/strings']
[?let [= $caps [$smtp:parse-ehlo [$smtp:parse-reply [$bytes:from-string-utf8 "250-mx.example\r\n250-size 1000\r\n250 STARTTLS\r\n"]]]]
[probe [size [$string $caps@size]] [exts [$strings:join [?for [in $e $caps/ext] [yield [$string $e@name]]] ","]]]]
[probe [size '1000'] [exts 'SIZE,STARTTLS']]
smtp:enhanced-status
[$smtp:enhanced-status] -> element — Lift RFC 3463's class.subject.detail out of a reply's first text word, or yield the absence channel. 550 5.1.1 (no such user) and 550 5.7.1 (policy) are the same three digits and completely different operational answers.
[?lib 'cx-platform/smtp']
[?lib 'cx-stdlib/bytes']
[?let [= $r [$smtp:parse-reply [$bytes:from-string-utf8 "550 5.1.1 <c@d.example>: Recipient address rejected: User unknown\r\n"]]]
[= $e [$smtp:enhanced-status $r]]
[probe [code [$string $e@code]] [class [$string $e@class]] [subject [$string $e@subject]] [detail [$string $e@detail]]
[line [$string [$first $r/line]]]]]
[probe [code 5.1.1] [class '5'] [subject '1'] [detail '1'] [line 5.1.1 <c@d.example>: Recipient address rejected: User unknown]]
smtp:dot-stuff
[$smtp:dot-stuff] -> bytes — Apply RFC 5321 §4.5.2 transparency: prepend exactly one dot to every line that already begins with one, so a body line that is "." cannot end the message. A bare CR or LF is refused rather than normalized.
[?lib 'cx-platform/smtp']
[?lib 'cx-stdlib/bytes']
[$bytes:to-string-utf8 [$smtp:dot-stuff [$bytes:from-string-utf8 "a\r\n.\r\nb\r\n"]]]
'a\r\n..\r\nb\r\n'
smtp:dot-unstuff
[$smtp:dot-unstuff] -> bytes — The exact inverse: remove exactly one leading dot from every line that begins with one. Exactness is the point — a client and a server that disagree about transparency corrupt every message with a dot-led line, silently.
[?lib 'cx-platform/smtp']
[?lib 'cx-stdlib/bytes']
[$bytes:to-string-utf8 [$smtp:dot-unstuff [$bytes:from-string-utf8 "...\r\n....\r\n..x\r\n"]]]
'..\r\n...\r\n.x\r\n'
smtp:server-init
[$smtp:server-init] -> element — Open a receive session over a [smtp-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/smtp']
[?let [= $st [$smtp:server-init [smtp-policy hostname="mx.test" tls=:none]]]
[probe [phase [$string $st@phase]] [max-rcpt [$string [$first $st/policy]@max-rcpt]]]]
[probe [phase greeting] [max-rcpt '100']]
smtp:server-step
[$smtp:server-step] -> element — Fold received octets into the session and return [smtp-step [state …] [reply …] [emit …] [delivery …] action=…]: the new state, the replies to write, their octets, a completed delivery when DATA ended, 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/smtp']
[?lib 'cx-stdlib/bytes']
[?let [= $st [$smtp:server-init [smtp-policy hostname="mx.test" tls=:none pipelining=false smtputf8=false chunking=false dsn=false]]]
[= $r [$smtp:server-step $st [$bytes:from-string-utf8 "HELO client.example\r\n"]]]
[probe [action [$string $r@action]] [emit [$bytes:to-string-utf8 $r/emit]]]]
[probe [action continue] [emit '220 mx.test ESMTP ready\r\n250 mx.test\r\n']]
smtp:serve
[$smtp:serve] -> element — Bind a listener, drive the pure state machine per connection, and call the handler with each completed [delivery], taking back an [smtp-verdict]. A returned err, a panic, a non-verdict or a code outside its disposition's class all produce 451 4.3.0 and the message is NOT delivered.
[?lib 'cx-platform/smtp']
[?let [= $r [$smtp:serve "smtp://0.0.0.0:25" [?fn ($d) [smtp-verdict disposition=:accept id="x"]] {block: false}]]
[?match $r [case [err] [$string $r@code]] [else "SERVING"]]]
'cx-err:CXER5710'
smtp:listen
[$smtp:listen] -> element — Bind a listener and return [smtp-server …] for the low-level loop. Bind URLs are net transport URLs — tcp://0.0.0.0:25 for the MX port, tls://0.0.0.0:465 for implicit-TLS submission.
[?lib 'cx-platform/smtp']
[?let [= $r [$smtp:listen "smtps://0.0.0.0:465"]]
[?match $r [case [err] [$string $r@code]] [else "BOUND"]]]
'cx-err:CXER5710'
smtp:accept-iter
[$smtp:accept-iter] -> [iterator element] — Yield one [smtp-exchange] per completed DATA — not per connection, because a connection can carry several messages. Consume it with [?for]; it is a server loop, never fully materialised.
[?lib 'cx-platform/smtp']
[?let [= $r [$smtp:accept-iter [smtp-server handle="0"]]]
[?match $r [case [err] [$string $r@code]] [else "ITERATOR"]]]
'cx-err:CXER5710'
smtp:exchange-delivery
[$smtp:exchange-delivery] -> element — Read the [delivery] off one exchange — the same value serve hands its handler: the peer, the TLS parameters, the authenticated identity, the envelope, the one Received: header, the parsed [message] (email:parse's element, parsed once here) and the raw [body] octets it arrived as.
[?lib 'cx-platform/smtp']
[?let [= $r [$smtp:exchange-delivery [smtp-exchange handle="0"]]]
[?match $r [case [err] [$string $r@code]] [else "READ"]]]
'cx-err:CXER5710'
smtp:respond
[$smtp:respond] -> null — Write one verdict and mark the exchange answered. A second respond on one exchange is refused: two answers to one message is a desynchronized pipeline, and it is the caller's fault.
[?lib 'cx-platform/smtp']
[?let [= $r [$smtp:respond [smtp-exchange handle="0"] [smtp-verdict disposition=:accept id="q1"]]]
[?match $r [case [err] [$string $r@code]] [else "ANSWERED"]]]
'cx-err:CXER5710'
smtp:stop
[$smtp:stop] -> null — Drain and close: existing sessions finish their current transaction, new connections are answered 421 4.3.2 Service shutting down and closed — which tells a sender to retry elsewhere rather than looking like a network fault.
[?lib 'cx-platform/smtp']
[?let [= $r [$smtp:stop [smtp-server handle="0"]]]
[?match $r [case [err] [$string $r@code]] [else "STOPPED"]]]
'cx-err:CXER5710'