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