Speak LMTP, and cite the specifications properly
LMTP (RFC 2033) is SMTP with two differences that matter: the greeting is
LHLO and HELO/EHLO are refused, and the end of a message is answered with
one reply per accepted recipient rather than one for the message. The
second is the whole point -- a delivery agent can say that one mailbox is
full while another is fine, which SMTP gives it no way to express -- and
it is why this is a mode rather than a separate protocol.
The server takes `Options.protocol = .lmtp` and a new `recipientResult`
callback, asked once per accepted recipient after the message callback has
returned. A message rejected outright is reported as that rejection for
every recipient, since it failed for all of them, and a recipient named
twice is answered twice, which RFC 2033 §4.2 is explicit about. BDAT LAST
draws the same per-recipient answer as the final dot.
The client takes `Client.mode = .lmtp` -- spelled `mode` only because the
`protocol` module import already holds that name in the struct's scope --
and tracks how many recipients the server accepted, since that is how many
replies the end of the message will bring. `DataWriter.endResults` hands
them back one at a time with the index they belong to; `end` reads them all
and says `error.RecipientRejected`, which is a different error from
`UnexpectedReply` precisely because it cannot say which recipient failed:
the replies share one buffer and reading the next overwrites the previous.
Along the way the transaction state became a `Transaction` struct. It was
seven copies of the same seven-line reset by the time LHLO wanted an
eighth, and adding a field to six of seven places is a bug waiting to be
written.
Verified against real implementations: exim now routes a two-recipient
message to a zsmtp LMTP server that accepts one mailbox and refuses the
other, and reads the two verdicts back as a delivery and a permanent
failure of the same message; the zsmtp LMTP client delivers to dovecot,
reports which recipient dovecot refused, and both servers are checked for
refusing EHLO as RFC 2033 §4 requires.
The README grows a "References cited" section, in the RFC citation format
so that an entry here matches one anywhere else. Every author and date in
it came from the IETF's own bibliography rather than from memory. The
Standards section says what is implemented of each document; this one says
what each document is, and covers the ones cited only as gaps or as out of
scope, plus tls.zig, the is_email corpus and exim's test suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC