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.

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 45When the server advertises CHUNKING (`extensions.chunking`), `bdat` and 46`sendMessageChunked` transmit the message with length-framed BDAT chunks 47instead of DATA — verbatim, with no dot-stuffing, so content must already 48use CRLF line endings. 49 50### Authentication 51 52`hello` reports the server's advertised mechanisms in `extensions.auth`; 53`authenticate` picks the best one (PLAIN, then LOGIN, then CRAM-MD5), or use 54`authPlain`/`authLogin`/`authCramMd5` directly. PLAIN and LOGIN send 55credentials unprotected, so use TLS on real networks. A 535 rejection 56surfaces as `error.AuthenticationFailed` with the reply in `last_reply`. 57 58```zig 59const extensions = try client.hello("my-host.example.com"); 60try client.authenticate(extensions, "user", "password"); 61``` 62 63### TLS 64 65`zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and 66verifies against the system trust store by default (a caller-managed CA 67bundle and an insecure mode are also available). The stream reader/writer 68handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and 69`init` must run at the value's final address (the connection holds interior 70pointers). The standard library's TLS client is deliberately not used: it 71requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec 72record, which servers like Exim disable. 73 74Implicit TLS (port 465) — handshake first, then speak SMTP: 75 76```zig 77var tls: zsmtp.Tls = undefined; 78try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 79 .host = "smtp.example.com", 80}); 81defer tls.deinit(gpa); 82var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); 83// ... greet, hello, sendMail ... 84try client.quit(); 85try tls.end(); // close_notify, before closing the socket 86``` 87 88STARTTLS (port 587) — upgrade mid-session, then EHLO again: 89 90```zig 91_ = try client.greet(); 92_ = try client.hello("my-host.example.com"); // check .starttls in the result 93try client.starttls(); 94var tls: zsmtp.Tls = undefined; 95try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 96 .host = "smtp.example.com", 97}); 98client.setTransport(tls.reader(), tls.writer()); 99_ = try client.hello("my-host.example.com"); // server state was reset 100``` 101 102## Server 103 104```zig 105var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 106 .context = &my_state, 107 .vtable = &.{ 108 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN 109 .rcptTo = onRcptTo, // optional; accept/reject each recipient 110 .message = onMessage, // required; receives envelope + message data 111 }, 112}, .{ .hostname = "mx.example.com" }); 113try session.run(gpa); 114``` 115 116With an `authenticate` callback the session advertises and accepts AUTH 117PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL 118with 530 until the client has authenticated. 119 120Instead of `message` (which collects the whole body in memory, bounded by 121`max_message_size`), a handler can set `messageReader` to stream it: the 122callback receives an `Io.Reader` yielding the unstuffed message content, 123and anything left unread is drained by the session. 124 125`run` serves one connection until QUIT or disconnect, enforcing command 126sequencing, recipient and message-size limits, and un-stuffing message data. 127Messages may also arrive via BDAT chunks (CHUNKING is advertised); both 128the collecting and streaming handler paths receive the reassembled content. 129MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552 130when it exceeds `max_message_size`, `BODY=7BIT`/`BODY=8BITMIME` (RFC 6152) 131are accepted, and unrecognized parameters get 555; the declared size and 132body type reach the handler via `Envelope`. Listening, accepting, and 133concurrency are up to the caller. 134 135To advertise and accept STARTTLS (TLS 1.3, via 136[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key 137pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` / 138`zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them: 139 140```zig 141var auth: zsmtp.tls.config.CertKeyPair = 142 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem"); 143defer auth.deinit(gpa); 144 145var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 146 .hostname = "mx.example.com", 147 .tls = .{ .io = io, .auth = &auth }, 148}); 149try session.run(gpa); 150``` 151 152On STARTTLS the session answers 220, performs the server handshake, swaps 153its transport to the encrypted connection, and resets state per RFC 3207 (the 154client must EHLO again). With `.mode = .implicit` the handshake instead runs 155before the greeting (SMTPS, port 465 style): 156 157```zig 158var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 159 .hostname = "mx.example.com", 160 .tls = .{ .io = io, .auth = &auth, .mode = .implicit }, 161}); 162``` 163 164## Demo CLI 165 166```sh 167zig build 168 169# Debug server that prints received messages to stdout 170# (with a cert/key pair it advertises and accepts STARTTLS): 171./zig-out/bin/zsmtp serve 2525 172./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525 173./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem --implicit-tls 2465 174 175# Send a message read from stdin: 176printf 'Subject: hi\r\n\r\nhello\r\n' | \ 177 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net 178 179# Same, over implicit TLS or STARTTLS (--insecure skips cert verification): 180zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 181zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 182``` 183 184## Status 185 186TLS is supported on both sides via 187[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 188TLS and STARTTLS via `zsmtp.Tls`, and the server accepts both STARTTLS and 189implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the 190client and PLAIN and LOGIN on the server. Message bodies can be streamed on 191both sides, and the server validates MAIL parameters (SIZE=, BODY=). 192 193## Standards 194 195- [RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321) — Simple Mail 196 Transfer Protocol: the command/reply protocol, multiline replies, 197 dot-stuffing, reply classes, and ESMTP parameter syntax (client and 198 server). 199- [RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870) — SIZE: 200 advertised and enforced by the server (oversize declarations are rejected 201 with 552 before DATA); parsed from EHLO by the client. 202- [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152) — 8BITMIME: 203 advertised by the server and `BODY=` validated; parsed by the client. 204- [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030) — CHUNKING 205 (BDAT): client and server, with length-based framing and no dot-stuffing; 206 the companion BINARYMIME extension is not implemented (`BODY=BINARYMIME` 207 is rejected). 208- [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING: 209 advertised by the server, whose strictly sequential command loop handles 210 pipelined clients naturally; parsed by the client. 211- [RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207) — STARTTLS: 212 client and server, including the mandatory post-handshake state reset. 213- [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314) — implicit TLS 214 (SMTPS): client (`Tls` before any SMTP traffic) and server 215 (`.mode = .implicit`). 216- [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client 217 and server, including initial responses and `*` cancellation. 218- [RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616) — the PLAIN 219 SASL mechanism (client and server). 220- [RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195) — CRAM-MD5 221 (client only; the server would need plaintext-equivalent credentials). 222- [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 223 — the de-facto AUTH LOGIN mechanism (client and server). 224- [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) / 225 [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced 226 status codes: carried in every server reply and advertised via 227 ENHANCEDSTATUSCODES; detected by the client. 228- [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) — SMTPUTF8: 229 client (`mailFromUtf8`) and server (advertised; non-ASCII addresses 230 require the parameter and must be valid UTF-8, rejected with 553 5.6.7 231 per [RFC 6533](https://datatracker.ietf.org/doc/html/rfc6533) otherwise; 232 the flag reaches handlers via `Envelope.smtputf8`). 233 234TLS itself (TLS 1.3, [RFC 8446](https://datatracker.ietf.org/doc/html/rfc8446)) 235is provided by [ianic/tls.zig](https://github.com/ianic/tls.zig). 236 237## Tests 238 239```sh 240zig build test 241zig build test --fuzz # run the fuzz tests under the fuzzer (endless) 242``` 243 244The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`), 245whole-session robustness against arbitrary bytes on both the client and 246server side, and two differential properties: the streaming `DataWriter` 247must produce byte-identical output to the slice-based `writeStuffed` under 248fuzzer-chosen chunk boundaries, and the collecting and streaming server 249DATA paths must yield identical message content. 250 251### Protocol torture testing with exim's test client 252 253Exim's scriptable SMTP test client (`test/src/client.c` in the exim 254source) sends raw protocol lines and asserts reply prefixes. The exim 255source is declared as a *lazy* Zig dependency, fetched only on demand: 256 257```sh 258zig build -Dexim-client # fetches exim, installs zig-out/bin/exim-client 259./zig-out/bin/zsmtp serve 2525 & 260./zig-out/bin/exim-client 127.0.0.1 2525 < test/protocol-torture.script 261``` 262 263`test/protocol-torture.script` is a 28-reply dialogue distilled from 264exim's own test suite (syntax errors, sequencing violations, parameter 265validation, dot-stuffing); the same dialogue is asserted byte-for-byte 266as a unit test in `Server.zig`. 267 268The library is MIT-licensed; the small amount of test-only material adapted 269from exim's test suite (the torture script and the gauntlet unit test's 270dialogue) is GPL-2.0-or-later, marked with SPDX snippet tags and REUSE.toml 271annotations. 272 273Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled 274test runner fails to compile in fuzz mode, and the coverage server panics 275on a test binary with no fuzz tests); both are fixed on Zig master. Until 276then, fuzzing needs a patched copy of the standard library via 277`zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests 278themselves also run once per invocation as part of the normal 279`zig build test` suite. 280 281Interoperability against third-party implementations is covered by a NixOS 282VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix 283and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks 284delivers to the zsmtp server over plaintext and STARTTLS. 285 286```sh 287nix build .#zsmtp # build the package 288nix build .#checks.x86_64-linux.interop # run the VM interop test 289```