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