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.5 kB 216 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 .tls = .{ .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). With `.mode = .implicit` the handshake instead runs 148before the greeting (SMTPS, port 465 style): 149 150```zig 151var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 152 .hostname = "mx.example.com", 153 .tls = .{ .io = io, .auth = &auth, .mode = .implicit }, 154}); 155``` 156 157## Demo CLI 158 159```sh 160zig build 161 162# Debug server that prints received messages to stdout 163# (with a cert/key pair it advertises and accepts STARTTLS): 164./zig-out/bin/zsmtp serve 2525 165./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525 166./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem --implicit-tls 2465 167 168# Send a message read from stdin: 169printf 'Subject: hi\r\n\r\nhello\r\n' | \ 170 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net 171 172# Same, over implicit TLS or STARTTLS (--insecure skips cert verification): 173zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 174zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 175``` 176 177## Status 178 179TLS is supported on both sides via 180[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 181TLS and STARTTLS via `zsmtp.Tls`, and the server accepts both STARTTLS and 182implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the 183client and PLAIN and LOGIN on the server. Message bodies can be streamed on 184both sides, and the server validates MAIL parameters (SIZE=, BODY=). 185 186## Tests 187 188```sh 189zig build test 190zig build test --fuzz # run the fuzz tests under the fuzzer (endless) 191``` 192 193The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`), 194whole-session robustness against arbitrary bytes on both the client and 195server side, and two differential properties: the streaming `DataWriter` 196must produce byte-identical output to the slice-based `writeStuffed` under 197fuzzer-chosen chunk boundaries, and the collecting and streaming server 198DATA paths must yield identical message content. 199 200Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled 201test runner fails to compile in fuzz mode, and the coverage server panics 202on a test binary with no fuzz tests); both are fixed on Zig master. Until 203then, fuzzing needs a patched copy of the standard library via 204`zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests 205themselves also run once per invocation as part of the normal 206`zig build test` suite. 207 208Interoperability against third-party implementations is covered by a NixOS 209VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix 210and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks 211delivers to the zsmtp server over plaintext and STARTTLS. 212 213```sh 214nix build .#zsmtp # build the package 215nix build .#checks.x86_64-linux.interop # run the VM interop test 216```