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(io, gpa, &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(io, gpa, &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### Address corpus testing with the is_email suite 264 265Dominic Sayers' [is_email](https://github.com/dominicsayers/isemail) test 266suite (BSD-3-Clause) is declared as a *lazy* Zig dependency; nothing from 267it is copied into this repository. On demand, the corpus test embeds its 268XML test files, extracts the 125 addresses valid at the RFC 5321 layer, 269and checks that each passes through the path parser byte-for-byte: 270 271```sh 272zig build test -Disemail-corpus # fetches the suite and runs the corpus test 273``` 274 275Without the option the corpus test is skipped. 276 277`test/protocol-torture.script` is a 28-reply dialogue distilled from 278exim's own test suite (syntax errors, sequencing violations, parameter 279validation, dot-stuffing); the same dialogue is asserted byte-for-byte 280as a unit test in `Server.zig`. 281 282The library is MIT-licensed; the small amount of test-only material adapted 283from exim's test suite (the torture script and the gauntlet unit test's 284dialogue) is GPL-2.0-or-later, marked with SPDX snippet tags and REUSE.toml 285annotations. 286 287Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled 288test runner fails to compile in fuzz mode, and the coverage server panics 289on a test binary with no fuzz tests); both are fixed on Zig master. Until 290then, fuzzing needs a patched copy of the standard library via 291`zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests 292themselves also run once per invocation as part of the normal 293`zig build test` suite. 294 295Interoperability against third-party implementations is covered by a NixOS 296VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix 297and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks 298delivers to the zsmtp server over plaintext and STARTTLS. 299 300```sh 301nix build .#zsmtp # build the package 302nix build .#checks.x86_64-linux.interop # run the VM interop test 303```