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