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