# zsmtp An SMTP client and server library for Zig (RFC 5321). Both the client and the server run over plain `std.Io.Reader`/`std.Io.Writer` pairs, so they are transport-agnostic: wrap a TCP stream for real use, or fixed in-memory buffers in tests. Requires Zig 0.16. ## Where this lives The canonical repository is on Forgejo, with a mirror on Tangled: - - ```sh git clone https://git.jcollie.dev/jeff/zsmtp.git ``` The API documentation is generated from the doc comments and published at ; `zig build docs` builds it locally and `zig build docs-serve` serves it for reading. ## Client ```zig const zsmtp = @import("zsmtp"); var reply_buf: [1024]u8 = undefined; var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); _ = try client.greet(); // read the 220 greeting _ = try client.hello("my-host.example.com"); // EHLO (HELO fallback), returns extensions try client.sendMail("me@example.com", &.{"you@example.net"}, message); try client.quit(); ``` Line endings in the message are normalized to CRLF and leading dots are stuffed automatically. On `error.UnexpectedReply`, `client.last_reply` holds the server's actual code and text. `mailFrom`/`rcptTo`/`sendMessage` are also available individually. Message bodies can also be streamed instead of passed as a slice — from any reader via `sendMessageReader(&reader)`, or push-style via `data()`, which returns a writer that dot-stuffs and normalizes line endings as content flows through it: ```zig var data_writer = try client.data(); try data_writer.interface.print("Subject: report {d}\r\n\r\n", .{id}); // ... stream as much as needed ... try data_writer.end(); // terminates the message, reads the verdict ``` When the server advertises CHUNKING (`extensions.chunking`), `bdat` and `sendMessageChunked` transmit the message with length-framed BDAT chunks instead of DATA — verbatim, with no dot-stuffing, so content must already use CRLF line endings. ### Authentication `hello` reports the server's advertised mechanisms in `extensions.auth`; `authenticate` picks the best one (PLAIN, then LOGIN, then CRAM-MD5), or use `authPlain`/`authLogin`/`authCramMd5` directly. PLAIN and LOGIN send credentials unprotected, so use TLS on real networks. A 535 rejection surfaces as `error.AuthenticationFailed` with the reply in `last_reply`. ```zig const extensions = try client.hello("my-host.example.com"); try client.authenticate(extensions, "user", "password"); ``` ### TLS `zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and verifies against the system trust store by default (a caller-managed CA bundle and an insecure mode are also available). The stream reader/writer handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and `init` must run at the value's final address (the connection holds interior pointers). The standard library's TLS client is deliberately not used: it requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec record, which servers like Exim disable. Implicit TLS (port 465) — handshake first, then speak SMTP: ```zig var tls: zsmtp.Tls = undefined; try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{ .host = "smtp.example.com", }); defer tls.deinit(gpa); var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); // ... greet, hello, sendMail ... try client.quit(); try tls.end(); // close_notify, before closing the socket ``` STARTTLS (port 587) — upgrade mid-session, then EHLO again: ```zig _ = try client.greet(); _ = try client.hello("my-host.example.com"); // check .starttls in the result try client.starttls(); var tls: zsmtp.Tls = undefined; try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{ .host = "smtp.example.com", }); client.setTransport(tls.reader(), tls.writer()); _ = try client.hello("my-host.example.com"); // server state was reset ``` ## Server ```zig var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ .context = &my_state, .vtable = &.{ .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN .rcptTo = onRcptTo, // optional; accept/reject each recipient .message = onMessage, // required; receives envelope + message data }, }, .{ .hostname = "mx.example.com" }); try session.run(gpa); ``` With an `authenticate` callback the session advertises and accepts AUTH PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL with 530 until the client has authenticated. Instead of `message` (which collects the whole body in memory, bounded by `max_message_size`), a handler can set `messageReader` to stream it: the callback receives an `Io.Reader` yielding the unstuffed message content, and anything left unread is drained by the session. `run` serves one connection until QUIT or disconnect, enforcing command sequencing, recipient and message-size limits, and un-stuffing message data. Messages may also arrive via BDAT chunks (CHUNKING is advertised); both the collecting and streaming handler paths receive the reassembled content. MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552 when it exceeds `max_message_size`, `BODY=7BIT`/`BODY=8BITMIME` (RFC 6152) are accepted, and unrecognized parameters get 555; the declared size and body type reach the handler via `Envelope`. Listening, accepting, and concurrency are up to the caller. To advertise and accept STARTTLS (TLS 1.3, via [ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` / `zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them: ```zig var auth: zsmtp.tls.config.CertKeyPair = try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem"); defer auth.deinit(gpa); var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ .hostname = "mx.example.com", .tls = .{ .io = io, .auth = &auth }, }); try session.run(gpa); ``` On STARTTLS the session answers 220, performs the server handshake, swaps its transport to the encrypted connection, and resets state per RFC 3207 (the client must EHLO again). With `.mode = .implicit` the handshake instead runs before the greeting (SMTPS, port 465 style): ```zig var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ .hostname = "mx.example.com", .tls = .{ .io = io, .auth = &auth, .mode = .implicit }, }); ``` ## Demo CLI ```sh zig build # Debug server that prints received messages to stdout # (with a cert/key pair it advertises and accepts STARTTLS): ./zig-out/bin/zsmtp serve 2525 ./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525 ./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem --implicit-tls 2465 # Send a message read from stdin: printf 'Subject: hi\r\n\r\nhello\r\n' | \ ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net # Same, over implicit TLS or STARTTLS (--insecure skips cert verification): zsmtp send --tls smtp.example.com 465 me@example.com you@example.net zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net ``` ## Status TLS is supported on both sides via [ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit TLS and STARTTLS via `zsmtp.Tls`, and the server accepts both STARTTLS and implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the client and PLAIN and LOGIN on the server. Message bodies can be streamed on both sides, and the server validates MAIL parameters (SIZE=, BODY=). ## Standards - [RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321) — Simple Mail Transfer Protocol: the command/reply protocol, multiline replies, dot-stuffing, reply classes, and ESMTP parameter syntax (client and server). - [RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870) — SIZE: advertised and enforced by the server (oversize declarations are rejected with 552 before DATA); parsed from EHLO by the client. - [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152) — 8BITMIME: advertised by the server and `BODY=` validated; parsed by the client. - [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030) — CHUNKING (BDAT): client and server, with length-based framing and no dot-stuffing; the companion BINARYMIME extension is not implemented (`BODY=BINARYMIME` is rejected). - [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING: advertised by the server, whose strictly sequential command loop handles pipelined clients naturally; parsed by the client. - [RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207) — STARTTLS: client and server, including the mandatory post-handshake state reset. - [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314) — implicit TLS (SMTPS): client (`Tls` before any SMTP traffic) and server (`.mode = .implicit`). - [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client and server, including initial responses and `*` cancellation. - [RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616) — the PLAIN SASL mechanism (client and server). - [RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195) — CRAM-MD5 (client only; the server would need plaintext-equivalent credentials). - [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) — the de-facto AUTH LOGIN mechanism (client and server). - [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) / [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced status codes: carried in every server reply and advertised via ENHANCEDSTATUSCODES; detected by the client. - [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) — SMTPUTF8: client (`mailFromUtf8`) and server (advertised; non-ASCII addresses require the parameter and must be valid UTF-8, rejected with 553 5.6.7 per [RFC 6533](https://datatracker.ietf.org/doc/html/rfc6533) otherwise; the flag reaches handlers via `Envelope.smtputf8`). TLS itself (TLS 1.3, [RFC 8446](https://datatracker.ietf.org/doc/html/rfc8446)) is provided by [ianic/tls.zig](https://github.com/ianic/tls.zig). ## Tests ```sh zig build test zig build test --fuzz # run the fuzz tests under the fuzzer (endless) ``` The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`), whole-session robustness against arbitrary bytes on both the client and server side, and two differential properties: the streaming `DataWriter` must produce byte-identical output to the slice-based `writeStuffed` under fuzzer-chosen chunk boundaries, and the collecting and streaming server DATA paths must yield identical message content. ### Protocol torture testing with exim's test client Exim's scriptable SMTP test client (`test/src/client.c` in the exim source) sends raw protocol lines and asserts reply prefixes. The exim source is declared as a *lazy* Zig dependency, fetched only on demand: ```sh zig build -Dexim-client # fetches exim, installs zig-out/bin/exim-client ./zig-out/bin/zsmtp serve 2525 & ./zig-out/bin/exim-client 127.0.0.1 2525 < test/protocol-torture.script ``` ### Address corpus testing with the is_email suite Dominic Sayers' [is_email](https://github.com/dominicsayers/isemail) test suite (BSD-3-Clause) is declared as a *lazy* Zig dependency; nothing from it is copied into this repository. On demand, the corpus test embeds its XML test files, extracts the 125 addresses valid at the RFC 5321 layer, and checks that each passes through the path parser byte-for-byte: ```sh zig build test -Disemail-corpus # fetches the suite and runs the corpus test ``` Without the option the corpus test is skipped. `test/protocol-torture.script` is a 28-reply dialogue distilled from exim's own test suite (syntax errors, sequencing violations, parameter validation, dot-stuffing); the same dialogue is asserted byte-for-byte as a unit test in `Server.zig`. The library is MIT-licensed; the small amount of test-only material adapted from exim's test suite (the torture script and the gauntlet unit test's dialogue) is GPL-2.0-or-later, marked with SPDX snippet tags and REUSE.toml annotations. Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled test runner fails to compile in fuzz mode, and the coverage server panics on a test binary with no fuzz tests); both are fixed on Zig master. Until then, fuzzing needs a patched copy of the standard library via `zig build --zig-lib-dir test --fuzz`. The fuzz tests themselves also run once per invocation as part of the normal `zig build test` suite. Interoperability against third-party implementations is covered by a NixOS VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks delivers to the zsmtp server over plaintext and STARTTLS. ```sh nix build .#zsmtp # build the package nix build .#checks.x86_64-linux.interop # run the VM interop test ```