Commits
EXPN fell through to the unknown-command arm and answered 500, which
claims never to have heard of a command this server can perfectly well
parse. RFC 5321 §4.2.4 permits either 500 or 502 for a command that is
not implemented, and 502 is the one that is true here: the verb was
recognized and the service is not offered. A client can tell the
difference and act on it -- 500 means stop asking, 502 means this server
in this configuration.
VRFY keeps its 252, which is the compliant answer for a server that will
not check an address in advance but will accept the mail, and which
§4.5.1 requires of it -- 500 or 502 there would put this out of
compliance, since VRFY is one of the commands a server must support.
Both now require their argument, which the ABNF makes mandatory: `vrfy =
"VRFY" SP String CRLF`, and EXPN the same shape. Neither has anything to
act on without one, so a bare VRFY is a syntax error rather than a
command with an empty operand.
The RFC 2034 conformance walk covers EXPN now too, so its 502 is checked
for a status code whose class agrees, along with everything else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
`Reply.enhanced` parses the class.subject.detail code RFC 3463 defines
and RFC 2034 puts at the front of a reply's text; `Reply.message` gives
the text without it. 550 is "no", where 5.1.1 is "no, that mailbox does
not exist" and 5.7.1 is "no, and not because of anything about the
address" -- a difference a caller can act on and the three digits cannot
express.
The parser is strict on purpose, because the failure mode of a loose one
is misreading an ordinary message that happens to start with digits. The
class must be one of the three RFC 3463 defines, each field is one to
three digits with no leading zeros, and the code must be followed by a
space or be the whole text. "2.1 GB is too large" is not a status code
and does not parse as one.
`agrees` is there because nothing else checks it: a 250 carrying a 5.x.x
code is a server contradicting itself, RFC 3463 does not say what a
receiver should do about that, and a caller that reads only one half will
believe the wrong one.
The other half of this is a test that walks a session touching most of
the command table and checks every reply against RFC 2034's rule --
prefaced with a code whose class agrees, except the greeting, the EHLO
response and any 3xx, which must *not* carry one. It passes, and it is
not vacuous: deleting the code from one reply makes it fail naming that
line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
RFC 4954 §5, which is how a relay says who originally submitted the
message it is carrying. `protocol.Submitter` is the parameter: a mailbox,
or the two characters `<>` that mean "I do not know". Both sides handle
it, and the xtext codec DSN already needed is what encodes the mailbox --
the `=` in an address like e=mc2@example.com would otherwise end the
parameter.
The interesting rule is §5's, and it is the opposite of what one would
guess: a server advertising AUTH **must accept the parameter even from a
client that has not authenticated**, and must then behave as though `<>`
had been sent. Taking it and disbelieving it, rather than refusing it, is
what keeps a relay from having to know in advance whether it will be
trusted. So the server records the claim as `.unknown` when the session
is unauthenticated, and a `.mailbox` reaching a handler always means an
authenticated peer asserted it -- `Envelope.authenticated_as` says which
peer, which is the other half of deciding whether to believe it.
A server that advertises no mechanisms at all is in a different position:
it never offered the extension, so the parameter is simply unrecognized
and gets 555.
`<>` is a claim rather than an absence, which is why the client spells it
`.unknown` rather than leaving the parameter off: a server that receives
nothing learns nothing, where one that receives `<>` learns the peer
considered the question.
Verified against exim, which decoded the xtext, believed an authenticated
peer, and kept the value -- the interop test reads it back out of the
delivered message, exim having no log selector that shows it. And against
postfix, which advertises no AUTH on that port, where the client refuses
to send the parameter at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Surveying the gaps against the code rather than against the last list
turned up something the list did not have, because I had put it there:
the AUTH paths each held about 27 KB on the stack. `max_sasl_message` was
8192, base64 makes the encoded form 10924, and three such buffers were
live in one frame on both sides. Fine on a main thread; not fine on a
server handing each connection a 64 KB stack.
Both sides now take the scratch from the caller, for the same reason
`reply_buffer` is the caller's: how much room a mechanism needs is the
caller's to know, and the range is wide -- a few hundred bytes for the
classic mechanisms, several kilobytes for an OAuth token. An absent or
undersized one is `error.SaslBufferTooSmall` rather than a hidden
allocation or an array the caller cannot see.
Three buffers became two, and the two take turns. The split is
four-to-three, which is base64's expansion exactly, so the coded half
always holds the encoding of a full plaintext half. A challenge decodes
into the coded half; the mechanism consumes it while writing its answer
into the plain half; the answer encodes back over the challenge, which is
finished with. There is a test that runs a real CRAM-MD5 exchange through
the 896-byte minimum, where those halves are 512 and 384.
The survey also found that `Extensions.auth` is the one field of
`Extensions` that borrows -- it points into the reply buffer -- where
zig-pop3 answered the same question with a bounded copy. That one is
written down rather than fixed: the two libraries disagree on purpose
until one of them gives way.
And the README's code samples said `zig-smtp.Client`, which is not an
identifier. The rename replaced the name everywhere including inside the
examples; they say `smtp.` now, matching `@import("smtp")`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
`Handler.authenticate` is gone. `fn (username, password) bool` could
carry PLAIN and LOGIN and nothing else, because it assumed the server
held something a password could be compared against -- so the server
offered exactly those two and could never offer a third.
`Options.auth_mechanisms` replaces it: a list of `sasl.Server`,
advertised by name in the EHLO response and driven by the loop that is
left here, which is the SMTP part -- the 334 challenges, the `*` that
cancels, 235, and the 504 for a name nothing answers to. The server can
now do CRAM-MD5, which there is a test for against RFC 2195's published
response, and EXTERNAL, and anything else zig-sasl grows.
Where the credential comes from moved to the mechanism, which is the
whole reason this works. PLAIN and LOGIN share a `PasswordCheck` and are
told only whether a password was right; CRAM-MD5 needs a
`PasswordLookup` and gets the password itself, because it must compute
the same HMAC the client did. That has always been the argument against
offering CRAM-MD5 and it is now visible in the types.
The mechanisms hold per-exchange state, so a session needs its own set
rather than a shared one -- the demo server builds a fresh CRAM-MD5
challenge per connection for exactly that reason.
`Envelope.authenticated_as` is new, and had to be: the identity used to
reach the handler because the handler did the checking, and now the
mechanism does. It is what the mechanism reported rather than what the
client typed, which for PLAIN's authorization identity is not the same
thing, and it is what a handler deciding whether to relay actually wants.
Verified end to end: the demo server advertises PLAIN LOGIN CRAM-MD5, a
CRAM-MD5 login succeeds over a plaintext session with no cleartext
opt-in, swaks still logs in with LOGIN, and the whole interop suite
passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
It sits beside zig-pop3, zig-ftp, zig-scram and zig-sasl, and "zsmtp" was
the odd one out.
Three names come out of it rather than one, following what those siblings
do. The repository and the package are `zig-smtp`; the Zig module is
`smtp`, so callers write `@import("smtp")` the way zig-pop3's callers
write `@import("pop3")`; and the demo CLI is `zig-smtp`, matching zig-ftp
rather than pop3's bare `pop3`, because `smtp` is too generic a name to
put on somebody's PATH.
The manifest fingerprint had to change with the package name -- Zig
derives it from that name and refuses the old one -- so this is a new
package as far as the package manager is concerned, not a renamed one.
The Radicle identity was renamed in place, so the RID is unchanged and
every `rad clone rad:z3ZKHgoDKEue8FT7sV6fHZdtjxRx1` in the wild still
works. The Tangled mirror could not be renamed and was recreated.
The published documentation moves with it, from jeff.jcollie.page/zsmtp/
to jeff.jcollie.page/zig-smtp/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
PLAIN, LOGIN and CRAM-MD5 are gone from the client. They were three of
the four copies in this tree -- zig-pop3 has its own, an IMAP library
would have made a third, and zig-scram's SCRAM was reachable from none of
them. They live in zig-sasl now, re-exported here as `zsmtp.sasl` so a
caller does not need a second import to say `sasl.Plain`.
What is left behind is the part that was ever specific to SMTP, and it is
one loop: the AUTH command, the 334 challenges, the `*` that cancels, the
235 that ends it. `authenticate` takes a `sasl.Client` and drives it.
`Extensions.auth` is now the mechanism names as the server sent them,
which is what `sasl.Client.selectFromList` reads -- so the preference
order and the don't-send-a-password-in-the-clear rule are one
implementation instead of one per protocol.
Two things the old code could not express now work. A mechanism may
answer a challenge with nothing, which is how SCRAM acknowledges the
server's proof and how XOAUTH2 acknowledges a failure report. And
`error.ServerNotAuthenticated` is the server reporting success while the
mechanism says it never finished proving what it set out to -- for SCRAM,
a peer that took the client's proof and offered none of its own, which is
precisely what something in the middle without the verifier would do.
Nothing here could tell that from a real success before.
A mechanism failing mid-exchange now cancels with `*` and reads the 501,
rather than leaving the server waiting for a line that is not coming.
The server side is unchanged: it still implements PLAIN and LOGIN itself,
against a plaintext password, because zig-sasl's server side does not yet
reach past PLAIN. That is the next thing.
Verified against postfix, exim and dovecot: the whole interop suite
passes, including AUTH PLAIN and LOGIN to exim over both a cleartext
opt-in and STARTTLS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
CHUNKING was here and BINARYMIME was refused with 555, which left the
framing without the thing it exists to frame. BDAT carries a length, so
it can carry content that holds what would otherwise be a terminator;
that is the whole reason RFC 3030 defines the two together and says
BINARYMIME can only be used with CHUNKING.
The server advertises it beside CHUNKING, takes `BODY=BINARYMIME`, and
answers DATA for such a message with 503 as §3 requires -- binary has no
line structure, so a line holding a single dot cannot mean the end of it.
Nothing had to change in the receive path: BDAT already copied octets
without touching line endings, which is what "preserve all bits in each
octet" asks for, and there is now a test that walks all 256 byte values
through it to keep that true.
The client gained `MailOptions.body`, so `BODY=` is sayable at all --
7BIT and 8BITMIME as well, which the client could parse off EHLO and
never send. Declaring `.binary_mime` commits the transaction to BDAT, and
`data` refuses it with `error.BinaryRequiresChunking` rather than making
the round trip to be told 503. The demo CLI exposes it as --binarymime,
which implies --chunking because there is no other way to send it.
`Envelope.Body` moved to `protocol.Body` and lost its `unspecified`
variant in favour of `?protocol.Body`, since the client needs the same
enum and the envelope's other optional parameters are already spelled
that way. That is the breaking part.
The torture dialogue asserted 555 for BODY=BINARYMIME, which was exim's
answer and is no longer ours; those cases now use BODY=BINARY, which is
still not a body-value, so they go on testing what they were written to
test.
Verified against postfix and exim, neither of which offers BINARYMIME:
the client refuses to send binary to them at all, which is what RFC 3030
demands without qualification. Bit-exactness is checked end to end
through the CLI, all 256 octets plus a bare CR and a lone dot line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
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
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
The README grows a "Known gaps" section, because the list was being
rediscovered each time somebody asked what zsmtp does not do. It opens
with what is deliberately absent -- message composition, and everything an
MTA does around a session -- so that the rest reads as a list of things to
do rather than a list of complaints.
The first of them is now done. DSN is the SMTP extension of RFC 3461 and
nothing else: `RET` and `ENVID` on MAIL, `NOTIFY` and `ORCPT` on RCPT.
Generating the `multipart/report` that carries a delivery status back to
the sender is RFC 3464, which is message composition wearing a protocol
hat, and it stays out.
The server advertises DSN, validates all four parameters and answers a bad
one with 501 as RFC 3461 §6 asks. `RET` and `ENVID` land on the
`Envelope`; `NOTIFY` and `ORCPT` belong to a recipient rather than a
message, so `Envelope.recipients` is now a slice of `Recipient` and the
`rcptTo` callback receives one instead of a bare address -- which is the
breaking part of this change, along with RCPT parameters no longer being
refused wholesale with 555.
On the client, `mail` and `rcpt` are the parameterized forms of `mailFrom`
and `rcptTo`, and `mailFromUtf8` becomes a wrapper over `mail`. The demo
CLI exposes the four as --ret, --envid, --notify and --orcpt.
Both `ENVID` and the `ORCPT` address are xtext (RFC 3461 §4), so that
codec is in `protocol`: `writeXtext` escapes everything that is not an
xchar, which means the encoded form can never end the command line and a
value from untrusted input is safe by construction rather than by
checking. Decoding is strict in the other direction -- a byte the encoder
was obliged to escape is rejected rather than passed through -- so one
sequence of bytes has one spelling. The length limits are on the encoded
form, which is why they are checked there: 100 characters for ENVID, 500
for the whole ORCPT parameter.
Verified against real implementations in the interop test, which now sends
the DSN parameters to postfix and to exim (told to advertise DSN, which it
does not do by default) and round-trips them through zsmtp's own server,
where the ENVID comes back with its space intact and the ORCPT with the
'+' that had to be encoded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Two things the client got wrong, both of which put something on the wire
that the caller did not ask for.
`mailFrom`, `rcptTo`, `mailFromUtf8` and `hello` interpolated their
argument straight into the command line, so an address carrying CR or LF
ended the line early and everything after it was read by the server as
further SMTP commands -- `bob@example.net>\r\nRCPT TO:<victim@example.net`
delivered to two people. Those four now check the argument first and
return `error.UnsafeArgument` rather than send it, as does AUTH PLAIN,
where the byte that matters is NUL: it separates the three fields, so one
hidden inside a field moves the boundary and authenticates as somebody
else. The check is `protocol.isSafeArgument`, and it is deliberately
framing only -- CR, LF and NUL and nothing else -- because the RFC 5321
path grammar rejects addresses that real deployments carry every day, and
a client that refused them would be the wrong tool.
`authenticate` preferred AUTH PLAIN unconditionally, which sent the
password in the clear whenever the transport was. The client cannot tell
on its own -- it is handed a reader and a writer and has no idea what is
under them -- so it now assumes the worst and takes the answer from the
caller: `setTransport` records it for a STARTTLS upgrade, and a session
that speaks TLS from the first byte sets `security` itself. PLAIN and
LOGIN return `error.InsecureTransport` on a plaintext transport, and
`authenticate` inverts its preference there to CRAM-MD5, the one
mechanism of the three that never puts the password on the wire.
`allow_cleartext_auth` is the way past that for a connection protected by
something this library cannot see -- a unix socket, an SSH tunnel, a
loopback test -- and `zsmtp send --allow-cleartext-auth` exposes it.
The interop test grew the case that matters: the same delivery to exim
fails without the opt-in and succeeds over STARTTLS without one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
The repository is now on Radicle as rad:z3ZKHgoDKEue8FT7sV6fHZdtjxRx1, and
the README's "Where this lives" section says so. The ID is the whole of it:
a Radicle repository has no other name, so a README that leaves the RID out
has left out the one thing a reader needs in order to seed or clone it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
The repository now has a mirror at https://tangled.org/jcollie.dev/zsmtp,
pushed to the `tangled` remote on knot.jcollie.dev.
A "Where this lives" section in the README names both homes, gives the
https clone URL (which works without an account on the server), and links
the published API documentation, which nothing pointed at before.
The rest is the host move: `origin`, the package's `meta.homepage`, and
the docs publish job, which now publishes to jeff.jcollie.page via the
jcollie.page server. Both names resolve, so the job will not fail at the
publish step.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
The package moves to the repo root and adopts the vaultz style:
finalAttrs, a fileset-narrowed src (build.zig, build.zig.zon, src - so
flake or docs edits no longer invalidate the derivation), the nixpkgs
zig setup hook for the standard phases (yielding a portable
-Dcpu=baseline --release=safe binary instead of hand-rolled build and
check phases), zigBuildFlags/zigCheckFlags carrying --system with the
zon2nix-generated dependencies, and full meta with longDescription,
homepage, mainProgram, and platforms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
build.zig.zon.nix is generated from build.zig.zon by
github.com/jcollie/zon2nix (regenerate with
`nix run github:jcollie/zon2nix#zon2nix -- --nix=build.zig.zon.nix
--16 build.zig.zon` whenever dependencies change). It evaluates to the
package layout `zig build --system` expects, replacing the hand-written
nix/zig-deps.nix: package.nix now builds and tests with --system, the
flake exposes it as packages.zig-deps, and the docs workflow uses it
directly instead of materializing zig-pkg. The lazy exim and isemail
dependencies are included, so those test features work offline too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The first workflow run failed because zig build docs tried to fetch
the tls.zig dependency from GitHub on the runner and its TLS setup
failed (TlsInitializationFailed). The zig-pkg directory derivation is
now factored out of package.nix into nix/zig-deps.nix, exposed as
packages.zig-pkg, and the workflow copies it into the checkout before
building so zig never touches the network. Verified from a clean clone
with an empty Zig cache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
zig build docs emits the autodoc bundle for the zsmtp module into
zig-out/docs, and the workflow publishes it to
https://jeff.ocj.page/zsmtp/ with git-pages-cli (now in the devshell)
on every push to main, mirroring the notmuch.zig setup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Project convention: io comes first in any function taking a std.Io
(after the receiver, for methods). The one violation was Tls.init,
which took (t, gpa, io, ...) and is now (t, io, gpa, ...); callers and
documentation examples updated to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The RFC 5321 address round-trip test now embeds tests.xml and
tests-original.xml straight from Dominic Sayers' canonical isemail
repository (pinned, .lazy = true), gated by -Disemail-corpus so plain
builds and the network-less Nix sandbox never fetch it. The test parses
the XML at test time (element extraction plus full entity unescaping),
filters both files to the RFC 5321-valid categories, round-trips all
125 addresses through Command.parse, and asserts a minimum count so
the extraction cannot silently rot; without the option it skips.
The canonical repo supersedes the copy bundled with the
email-addresses npm package: same current tests.xml plus
tests-original.xml with 92 additional RFC 5321-valid cases (richer
quoted-string escapes and address literals). Since no corpus text is
distributed in this repository anymore, the BSD-3-Clause snippet,
license file, and README note are removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Reviewing postfix's address corpora (src/global/mail_addr_crunch.in)
surfaced a parser bug: parsePathArgs located the closing angle bracket
with a plain scan, truncating legal RFC 5321 addresses whose quoted
local-part contains '>' (e.g. <"a>b"@example.com>). The scan is now
quote-aware with backslash-escape handling, and an unterminated quote
is a syntax error.
Postfix has no exim-style protocol dialogue tests to adopt: its .in/
.ref corpora exercise the policy engine with pre-tokenized inputs, and
the smtpstone tools are load generators that are neither shipped by
nixpkgs nor standalone-buildable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The server advertises SMTPUTF8 and accepts the valueless SMTPUTF8 MAIL
parameter (a value gets 501). Non-ASCII envelope addresses on MAIL and
RCPT are rejected with 553 5.6.7 (RFC 6533) unless the transaction
requested SMTPUTF8, and must be well-formed UTF-8 even then. The flag
rides the transaction state and reaches handlers via Envelope.smtputf8.
The client gains mailFromUtf8 and the CLI gains send --smtputf8, which
errors cleanly when the server does not advertise the extension.
The torture script and the byte-for-byte gauntlet gain the SMTPUTF8
cases, and the VM interop suite delivers with a UTF-8 sender to real
Postfix (ICU-enabled in nixpkgs); 19 subtests pass. Exim interop is
skipped since nixpkgs exim is built without SUPPORT_I18N.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Command.bdat parses "BDAT <size> [LAST]" strictly. The server
advertises CHUNKING and receives chunked messages on both handler
paths: the collecting path reassembles raw chunks (no dot-stuffing,
max_message_size enforced with 552), and BdatReader adapts the chunk
sequence into the streaming handler's reader, replying 250 between
chunks and handling RSET/QUIT/protocol violations mid-stream, with
unread remainder drained. Framing is strictly length-based: a BDAT
without a transaction still consumes its payload octets, and chunk
payloads that look like commands are data.
The client gains Extensions.chunking, bdat(chunk, last) (verbatim
transmission, one flush per chunk), and sendMessageChunked; the CLI
gains send --chunking, streaming stdin as BDAT chunks.
The exim-client torture script and the byte-for-byte gauntlet unit
test gain a BDAT section, and the VM interop suite delivers via
CHUNKING to real Postfix and Exim (18 subtests passing). BINARYMIME
remains deliberately unimplemented (BODY=BINARYMIME is rejected).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The protocol gauntlet test in Server.zig is wrapped in SPDX snippet
tags declaring GPL-2.0-or-later with copyright to The Exim Maintainers
and the University of Cambridge (per the exim source headers), since
its command dialogue and message lines are adapted from exim's test
suite; the reply expectations are ours.
test/protocol-torture.script had been annotated MIT by mistake - its
REUSE.toml annotation is corrected to GPL-2.0-or-later with the same
holders. LICENSES/GPL-2.0-or-later.txt is added and the README notes
that this test-only material carries a different license than the
MIT library.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The exim source (pinned commit) is declared with .lazy = true and only
fetched when requested: `zig build -Dexim-client` compiles exim's
scriptable SMTP test client (test/src/client.c) to
zig-out/bin/exim-client. The option guard keeps plain builds, tests,
and the network-less Nix sandbox build from ever fetching it.
test/protocol-torture.script (with a REUSE.toml annotation) carries
the 28-reply dialogue distilled from exim's test suite; running
exim-client with it against zsmtp serve passes all expectations, and
the same dialogue is asserted byte-for-byte by the protocol gauntlet
unit test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
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
Add identifier-named doctests for every remaining public function and
substantive public type: crlf, Reply.read/lines and the four reply
class predicates, Command.parse, PathArgs.paramIterator,
ParamIterator.init/next, Extensions and Extensions.Auth.any,
DataWriter.end, and Server's init, Options, Decision, Envelope, and
Handler. Nested declarations get their tests inside the container so
autodoc attaches them to the member.
Left without doctests, deliberately: Tls.zig and Server.TlsOptions
(need a live TLS peer; examples stay in doc comments), plain error
sets, and pure data shapes already demonstrated by their containers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Server.Options.starttls becomes tls: ?TlsOptions with a mode field:
.starttls keeps the RFC 3207 behavior (advertise, 220, upgrade, state
reset) and .implicit performs the tls.zig server handshake before the
greeting (SMTPS, port 465 style). Both paths share one upgradeToTls
helper; in implicit mode STARTTLS is never advertised and the command
gets 502. Breaking rename for Server.Options at version 0.0.0.
The serve CLI grows --implicit-tls (requires --tls-cert/--tls-key) and
its flag parser now supports valueless flags.
Verified locally with openssl s_client (greeting arrives inside the
TLS channel) and our own --tls client, plus a STARTTLS regression
check. The VM interop test adds an implicit-TLS zsmtp server and a
swaks --tlsc subtest against it; all 16 subtests pass.
The Status list is complete: TLS in both modes on both sides, AUTH,
streaming bodies, and MAIL parameter validation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
protocol.ParamIterator iterates the KEY=value parameters of MAIL and
RCPT commands (RFC 5321 4.1.2), reachable via PathArgs.paramIterator().
The server validates MAIL parameters before the mailFrom callback:
SIZE= (RFC 1870) over max_message_size is rejected early with 552 and
malformed values with 501; BODY=7BIT/8BITMIME (RFC 6152) are accepted
case-insensitively and other values get 555, as do unrecognized
keywords. A rejected parameter leaves the transaction unstarted. RCPT
parameters are all rejected with 555 since no RCPT extensions are
advertised.
Envelope gains declared_size and body (defaulted, so existing handlers
are unaffected), populated from accepted MAIL parameters and reset
with the rest of the transaction state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Six std.testing.fuzz targets, all also running once as part of the
normal test suite:
- Command.parse: arbitrary bytes parse or error cleanly, and payload
slices always lie within the input line
- Reply.read: arbitrary reply streams; successful codes stay in range
- client vs arbitrary server replies: full greet/hello/auth/sendMail
sequence must fail cleanly, never crash
- DataWriter differential: streaming stuffing must be byte-identical
to writeStuffed under fuzzer-chosen chunk boundaries
- server session vs arbitrary client input (auth enabled, discarding
writer)
- collecting vs streaming DATA differential: both handler paths must
yield identical unstuffed content
Verified with ~5 minutes of coverage-guided fuzzing (corpus saturated
at 27 entries, no failures). Running the fuzzer on stock Zig 0.16.0
requires a patched std (its fuzz-mode test runner does not compile and
the coverage server panics on a binary with no fuzz tests; both fixed
on master) - documented in the README.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Client: data() starts the DATA phase and returns a DataWriter, an
Io.Writer whose dot-stuffing and CRLF-normalization state machine
persists across writes, so chunks may split lines, CRLF pairs, and
leading dots at any byte boundary with no line-length limits.
sendMessageReader() streams from any Io.Reader; sendMessage() is now a
thin wrapper over data(), sharing one stuffing implementation.
Server: the handler vtable gains messageReader as a streaming
alternative to message (exactly one must be set). The callback gets an
Io.Reader backed by a zero-copy line adapter that removes dot-stuffing;
anything left unread is drained through the terminator so early returns
cannot desynchronize the session. max_message_size is not enforced in
streaming mode.
The CLI send command streams stdin instead of buffering it; verified
with a 5 MB, 100k-dotted-line message round-tripping byte-exact, plus
the full VM interop suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Rename existing demonstrative tests to identifier-named doctests so
autodoc attaches them to their declarations (readLine, Reply, Command,
writeStuffed, sendMail, hello, starttls, authenticate, authPlain,
authLogin, authCramMd5, run), and add new small scripted doctests for
the client functions that had none: init, greet, setTransport,
mailFrom, rcptTo, sendMessage, rset, noop, quit.
Server.run's doctest now constructs the session inline instead of
going through the runScript test helper, so the example shows the
actual API. Edge-case tests keep descriptive string names. Tls.zig
and Server.StartTls have no runnable doctests since a handshake needs
a live peer; their usage examples stay in doc comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Client (RFC 4954/4616/2195):
- Extensions.auth parses the advertised mechanism list (including the
legacy AUTH= form) into plain/login/cram_md5 flags
- authLogin and authCramMd5 join authPlain; CRAM-MD5 is verified against
the RFC 2195 example vector
- authenticate() picks PLAIN, then LOGIN, then CRAM-MD5; a 535 surfaces
as error.AuthenticationFailed with the reply in last_reply
Server:
- an optional authenticate handler callback enables AUTH PLAIN and
LOGIN: initial responses, 334 challenges, "*" cancellation, bad
base64 (501), unknown mechanism (504), re-auth/mid-transaction (503)
- Options.require_auth rejects MAIL with 530 until authenticated;
STARTTLS resets auth state
CLI: send grew --user/--password/--auth-method, serve grew
--auth user:pass (implies require_auth).
VM interop additions: zsmtp client authenticates to Exim via PLAIN and
LOGIN (its plaintext authenticator; CRAM-MD5 is not compiled into
nixpkgs exim) with a wrong-password rejection, and swaks authenticates
to the auth-required zsmtp server via PLAIN and LOGIN with
wrong-password and unauthenticated rejections.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The VM test now also runs the zsmtp client against Exim (ports
2625/2626) over plaintext, STARTTLS, and implicit TLS, with taint-safe
appendfile delivery checked in /var/spool/exim-mail.
Exim exposed a standard-library TLS bug: std.crypto.tls.Client only
advances its record-decryption state upon receiving the TLS 1.3
middlebox-compatibility ChangeCipherSpec record, which is optional and
disabled by Exim's OpenSSL setup, so the handshake died with
TlsUnexpectedMessage. The client-side Tls wrapper now uses ianic/tls.zig
(already used server-side) instead: init is in-place (the connection
holds interior pointers), and the flush-through workaround is gone since
tls.zig flushes each record to the stream.
A sendmail interop test was built and passing but removed again since
nixpkgs does not package the sendmail MTA.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
nix/package.nix builds zsmtp with zig_0_16 and runs the unit tests; the
tls.zig dependency is provided offline by materializing it into the
project-local zig-pkg/<hash>/ directory with only the files from the
dependency's paths list, so Zig's content hash matches.
nix/interop-test.nix exercises zsmtp against third-party
implementations in one VM:
- zsmtp client -> Postfix: plaintext (25), STARTTLS (25), implicit TLS
(465, submissions wrapper mode), verified via alice's maildir spool
- swaks -> zsmtp server: plaintext and STARTTLS (snakeoil EC cert),
verified via the server's journal
Exposed as packages.zsmtp/default and checks.{zsmtp,interop}. Postfix
on NixOS delivers maildir-style (mail_spool_directory has a trailing
slash), so assertions grep the directory recursively, with 60s
timeouts to fail fast.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Client (std.crypto.tls):
- Tls.zig wraps std.crypto.tls.Client for implicit TLS and STARTTLS,
verifying against the system trust store by default (caller-managed
bundle and insecure modes available)
- Client.starttls() does the RFC 3207 exchange; setTransport() swaps in
the encrypted reader/writer
- Tls.writer() is a flush-through wrapper: std's TLS writer encrypts on
flush but leaves records in the stream writer's buffer, which deadlocks
request/reply protocols like SMTP
Server (ianic/tls.zig, pinned to zig-0.16.x head):
- Options.starttls advertises and accepts STARTTLS (TLS 1.3 only): 220,
server handshake over the raw stream, transport swap, RFC 3207 state
reset; 503 on a second STARTTLS, close_notify on QUIT
- tls dependency re-exported as zsmtp.tls for CertKeyPair loading
CLI: send grew --tls/--starttls/--insecure, serve grew
--tls-cert/--tls-key. Verified end to end over real sockets: zsmtp
client <-> zsmtp server STARTTLS, openssl s_client -starttls smtp
against the server, and openssl s_server against the client.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Transport-agnostic SMTP (RFC 5321) over std.Io.Reader/Writer pairs:
- protocol.zig: reply parsing, command parsing, dot-stuffing
- Client.zig: EHLO/HELO, extensions, AUTH PLAIN, mail transactions
- Server.zig: single-connection session with handler vtable, command
sequencing, recipient/message-size limits
- main.zig: demo CLI (send via stdin, debug serve on loopback)
MIT licensed with SPDX headers; REUSE 3.3 compliant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
EXPN fell through to the unknown-command arm and answered 500, which
claims never to have heard of a command this server can perfectly well
parse. RFC 5321 §4.2.4 permits either 500 or 502 for a command that is
not implemented, and 502 is the one that is true here: the verb was
recognized and the service is not offered. A client can tell the
difference and act on it -- 500 means stop asking, 502 means this server
in this configuration.
VRFY keeps its 252, which is the compliant answer for a server that will
not check an address in advance but will accept the mail, and which
§4.5.1 requires of it -- 500 or 502 there would put this out of
compliance, since VRFY is one of the commands a server must support.
Both now require their argument, which the ABNF makes mandatory: `vrfy =
"VRFY" SP String CRLF`, and EXPN the same shape. Neither has anything to
act on without one, so a bare VRFY is a syntax error rather than a
command with an empty operand.
The RFC 2034 conformance walk covers EXPN now too, so its 502 is checked
for a status code whose class agrees, along with everything else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
`Reply.enhanced` parses the class.subject.detail code RFC 3463 defines
and RFC 2034 puts at the front of a reply's text; `Reply.message` gives
the text without it. 550 is "no", where 5.1.1 is "no, that mailbox does
not exist" and 5.7.1 is "no, and not because of anything about the
address" -- a difference a caller can act on and the three digits cannot
express.
The parser is strict on purpose, because the failure mode of a loose one
is misreading an ordinary message that happens to start with digits. The
class must be one of the three RFC 3463 defines, each field is one to
three digits with no leading zeros, and the code must be followed by a
space or be the whole text. "2.1 GB is too large" is not a status code
and does not parse as one.
`agrees` is there because nothing else checks it: a 250 carrying a 5.x.x
code is a server contradicting itself, RFC 3463 does not say what a
receiver should do about that, and a caller that reads only one half will
believe the wrong one.
The other half of this is a test that walks a session touching most of
the command table and checks every reply against RFC 2034's rule --
prefaced with a code whose class agrees, except the greeting, the EHLO
response and any 3xx, which must *not* carry one. It passes, and it is
not vacuous: deleting the code from one reply makes it fail naming that
line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
RFC 4954 §5, which is how a relay says who originally submitted the
message it is carrying. `protocol.Submitter` is the parameter: a mailbox,
or the two characters `<>` that mean "I do not know". Both sides handle
it, and the xtext codec DSN already needed is what encodes the mailbox --
the `=` in an address like e=mc2@example.com would otherwise end the
parameter.
The interesting rule is §5's, and it is the opposite of what one would
guess: a server advertising AUTH **must accept the parameter even from a
client that has not authenticated**, and must then behave as though `<>`
had been sent. Taking it and disbelieving it, rather than refusing it, is
what keeps a relay from having to know in advance whether it will be
trusted. So the server records the claim as `.unknown` when the session
is unauthenticated, and a `.mailbox` reaching a handler always means an
authenticated peer asserted it -- `Envelope.authenticated_as` says which
peer, which is the other half of deciding whether to believe it.
A server that advertises no mechanisms at all is in a different position:
it never offered the extension, so the parameter is simply unrecognized
and gets 555.
`<>` is a claim rather than an absence, which is why the client spells it
`.unknown` rather than leaving the parameter off: a server that receives
nothing learns nothing, where one that receives `<>` learns the peer
considered the question.
Verified against exim, which decoded the xtext, believed an authenticated
peer, and kept the value -- the interop test reads it back out of the
delivered message, exim having no log selector that shows it. And against
postfix, which advertises no AUTH on that port, where the client refuses
to send the parameter at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Surveying the gaps against the code rather than against the last list
turned up something the list did not have, because I had put it there:
the AUTH paths each held about 27 KB on the stack. `max_sasl_message` was
8192, base64 makes the encoded form 10924, and three such buffers were
live in one frame on both sides. Fine on a main thread; not fine on a
server handing each connection a 64 KB stack.
Both sides now take the scratch from the caller, for the same reason
`reply_buffer` is the caller's: how much room a mechanism needs is the
caller's to know, and the range is wide -- a few hundred bytes for the
classic mechanisms, several kilobytes for an OAuth token. An absent or
undersized one is `error.SaslBufferTooSmall` rather than a hidden
allocation or an array the caller cannot see.
Three buffers became two, and the two take turns. The split is
four-to-three, which is base64's expansion exactly, so the coded half
always holds the encoding of a full plaintext half. A challenge decodes
into the coded half; the mechanism consumes it while writing its answer
into the plain half; the answer encodes back over the challenge, which is
finished with. There is a test that runs a real CRAM-MD5 exchange through
the 896-byte minimum, where those halves are 512 and 384.
The survey also found that `Extensions.auth` is the one field of
`Extensions` that borrows -- it points into the reply buffer -- where
zig-pop3 answered the same question with a bounded copy. That one is
written down rather than fixed: the two libraries disagree on purpose
until one of them gives way.
And the README's code samples said `zig-smtp.Client`, which is not an
identifier. The rename replaced the name everywhere including inside the
examples; they say `smtp.` now, matching `@import("smtp")`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
`Handler.authenticate` is gone. `fn (username, password) bool` could
carry PLAIN and LOGIN and nothing else, because it assumed the server
held something a password could be compared against -- so the server
offered exactly those two and could never offer a third.
`Options.auth_mechanisms` replaces it: a list of `sasl.Server`,
advertised by name in the EHLO response and driven by the loop that is
left here, which is the SMTP part -- the 334 challenges, the `*` that
cancels, 235, and the 504 for a name nothing answers to. The server can
now do CRAM-MD5, which there is a test for against RFC 2195's published
response, and EXTERNAL, and anything else zig-sasl grows.
Where the credential comes from moved to the mechanism, which is the
whole reason this works. PLAIN and LOGIN share a `PasswordCheck` and are
told only whether a password was right; CRAM-MD5 needs a
`PasswordLookup` and gets the password itself, because it must compute
the same HMAC the client did. That has always been the argument against
offering CRAM-MD5 and it is now visible in the types.
The mechanisms hold per-exchange state, so a session needs its own set
rather than a shared one -- the demo server builds a fresh CRAM-MD5
challenge per connection for exactly that reason.
`Envelope.authenticated_as` is new, and had to be: the identity used to
reach the handler because the handler did the checking, and now the
mechanism does. It is what the mechanism reported rather than what the
client typed, which for PLAIN's authorization identity is not the same
thing, and it is what a handler deciding whether to relay actually wants.
Verified end to end: the demo server advertises PLAIN LOGIN CRAM-MD5, a
CRAM-MD5 login succeeds over a plaintext session with no cleartext
opt-in, swaks still logs in with LOGIN, and the whole interop suite
passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
It sits beside zig-pop3, zig-ftp, zig-scram and zig-sasl, and "zsmtp" was
the odd one out.
Three names come out of it rather than one, following what those siblings
do. The repository and the package are `zig-smtp`; the Zig module is
`smtp`, so callers write `@import("smtp")` the way zig-pop3's callers
write `@import("pop3")`; and the demo CLI is `zig-smtp`, matching zig-ftp
rather than pop3's bare `pop3`, because `smtp` is too generic a name to
put on somebody's PATH.
The manifest fingerprint had to change with the package name -- Zig
derives it from that name and refuses the old one -- so this is a new
package as far as the package manager is concerned, not a renamed one.
The Radicle identity was renamed in place, so the RID is unchanged and
every `rad clone rad:z3ZKHgoDKEue8FT7sV6fHZdtjxRx1` in the wild still
works. The Tangled mirror could not be renamed and was recreated.
The published documentation moves with it, from jeff.jcollie.page/zsmtp/
to jeff.jcollie.page/zig-smtp/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
PLAIN, LOGIN and CRAM-MD5 are gone from the client. They were three of
the four copies in this tree -- zig-pop3 has its own, an IMAP library
would have made a third, and zig-scram's SCRAM was reachable from none of
them. They live in zig-sasl now, re-exported here as `zsmtp.sasl` so a
caller does not need a second import to say `sasl.Plain`.
What is left behind is the part that was ever specific to SMTP, and it is
one loop: the AUTH command, the 334 challenges, the `*` that cancels, the
235 that ends it. `authenticate` takes a `sasl.Client` and drives it.
`Extensions.auth` is now the mechanism names as the server sent them,
which is what `sasl.Client.selectFromList` reads -- so the preference
order and the don't-send-a-password-in-the-clear rule are one
implementation instead of one per protocol.
Two things the old code could not express now work. A mechanism may
answer a challenge with nothing, which is how SCRAM acknowledges the
server's proof and how XOAUTH2 acknowledges a failure report. And
`error.ServerNotAuthenticated` is the server reporting success while the
mechanism says it never finished proving what it set out to -- for SCRAM,
a peer that took the client's proof and offered none of its own, which is
precisely what something in the middle without the verifier would do.
Nothing here could tell that from a real success before.
A mechanism failing mid-exchange now cancels with `*` and reads the 501,
rather than leaving the server waiting for a line that is not coming.
The server side is unchanged: it still implements PLAIN and LOGIN itself,
against a plaintext password, because zig-sasl's server side does not yet
reach past PLAIN. That is the next thing.
Verified against postfix, exim and dovecot: the whole interop suite
passes, including AUTH PLAIN and LOGIN to exim over both a cleartext
opt-in and STARTTLS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
CHUNKING was here and BINARYMIME was refused with 555, which left the
framing without the thing it exists to frame. BDAT carries a length, so
it can carry content that holds what would otherwise be a terminator;
that is the whole reason RFC 3030 defines the two together and says
BINARYMIME can only be used with CHUNKING.
The server advertises it beside CHUNKING, takes `BODY=BINARYMIME`, and
answers DATA for such a message with 503 as §3 requires -- binary has no
line structure, so a line holding a single dot cannot mean the end of it.
Nothing had to change in the receive path: BDAT already copied octets
without touching line endings, which is what "preserve all bits in each
octet" asks for, and there is now a test that walks all 256 byte values
through it to keep that true.
The client gained `MailOptions.body`, so `BODY=` is sayable at all --
7BIT and 8BITMIME as well, which the client could parse off EHLO and
never send. Declaring `.binary_mime` commits the transaction to BDAT, and
`data` refuses it with `error.BinaryRequiresChunking` rather than making
the round trip to be told 503. The demo CLI exposes it as --binarymime,
which implies --chunking because there is no other way to send it.
`Envelope.Body` moved to `protocol.Body` and lost its `unspecified`
variant in favour of `?protocol.Body`, since the client needs the same
enum and the envelope's other optional parameters are already spelled
that way. That is the breaking part.
The torture dialogue asserted 555 for BODY=BINARYMIME, which was exim's
answer and is no longer ours; those cases now use BODY=BINARY, which is
still not a body-value, so they go on testing what they were written to
test.
Verified against postfix and exim, neither of which offers BINARYMIME:
the client refuses to send binary to them at all, which is what RFC 3030
demands without qualification. Bit-exactness is checked end to end
through the CLI, all 256 octets plus a bare CR and a lone dot line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
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
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
The README grows a "Known gaps" section, because the list was being
rediscovered each time somebody asked what zsmtp does not do. It opens
with what is deliberately absent -- message composition, and everything an
MTA does around a session -- so that the rest reads as a list of things to
do rather than a list of complaints.
The first of them is now done. DSN is the SMTP extension of RFC 3461 and
nothing else: `RET` and `ENVID` on MAIL, `NOTIFY` and `ORCPT` on RCPT.
Generating the `multipart/report` that carries a delivery status back to
the sender is RFC 3464, which is message composition wearing a protocol
hat, and it stays out.
The server advertises DSN, validates all four parameters and answers a bad
one with 501 as RFC 3461 §6 asks. `RET` and `ENVID` land on the
`Envelope`; `NOTIFY` and `ORCPT` belong to a recipient rather than a
message, so `Envelope.recipients` is now a slice of `Recipient` and the
`rcptTo` callback receives one instead of a bare address -- which is the
breaking part of this change, along with RCPT parameters no longer being
refused wholesale with 555.
On the client, `mail` and `rcpt` are the parameterized forms of `mailFrom`
and `rcptTo`, and `mailFromUtf8` becomes a wrapper over `mail`. The demo
CLI exposes the four as --ret, --envid, --notify and --orcpt.
Both `ENVID` and the `ORCPT` address are xtext (RFC 3461 §4), so that
codec is in `protocol`: `writeXtext` escapes everything that is not an
xchar, which means the encoded form can never end the command line and a
value from untrusted input is safe by construction rather than by
checking. Decoding is strict in the other direction -- a byte the encoder
was obliged to escape is rejected rather than passed through -- so one
sequence of bytes has one spelling. The length limits are on the encoded
form, which is why they are checked there: 100 characters for ENVID, 500
for the whole ORCPT parameter.
Verified against real implementations in the interop test, which now sends
the DSN parameters to postfix and to exim (told to advertise DSN, which it
does not do by default) and round-trips them through zsmtp's own server,
where the ENVID comes back with its space intact and the ORCPT with the
'+' that had to be encoded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Two things the client got wrong, both of which put something on the wire
that the caller did not ask for.
`mailFrom`, `rcptTo`, `mailFromUtf8` and `hello` interpolated their
argument straight into the command line, so an address carrying CR or LF
ended the line early and everything after it was read by the server as
further SMTP commands -- `bob@example.net>\r\nRCPT TO:<victim@example.net`
delivered to two people. Those four now check the argument first and
return `error.UnsafeArgument` rather than send it, as does AUTH PLAIN,
where the byte that matters is NUL: it separates the three fields, so one
hidden inside a field moves the boundary and authenticates as somebody
else. The check is `protocol.isSafeArgument`, and it is deliberately
framing only -- CR, LF and NUL and nothing else -- because the RFC 5321
path grammar rejects addresses that real deployments carry every day, and
a client that refused them would be the wrong tool.
`authenticate` preferred AUTH PLAIN unconditionally, which sent the
password in the clear whenever the transport was. The client cannot tell
on its own -- it is handed a reader and a writer and has no idea what is
under them -- so it now assumes the worst and takes the answer from the
caller: `setTransport` records it for a STARTTLS upgrade, and a session
that speaks TLS from the first byte sets `security` itself. PLAIN and
LOGIN return `error.InsecureTransport` on a plaintext transport, and
`authenticate` inverts its preference there to CRAM-MD5, the one
mechanism of the three that never puts the password on the wire.
`allow_cleartext_auth` is the way past that for a connection protected by
something this library cannot see -- a unix socket, an SSH tunnel, a
loopback test -- and `zsmtp send --allow-cleartext-auth` exposes it.
The interop test grew the case that matters: the same delivery to exim
fails without the opt-in and succeeds over STARTTLS without one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
The repository is now on Radicle as rad:z3ZKHgoDKEue8FT7sV6fHZdtjxRx1, and
the README's "Where this lives" section says so. The ID is the whole of it:
a Radicle repository has no other name, so a README that leaves the RID out
has left out the one thing a reader needs in order to seed or clone it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
The repository now has a mirror at https://tangled.org/jcollie.dev/zsmtp,
pushed to the `tangled` remote on knot.jcollie.dev.
A "Where this lives" section in the README names both homes, gives the
https clone URL (which works without an account on the server), and links
the published API documentation, which nothing pointed at before.
The rest is the host move: `origin`, the package's `meta.homepage`, and
the docs publish job, which now publishes to jeff.jcollie.page via the
jcollie.page server. Both names resolve, so the job will not fail at the
publish step.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
The package moves to the repo root and adopts the vaultz style:
finalAttrs, a fileset-narrowed src (build.zig, build.zig.zon, src - so
flake or docs edits no longer invalidate the derivation), the nixpkgs
zig setup hook for the standard phases (yielding a portable
-Dcpu=baseline --release=safe binary instead of hand-rolled build and
check phases), zigBuildFlags/zigCheckFlags carrying --system with the
zon2nix-generated dependencies, and full meta with longDescription,
homepage, mainProgram, and platforms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
build.zig.zon.nix is generated from build.zig.zon by
github.com/jcollie/zon2nix (regenerate with
`nix run github:jcollie/zon2nix#zon2nix -- --nix=build.zig.zon.nix
--16 build.zig.zon` whenever dependencies change). It evaluates to the
package layout `zig build --system` expects, replacing the hand-written
nix/zig-deps.nix: package.nix now builds and tests with --system, the
flake exposes it as packages.zig-deps, and the docs workflow uses it
directly instead of materializing zig-pkg. The lazy exim and isemail
dependencies are included, so those test features work offline too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The first workflow run failed because zig build docs tried to fetch
the tls.zig dependency from GitHub on the runner and its TLS setup
failed (TlsInitializationFailed). The zig-pkg directory derivation is
now factored out of package.nix into nix/zig-deps.nix, exposed as
packages.zig-pkg, and the workflow copies it into the checkout before
building so zig never touches the network. Verified from a clean clone
with an empty Zig cache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
zig build docs emits the autodoc bundle for the zsmtp module into
zig-out/docs, and the workflow publishes it to
https://jeff.ocj.page/zsmtp/ with git-pages-cli (now in the devshell)
on every push to main, mirroring the notmuch.zig setup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Project convention: io comes first in any function taking a std.Io
(after the receiver, for methods). The one violation was Tls.init,
which took (t, gpa, io, ...) and is now (t, io, gpa, ...); callers and
documentation examples updated to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The RFC 5321 address round-trip test now embeds tests.xml and
tests-original.xml straight from Dominic Sayers' canonical isemail
repository (pinned, .lazy = true), gated by -Disemail-corpus so plain
builds and the network-less Nix sandbox never fetch it. The test parses
the XML at test time (element extraction plus full entity unescaping),
filters both files to the RFC 5321-valid categories, round-trips all
125 addresses through Command.parse, and asserts a minimum count so
the extraction cannot silently rot; without the option it skips.
The canonical repo supersedes the copy bundled with the
email-addresses npm package: same current tests.xml plus
tests-original.xml with 92 additional RFC 5321-valid cases (richer
quoted-string escapes and address literals). Since no corpus text is
distributed in this repository anymore, the BSD-3-Clause snippet,
license file, and README note are removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Reviewing postfix's address corpora (src/global/mail_addr_crunch.in)
surfaced a parser bug: parsePathArgs located the closing angle bracket
with a plain scan, truncating legal RFC 5321 addresses whose quoted
local-part contains '>' (e.g. <"a>b"@example.com>). The scan is now
quote-aware with backslash-escape handling, and an unterminated quote
is a syntax error.
Postfix has no exim-style protocol dialogue tests to adopt: its .in/
.ref corpora exercise the policy engine with pre-tokenized inputs, and
the smtpstone tools are load generators that are neither shipped by
nixpkgs nor standalone-buildable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The server advertises SMTPUTF8 and accepts the valueless SMTPUTF8 MAIL
parameter (a value gets 501). Non-ASCII envelope addresses on MAIL and
RCPT are rejected with 553 5.6.7 (RFC 6533) unless the transaction
requested SMTPUTF8, and must be well-formed UTF-8 even then. The flag
rides the transaction state and reaches handlers via Envelope.smtputf8.
The client gains mailFromUtf8 and the CLI gains send --smtputf8, which
errors cleanly when the server does not advertise the extension.
The torture script and the byte-for-byte gauntlet gain the SMTPUTF8
cases, and the VM interop suite delivers with a UTF-8 sender to real
Postfix (ICU-enabled in nixpkgs); 19 subtests pass. Exim interop is
skipped since nixpkgs exim is built without SUPPORT_I18N.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Command.bdat parses "BDAT <size> [LAST]" strictly. The server
advertises CHUNKING and receives chunked messages on both handler
paths: the collecting path reassembles raw chunks (no dot-stuffing,
max_message_size enforced with 552), and BdatReader adapts the chunk
sequence into the streaming handler's reader, replying 250 between
chunks and handling RSET/QUIT/protocol violations mid-stream, with
unread remainder drained. Framing is strictly length-based: a BDAT
without a transaction still consumes its payload octets, and chunk
payloads that look like commands are data.
The client gains Extensions.chunking, bdat(chunk, last) (verbatim
transmission, one flush per chunk), and sendMessageChunked; the CLI
gains send --chunking, streaming stdin as BDAT chunks.
The exim-client torture script and the byte-for-byte gauntlet unit
test gain a BDAT section, and the VM interop suite delivers via
CHUNKING to real Postfix and Exim (18 subtests passing). BINARYMIME
remains deliberately unimplemented (BODY=BINARYMIME is rejected).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The protocol gauntlet test in Server.zig is wrapped in SPDX snippet
tags declaring GPL-2.0-or-later with copyright to The Exim Maintainers
and the University of Cambridge (per the exim source headers), since
its command dialogue and message lines are adapted from exim's test
suite; the reply expectations are ours.
test/protocol-torture.script had been annotated MIT by mistake - its
REUSE.toml annotation is corrected to GPL-2.0-or-later with the same
holders. LICENSES/GPL-2.0-or-later.txt is added and the README notes
that this test-only material carries a different license than the
MIT library.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The exim source (pinned commit) is declared with .lazy = true and only
fetched when requested: `zig build -Dexim-client` compiles exim's
scriptable SMTP test client (test/src/client.c) to
zig-out/bin/exim-client. The option guard keeps plain builds, tests,
and the network-less Nix sandbox build from ever fetching it.
test/protocol-torture.script (with a REUSE.toml annotation) carries
the 28-reply dialogue distilled from exim's test suite; running
exim-client with it against zsmtp serve passes all expectations, and
the same dialogue is asserted byte-for-byte by the protocol gauntlet
unit test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
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
Add identifier-named doctests for every remaining public function and
substantive public type: crlf, Reply.read/lines and the four reply
class predicates, Command.parse, PathArgs.paramIterator,
ParamIterator.init/next, Extensions and Extensions.Auth.any,
DataWriter.end, and Server's init, Options, Decision, Envelope, and
Handler. Nested declarations get their tests inside the container so
autodoc attaches them to the member.
Left without doctests, deliberately: Tls.zig and Server.TlsOptions
(need a live TLS peer; examples stay in doc comments), plain error
sets, and pure data shapes already demonstrated by their containers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Server.Options.starttls becomes tls: ?TlsOptions with a mode field:
.starttls keeps the RFC 3207 behavior (advertise, 220, upgrade, state
reset) and .implicit performs the tls.zig server handshake before the
greeting (SMTPS, port 465 style). Both paths share one upgradeToTls
helper; in implicit mode STARTTLS is never advertised and the command
gets 502. Breaking rename for Server.Options at version 0.0.0.
The serve CLI grows --implicit-tls (requires --tls-cert/--tls-key) and
its flag parser now supports valueless flags.
Verified locally with openssl s_client (greeting arrives inside the
TLS channel) and our own --tls client, plus a STARTTLS regression
check. The VM interop test adds an implicit-TLS zsmtp server and a
swaks --tlsc subtest against it; all 16 subtests pass.
The Status list is complete: TLS in both modes on both sides, AUTH,
streaming bodies, and MAIL parameter validation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
protocol.ParamIterator iterates the KEY=value parameters of MAIL and
RCPT commands (RFC 5321 4.1.2), reachable via PathArgs.paramIterator().
The server validates MAIL parameters before the mailFrom callback:
SIZE= (RFC 1870) over max_message_size is rejected early with 552 and
malformed values with 501; BODY=7BIT/8BITMIME (RFC 6152) are accepted
case-insensitively and other values get 555, as do unrecognized
keywords. A rejected parameter leaves the transaction unstarted. RCPT
parameters are all rejected with 555 since no RCPT extensions are
advertised.
Envelope gains declared_size and body (defaulted, so existing handlers
are unaffected), populated from accepted MAIL parameters and reset
with the rest of the transaction state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Six std.testing.fuzz targets, all also running once as part of the
normal test suite:
- Command.parse: arbitrary bytes parse or error cleanly, and payload
slices always lie within the input line
- Reply.read: arbitrary reply streams; successful codes stay in range
- client vs arbitrary server replies: full greet/hello/auth/sendMail
sequence must fail cleanly, never crash
- DataWriter differential: streaming stuffing must be byte-identical
to writeStuffed under fuzzer-chosen chunk boundaries
- server session vs arbitrary client input (auth enabled, discarding
writer)
- collecting vs streaming DATA differential: both handler paths must
yield identical unstuffed content
Verified with ~5 minutes of coverage-guided fuzzing (corpus saturated
at 27 entries, no failures). Running the fuzzer on stock Zig 0.16.0
requires a patched std (its fuzz-mode test runner does not compile and
the coverage server panics on a binary with no fuzz tests; both fixed
on master) - documented in the README.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Client: data() starts the DATA phase and returns a DataWriter, an
Io.Writer whose dot-stuffing and CRLF-normalization state machine
persists across writes, so chunks may split lines, CRLF pairs, and
leading dots at any byte boundary with no line-length limits.
sendMessageReader() streams from any Io.Reader; sendMessage() is now a
thin wrapper over data(), sharing one stuffing implementation.
Server: the handler vtable gains messageReader as a streaming
alternative to message (exactly one must be set). The callback gets an
Io.Reader backed by a zero-copy line adapter that removes dot-stuffing;
anything left unread is drained through the terminator so early returns
cannot desynchronize the session. max_message_size is not enforced in
streaming mode.
The CLI send command streams stdin instead of buffering it; verified
with a 5 MB, 100k-dotted-line message round-tripping byte-exact, plus
the full VM interop suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Rename existing demonstrative tests to identifier-named doctests so
autodoc attaches them to their declarations (readLine, Reply, Command,
writeStuffed, sendMail, hello, starttls, authenticate, authPlain,
authLogin, authCramMd5, run), and add new small scripted doctests for
the client functions that had none: init, greet, setTransport,
mailFrom, rcptTo, sendMessage, rset, noop, quit.
Server.run's doctest now constructs the session inline instead of
going through the runScript test helper, so the example shows the
actual API. Edge-case tests keep descriptive string names. Tls.zig
and Server.StartTls have no runnable doctests since a handshake needs
a live peer; their usage examples stay in doc comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Client (RFC 4954/4616/2195):
- Extensions.auth parses the advertised mechanism list (including the
legacy AUTH= form) into plain/login/cram_md5 flags
- authLogin and authCramMd5 join authPlain; CRAM-MD5 is verified against
the RFC 2195 example vector
- authenticate() picks PLAIN, then LOGIN, then CRAM-MD5; a 535 surfaces
as error.AuthenticationFailed with the reply in last_reply
Server:
- an optional authenticate handler callback enables AUTH PLAIN and
LOGIN: initial responses, 334 challenges, "*" cancellation, bad
base64 (501), unknown mechanism (504), re-auth/mid-transaction (503)
- Options.require_auth rejects MAIL with 530 until authenticated;
STARTTLS resets auth state
CLI: send grew --user/--password/--auth-method, serve grew
--auth user:pass (implies require_auth).
VM interop additions: zsmtp client authenticates to Exim via PLAIN and
LOGIN (its plaintext authenticator; CRAM-MD5 is not compiled into
nixpkgs exim) with a wrong-password rejection, and swaks authenticates
to the auth-required zsmtp server via PLAIN and LOGIN with
wrong-password and unauthenticated rejections.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The VM test now also runs the zsmtp client against Exim (ports
2625/2626) over plaintext, STARTTLS, and implicit TLS, with taint-safe
appendfile delivery checked in /var/spool/exim-mail.
Exim exposed a standard-library TLS bug: std.crypto.tls.Client only
advances its record-decryption state upon receiving the TLS 1.3
middlebox-compatibility ChangeCipherSpec record, which is optional and
disabled by Exim's OpenSSL setup, so the handshake died with
TlsUnexpectedMessage. The client-side Tls wrapper now uses ianic/tls.zig
(already used server-side) instead: init is in-place (the connection
holds interior pointers), and the flush-through workaround is gone since
tls.zig flushes each record to the stream.
A sendmail interop test was built and passing but removed again since
nixpkgs does not package the sendmail MTA.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
nix/package.nix builds zsmtp with zig_0_16 and runs the unit tests; the
tls.zig dependency is provided offline by materializing it into the
project-local zig-pkg/<hash>/ directory with only the files from the
dependency's paths list, so Zig's content hash matches.
nix/interop-test.nix exercises zsmtp against third-party
implementations in one VM:
- zsmtp client -> Postfix: plaintext (25), STARTTLS (25), implicit TLS
(465, submissions wrapper mode), verified via alice's maildir spool
- swaks -> zsmtp server: plaintext and STARTTLS (snakeoil EC cert),
verified via the server's journal
Exposed as packages.zsmtp/default and checks.{zsmtp,interop}. Postfix
on NixOS delivers maildir-style (mail_spool_directory has a trailing
slash), so assertions grep the directory recursively, with 60s
timeouts to fail fast.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Client (std.crypto.tls):
- Tls.zig wraps std.crypto.tls.Client for implicit TLS and STARTTLS,
verifying against the system trust store by default (caller-managed
bundle and insecure modes available)
- Client.starttls() does the RFC 3207 exchange; setTransport() swaps in
the encrypted reader/writer
- Tls.writer() is a flush-through wrapper: std's TLS writer encrypts on
flush but leaves records in the stream writer's buffer, which deadlocks
request/reply protocols like SMTP
Server (ianic/tls.zig, pinned to zig-0.16.x head):
- Options.starttls advertises and accepts STARTTLS (TLS 1.3 only): 220,
server handshake over the raw stream, transport swap, RFC 3207 state
reset; 503 on a second STARTTLS, close_notify on QUIT
- tls dependency re-exported as zsmtp.tls for CertKeyPair loading
CLI: send grew --tls/--starttls/--insecure, serve grew
--tls-cert/--tls-key. Verified end to end over real sockets: zsmtp
client <-> zsmtp server STARTTLS, openssl s_client -starttls smtp
against the server, and openssl s_server against the client.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Transport-agnostic SMTP (RFC 5321) over std.Io.Reader/Writer pairs:
- protocol.zig: reply parsing, command parsing, dot-stuffing
- Client.zig: EHLO/HELO, extensions, AUTH PLAIN, mail transactions
- Server.zig: single-connection session with handler vtable, command
sequencing, recipient/message-size limits
- main.zig: demo CLI (send via stdin, debug serve on loopback)
MIT licensed with SPDX headers; REUSE 3.3 compliant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx