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## Where this lives 15 16The canonical repository is on Forgejo, with mirrors on Tangled and Radicle: 17 18- <https://git.jcollie.dev/jeff/zsmtp> — issues and pull requests 19- <https://tangled.org/jcollie.dev/zsmtp> 20 21```sh 22git clone https://git.jcollie.dev/jeff/zsmtp.git 23``` 24 25On [Radicle](https://radicle.xyz/), the peer-to-peer forge, the repository is 26`rad:z3ZKHgoDKEue8FT7sV6fHZdtjxRx1`, which is the only name it has there — a 27Radicle repository is found by its ID and nothing else — so seeding or cloning 28it goes: 29 30```sh 31rad clone rad:z3ZKHgoDKEue8FT7sV6fHZdtjxRx1 32``` 33 34Cloning also seeds the repository, which helps keep it available on the 35network. 36 37The API documentation is generated from the doc comments and published at 38<https://jeff.jcollie.page/zsmtp/>; `zig build docs` builds it locally and 39`zig build docs-serve` serves it for reading. 40 41## Client 42 43```zig 44const zsmtp = @import("zsmtp"); 45 46var reply_buf: [1024]u8 = undefined; 47var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 48 49_ = try client.greet(); // read the 220 greeting 50_ = try client.hello("my-host.example.com"); // EHLO (HELO fallback), returns extensions 51try client.sendMail("me@example.com", &.{"you@example.net"}, message); 52try client.quit(); 53``` 54 55Line endings in the message are normalized to CRLF and leading dots are 56stuffed automatically. On `error.UnexpectedReply`, `client.last_reply` holds 57the server's actual code and text. `mailFrom`/`rcptTo`/`sendMessage` are also 58available individually. 59 60Addresses and the EHLO domain are checked before they are written: a value 61containing CR, LF or NUL is rejected with `error.UnsafeArgument` rather than 62sent, since it would otherwise end the command line early and let the rest of 63it be read as further SMTP commands. The check is `protocol.isSafeArgument`, 64and it is framing only — it does not claim the address is a well-formed 65mailbox. 66 67Message bodies can also be streamed instead of passed as a slice — from any 68reader via `sendMessageReader(&reader)`, or push-style via `data()`, which 69returns a writer that dot-stuffs and normalizes line endings as content 70flows through it: 71 72```zig 73var data_writer = try client.data(); 74try data_writer.interface.print("Subject: report {d}\r\n\r\n", .{id}); 75// ... stream as much as needed ... 76try data_writer.end(); // terminates the message, reads the verdict 77``` 78 79When the server advertises CHUNKING (`extensions.chunking`), `bdat` and 80`sendMessageChunked` transmit the message with length-framed BDAT chunks 81instead of DATA — verbatim, with no dot-stuffing, so content must already 82use CRLF line endings. 83 84### Authentication 85 86`hello` reports the server's advertised mechanisms in `extensions.auth`; 87`authenticate` picks the best one, or use `authPlain`/`authLogin`/ 88`authCramMd5` directly. A 535 rejection surfaces as 89`error.AuthenticationFailed` with the reply in `last_reply`. 90 91```zig 92const extensions = try client.hello("my-host.example.com"); 93try client.authenticate(extensions, "user", "password"); 94``` 95 96PLAIN and LOGIN send the password in the clear — base64 is not encryption — 97so the client refuses them unless `client.security` is `.encrypted`, 98returning `error.InsecureTransport` instead. The library is handed a reader 99and a writer and cannot see what is underneath them, so it assumes the worst: 100`setTransport` records the answer for a STARTTLS upgrade, and a session 101speaking TLS from the first byte sets `client.security = .encrypted` itself. 102Which mechanism `authenticate` picks follows from that — PLAIN, then LOGIN, 103then CRAM-MD5 once encrypted, and CRAM-MD5 first when it is not, since that 104is the one mechanism of the three that never puts the password on the wire. 105 106For a connection protected by something the library cannot see — a unix 107socket, an SSH tunnel, a loopback test — `client.allow_cleartext_auth = true` 108permits the cleartext mechanisms without claiming the transport is encrypted. 109 110### TLS 111 112`zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and 113verifies against the system trust store by default (a caller-managed CA 114bundle and an insecure mode are also available). The stream reader/writer 115handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and 116`init` must run at the value's final address (the connection holds interior 117pointers). The standard library's TLS client is deliberately not used: it 118requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec 119record, which servers like Exim disable. 120 121Implicit TLS (port 465) — handshake first, then speak SMTP: 122 123```zig 124var tls: zsmtp.Tls = undefined; 125try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{ 126 .host = "smtp.example.com", 127}); 128defer tls.deinit(gpa); 129var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); 130client.security = .encrypted; // the transport is TLS; `init` cannot tell 131// ... greet, hello, sendMail ... 132try client.quit(); 133try tls.end(); // close_notify, before closing the socket 134``` 135 136STARTTLS (port 587) — upgrade mid-session, then EHLO again: 137 138```zig 139_ = try client.greet(); 140_ = try client.hello("my-host.example.com"); // check .starttls in the result 141try client.starttls(); 142var tls: zsmtp.Tls = undefined; 143try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{ 144 .host = "smtp.example.com", 145}); 146client.setTransport(tls.reader(), tls.writer(), .encrypted); 147_ = try client.hello("my-host.example.com"); // server state was reset 148``` 149 150## Server 151 152```zig 153var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 154 .context = &my_state, 155 .vtable = &.{ 156 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN 157 .rcptTo = onRcptTo, // optional; accept/reject each recipient 158 .message = onMessage, // required; receives envelope + message data 159 }, 160}, .{ .hostname = "mx.example.com" }); 161try session.run(gpa); 162``` 163 164With an `authenticate` callback the session advertises and accepts AUTH 165PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL 166with 530 until the client has authenticated. 167 168Instead of `message` (which collects the whole body in memory, bounded by 169`max_message_size`), a handler can set `messageReader` to stream it: the 170callback receives an `Io.Reader` yielding the unstuffed message content, 171and anything left unread is drained by the session. 172 173`run` serves one connection until QUIT or disconnect, enforcing command 174sequencing, recipient and message-size limits, and un-stuffing message data. 175Messages may also arrive via BDAT chunks (CHUNKING is advertised); both 176the collecting and streaming handler paths receive the reassembled content. 177MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552 178when it exceeds `max_message_size`, `BODY=7BIT`/`BODY=8BITMIME` (RFC 6152) 179are accepted, and unrecognized parameters get 555; the declared size and 180body type reach the handler via `Envelope`. Listening, accepting, and 181concurrency are up to the caller. 182 183To advertise and accept STARTTLS (TLS 1.3, via 184[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key 185pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` / 186`zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them: 187 188```zig 189var auth: zsmtp.tls.config.CertKeyPair = 190 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem"); 191defer auth.deinit(gpa); 192 193var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 194 .hostname = "mx.example.com", 195 .tls = .{ .io = io, .auth = &auth }, 196}); 197try session.run(gpa); 198``` 199 200On STARTTLS the session answers 220, performs the server handshake, swaps 201its transport to the encrypted connection, and resets state per RFC 3207 (the 202client must EHLO again). With `.mode = .implicit` the handshake instead runs 203before the greeting (SMTPS, port 465 style): 204 205```zig 206var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 207 .hostname = "mx.example.com", 208 .tls = .{ .io = io, .auth = &auth, .mode = .implicit }, 209}); 210``` 211 212## Demo CLI 213 214```sh 215zig build 216 217# Debug server that prints received messages to stdout 218# (with a cert/key pair it advertises and accepts STARTTLS): 219./zig-out/bin/zsmtp serve 2525 220./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525 221./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem --implicit-tls 2465 222 223# Send a message read from stdin: 224printf 'Subject: hi\r\n\r\nhello\r\n' | \ 225 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net 226 227# Same, over implicit TLS or STARTTLS (--insecure skips cert verification): 228zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 229zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 230 231# Authenticate. Over a plaintext connection this refuses PLAIN and LOGIN 232# rather than put the password on the wire; --allow-cleartext-auth overrides 233# that for a connection protected by other means: 234zsmtp send --starttls --user me --password secret smtp.example.com 587 \ 235 me@example.com you@example.net 236``` 237 238## Status 239 240TLS is supported on both sides via 241[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 242TLS and STARTTLS via `zsmtp.Tls`, and the server accepts both STARTTLS and 243implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the 244client and PLAIN and LOGIN on the server. Message bodies can be streamed on 245both sides, and the server validates MAIL parameters (SIZE=, BODY=). 246 247## Standards 248 249- [RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321) — Simple Mail 250 Transfer Protocol: the command/reply protocol, multiline replies, 251 dot-stuffing, reply classes, and ESMTP parameter syntax (client and 252 server). 253- [RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870) — SIZE: 254 advertised and enforced by the server (oversize declarations are rejected 255 with 552 before DATA); parsed from EHLO by the client. 256- [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152) — 8BITMIME: 257 advertised by the server and `BODY=` validated; parsed by the client. 258- [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030) — CHUNKING 259 (BDAT): client and server, with length-based framing and no dot-stuffing; 260 the companion BINARYMIME extension is not implemented (`BODY=BINARYMIME` 261 is rejected). 262- [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING: 263 advertised by the server, whose strictly sequential command loop handles 264 pipelined clients naturally; parsed by the client. 265- [RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207) — STARTTLS: 266 client and server, including the mandatory post-handshake state reset. 267- [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314) — implicit TLS 268 (SMTPS): client (`Tls` before any SMTP traffic) and server 269 (`.mode = .implicit`). 270- [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client 271 and server, including initial responses and `*` cancellation. 272- [RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616) — the PLAIN 273 SASL mechanism (client and server). 274- [RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195) — CRAM-MD5 275 (client only; the server would need plaintext-equivalent credentials). 276- [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 277 — the de-facto AUTH LOGIN mechanism (client and server). 278- [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) / 279 [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced 280 status codes: carried in every server reply and advertised via 281 ENHANCEDSTATUSCODES; detected by the client. 282- [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) — SMTPUTF8: 283 client (`mailFromUtf8`) and server (advertised; non-ASCII addresses 284 require the parameter and must be valid UTF-8, rejected with 553 5.6.7 285 per [RFC 6533](https://datatracker.ietf.org/doc/html/rfc6533) otherwise; 286 the flag reaches handlers via `Envelope.smtputf8`). 287 288TLS itself (TLS 1.3, [RFC 8446](https://datatracker.ietf.org/doc/html/rfc8446)) 289is provided by [ianic/tls.zig](https://github.com/ianic/tls.zig). 290 291## Tests 292 293```sh 294zig build test 295zig build test --fuzz # run the fuzz tests under the fuzzer (endless) 296``` 297 298The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`), 299whole-session robustness against arbitrary bytes on both the client and 300server side, and two differential properties: the streaming `DataWriter` 301must produce byte-identical output to the slice-based `writeStuffed` under 302fuzzer-chosen chunk boundaries, and the collecting and streaming server 303DATA paths must yield identical message content. 304 305### Protocol torture testing with exim's test client 306 307Exim's scriptable SMTP test client (`test/src/client.c` in the exim 308source) sends raw protocol lines and asserts reply prefixes. The exim 309source is declared as a *lazy* Zig dependency, fetched only on demand: 310 311```sh 312zig build -Dexim-client # fetches exim, installs zig-out/bin/exim-client 313./zig-out/bin/zsmtp serve 2525 & 314./zig-out/bin/exim-client 127.0.0.1 2525 < test/protocol-torture.script 315``` 316 317### Address corpus testing with the is_email suite 318 319Dominic Sayers' [is_email](https://github.com/dominicsayers/isemail) test 320suite (BSD-3-Clause) is declared as a *lazy* Zig dependency; nothing from 321it is copied into this repository. On demand, the corpus test embeds its 322XML test files, extracts the 125 addresses valid at the RFC 5321 layer, 323and checks that each passes through the path parser byte-for-byte: 324 325```sh 326zig build test -Disemail-corpus # fetches the suite and runs the corpus test 327``` 328 329Without the option the corpus test is skipped. 330 331`test/protocol-torture.script` is a 28-reply dialogue distilled from 332exim's own test suite (syntax errors, sequencing violations, parameter 333validation, dot-stuffing); the same dialogue is asserted byte-for-byte 334as a unit test in `Server.zig`. 335 336The library is MIT-licensed; the small amount of test-only material adapted 337from exim's test suite (the torture script and the gauntlet unit test's 338dialogue) is GPL-2.0-or-later, marked with SPDX snippet tags and REUSE.toml 339annotations. 340 341Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled 342test runner fails to compile in fuzz mode, and the coverage server panics 343on a test binary with no fuzz tests); both are fixed on Zig master. Until 344then, fuzzing needs a patched copy of the standard library via 345`zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests 346themselves also run once per invocation as part of the normal 347`zig build test` suite. 348 349Interoperability against third-party implementations is covered by a NixOS 350VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix 351and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks 352delivers to the zsmtp server over plaintext and STARTTLS. 353 354```sh 355nix build .#zsmtp # build the package 356nix build .#checks.x86_64-linux.interop # run the VM interop test 357```