Use PIPELINING on both sides
RFC 2920 was advertised and parsed by both sides and used by neither.
On the client, `envelope` sends MAIL FROM and every RCPT TO as one group
and reads all of their replies, turning an envelope of n recipients from
n+1 round trips into one. `hello` sets `pipelining` from the EHLO
response, and clears it after a HELO fallback, because §3.1 allows
pipelining only against a server that said it could take it; when it is
false the same call waits for each reply and produces the same result.
`sendMail` goes through it and keeps its all-or-nothing contract: a
refused recipient means RSET and an error, not a delivery to the rest.
DATA is deliberately not in the group even though §3.1 allows it as the
last command of one. After a 354 the transaction is committed and the
only ways out are to send the message or to send an empty one to whoever
was accepted -- so stopping before DATA keeps that choice with the caller
and costs one round trip out of the n+1 saved.
Reading a group needed a way to keep a reply: they arrive one after
another into a single buffer, so the failing one is gone by the time the
group has been drained. `discardReply` reads a reply without touching
that buffer, which lets the first refusal stay in `last_reply` while the
rest of the group is drained -- and the same trick makes LMTP's `end`
able to report which verdict failed, which the last commit said it could
not.
On the server the rule is §3.2's: hold back the replies to RSET, MAIL
FROM and RCPT TO, and send everything pending the moment the input is
empty. That condition is the whole safety argument -- a reply is only
ever held while another command is already waiting to be answered, so the
client is never left waiting for something sitting in a buffer -- and the
commands whose replies must never be held (EHLO, DATA, VRFY, EXPN, TURN,
QUIT, NOOP) are exactly the ones still using the unconditional `reply`.
A test writer that records its flush boundaries pins it down: eight
replies leave in five writes, with the three envelope replies and the 354
as one of them.
Every reference the README cites is now filed in the Zotero library as
well, RFCs by their DOIs so that none of the metadata is typed by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Use PIPELINING on both sides
RFC 2920 was advertised and parsed by both sides and used by neither.
On the client, `envelope` sends MAIL FROM and every RCPT TO as one group
and reads all of their replies, turning an envelope of n recipients from
n+1 round trips into one. `hello` sets `pipelining` from the EHLO
response, and clears it after a HELO fallback, because §3.1 allows
pipelining only against a server that said it could take it; when it is
false the same call waits for each reply and produces the same result.
`sendMail` goes through it and keeps its all-or-nothing contract: a
refused recipient means RSET and an error, not a delivery to the rest.
DATA is deliberately not in the group even though §3.1 allows it as the
last command of one. After a 354 the transaction is committed and the
only ways out are to send the message or to send an empty one to whoever
was accepted -- so stopping before DATA keeps that choice with the caller
and costs one round trip out of the n+1 saved.
Reading a group needed a way to keep a reply: they arrive one after
another into a single buffer, so the failing one is gone by the time the
group has been drained. `discardReply` reads a reply without touching
that buffer, which lets the first refusal stay in `last_reply` while the
rest of the group is drained -- and the same trick makes LMTP's `end`
able to report which verdict failed, which the last commit said it could
not.
On the server the rule is §3.2's: hold back the replies to RSET, MAIL
FROM and RCPT TO, and send everything pending the moment the input is
empty. That condition is the whole safety argument -- a reply is only
ever held while another command is already waiting to be answered, so the
client is never left waiting for something sitting in a buffer -- and the
commands whose replies must never be held (EHLO, DATA, VRFY, EXPN, TURN,
QUIT, NOOP) are exactly the ones still using the unconditional `reply`.
A test writer that records its flush boundaries pins it down: eight
replies leave in five writes, with the three envelope replies and the 354
as one of them.
Every reference the README cites is now filed in the Zotero library as
well, RFCs by their DOIs so that none of the metadata is typed by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
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
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
RFC review: standards list, doc links, exim-derived protocol gauntlet
README gains a Standards section listing every implemented RFC with
its per-side coverage (5321, 1870, 6152, 2920, 3207, 8314, 4954, 4616,
2195, draft-murchison-sasl-login, 3463/2034, 6531, and 8446 via
tls.zig). Doc comments now link each RFC mention to the datatracker,
with section fragments where a section is cited; authLogin's doc notes
it has no RFC.
The review surfaced two fixes: the server always emitted RFC 3463
enhanced status codes but never advertised ENHANCEDSTATUSCODES
(RFC 2034) - now it does; and root.zig's module doc still called TLS
an eventual feature.
Also adds a protocol gauntlet unit test distilled from exim's test
suite (test/scripts/0000-Basic, notably 0019's syntax-error dialogue
and the 0008/0100 dotted message lines), asserting the exact 28-reply
transcript and resulting envelope. The dialogue was first validated by
running exim's own scriptable test client (test/src/client.c, built
with zig cc) against zsmtp serve.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx