# 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. ## 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 ``` ### 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(gpa, io, &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(gpa, io, &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. 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", .starttls = .{ .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). ## 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 # 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 STARTTLS (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. Not yet implemented: implicit TLS on the server side, and ESMTP parameter handling (SIZE=, BODY=) on the server side. ## 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. 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 ```