An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-smtp / README.md
8.2 kB 208 lines
1<!-- 2SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 3SPDX-License-Identifier: MIT 4--> 5 6# zsmtp 7 8An SMTP client and server library for Zig (RFC 5321). 9 10Both the client and the server run over plain `std.Io.Reader`/`std.Io.Writer` 11pairs, so they are transport-agnostic: wrap a TCP stream for real use, or 12fixed in-memory buffers in tests. Requires Zig 0.16. 13 14## Client 15 16```zig 17const zsmtp = @import("zsmtp"); 18 19var reply_buf: [1024]u8 = undefined; 20var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 21 22_ = try client.greet(); // read the 220 greeting 23_ = try client.hello("my-host.example.com"); // EHLO (HELO fallback), returns extensions 24try client.sendMail("me@example.com", &.{"you@example.net"}, message); 25try client.quit(); 26``` 27 28Line endings in the message are normalized to CRLF and leading dots are 29stuffed automatically. On `error.UnexpectedReply`, `client.last_reply` holds 30the server's actual code and text. `mailFrom`/`rcptTo`/`sendMessage` are also 31available individually. 32 33Message bodies can also be streamed instead of passed as a slice — from any 34reader via `sendMessageReader(&reader)`, or push-style via `data()`, which 35returns a writer that dot-stuffs and normalizes line endings as content 36flows through it: 37 38```zig 39var data_writer = try client.data(); 40try data_writer.interface.print("Subject: report {d}\r\n\r\n", .{id}); 41// ... stream as much as needed ... 42try data_writer.end(); // terminates the message, reads the verdict 43``` 44 45### Authentication 46 47`hello` reports the server's advertised mechanisms in `extensions.auth`; 48`authenticate` picks the best one (PLAIN, then LOGIN, then CRAM-MD5), or use 49`authPlain`/`authLogin`/`authCramMd5` directly. PLAIN and LOGIN send 50credentials unprotected, so use TLS on real networks. A 535 rejection 51surfaces as `error.AuthenticationFailed` with the reply in `last_reply`. 52 53```zig 54const extensions = try client.hello("my-host.example.com"); 55try client.authenticate(extensions, "user", "password"); 56``` 57 58### TLS 59 60`zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and 61verifies against the system trust store by default (a caller-managed CA 62bundle and an insecure mode are also available). The stream reader/writer 63handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and 64`init` must run at the value's final address (the connection holds interior 65pointers). The standard library's TLS client is deliberately not used: it 66requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec 67record, which servers like Exim disable. 68 69Implicit TLS (port 465) — handshake first, then speak SMTP: 70 71```zig 72var tls: zsmtp.Tls = undefined; 73try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 74 .host = "smtp.example.com", 75}); 76defer tls.deinit(gpa); 77var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); 78// ... greet, hello, sendMail ... 79try client.quit(); 80try tls.end(); // close_notify, before closing the socket 81``` 82 83STARTTLS (port 587) — upgrade mid-session, then EHLO again: 84 85```zig 86_ = try client.greet(); 87_ = try client.hello("my-host.example.com"); // check .starttls in the result 88try client.starttls(); 89var tls: zsmtp.Tls = undefined; 90try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 91 .host = "smtp.example.com", 92}); 93client.setTransport(tls.reader(), tls.writer()); 94_ = try client.hello("my-host.example.com"); // server state was reset 95``` 96 97## Server 98 99```zig 100var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 101 .context = &my_state, 102 .vtable = &.{ 103 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN 104 .rcptTo = onRcptTo, // optional; accept/reject each recipient 105 .message = onMessage, // required; receives envelope + message data 106 }, 107}, .{ .hostname = "mx.example.com" }); 108try session.run(gpa); 109``` 110 111With an `authenticate` callback the session advertises and accepts AUTH 112PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL 113with 530 until the client has authenticated. 114 115Instead of `message` (which collects the whole body in memory, bounded by 116`max_message_size`), a handler can set `messageReader` to stream it: the 117callback receives an `Io.Reader` yielding the unstuffed message content, 118and anything left unread is drained by the session. 119 120`run` serves one connection until QUIT or disconnect, enforcing command 121sequencing, recipient and message-size limits, and un-stuffing message data. 122MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552 123when it exceeds `max_message_size`, `BODY=7BIT`/`BODY=8BITMIME` (RFC 6152) 124are accepted, and unrecognized parameters get 555; the declared size and 125body type reach the handler via `Envelope`. Listening, accepting, and 126concurrency are up to the caller. 127 128To advertise and accept STARTTLS (TLS 1.3, via 129[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key 130pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` / 131`zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them: 132 133```zig 134var auth: zsmtp.tls.config.CertKeyPair = 135 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem"); 136defer auth.deinit(gpa); 137 138var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 139 .hostname = "mx.example.com", 140 .starttls = .{ .io = io, .auth = &auth }, 141}); 142try session.run(gpa); 143``` 144 145On STARTTLS the session answers 220, performs the server handshake, swaps 146its transport to the encrypted connection, and resets state per RFC 3207 (the 147client must EHLO again). 148 149## Demo CLI 150 151```sh 152zig build 153 154# Debug server that prints received messages to stdout 155# (with a cert/key pair it advertises and accepts STARTTLS): 156./zig-out/bin/zsmtp serve 2525 157./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525 158 159# Send a message read from stdin: 160printf 'Subject: hi\r\n\r\nhello\r\n' | \ 161 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net 162 163# Same, over implicit TLS or STARTTLS (--insecure skips cert verification): 164zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 165zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 166``` 167 168## Status 169 170TLS is supported on both sides via 171[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 172TLS and STARTTLS via `zsmtp.Tls`, and the server accepts STARTTLS (TLS 1.3 173only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the client and PLAIN and 174LOGIN on the server. Message bodies can be streamed on both sides, and the 175server validates MAIL parameters (SIZE=, BODY=). Not yet implemented: 176implicit TLS on the server side. 177 178## Tests 179 180```sh 181zig build test 182zig build test --fuzz # run the fuzz tests under the fuzzer (endless) 183``` 184 185The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`), 186whole-session robustness against arbitrary bytes on both the client and 187server side, and two differential properties: the streaming `DataWriter` 188must produce byte-identical output to the slice-based `writeStuffed` under 189fuzzer-chosen chunk boundaries, and the collecting and streaming server 190DATA paths must yield identical message content. 191 192Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled 193test runner fails to compile in fuzz mode, and the coverage server panics 194on a test binary with no fuzz tests); both are fixed on Zig master. Until 195then, fuzzing needs a patched copy of the standard library via 196`zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests 197themselves also run once per invocation as part of the normal 198`zig build test` suite. 199 200Interoperability against third-party implementations is covered by a NixOS 201VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix 202and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks 203delivers to the zsmtp server over plaintext and STARTTLS. 204 205```sh 206nix build .#zsmtp # build the package 207nix build .#checks.x86_64-linux.interop # run the VM interop test 208```