Commits
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
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