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 79`mail` and `rcpt` are the parameterized forms of `mailFrom` and `rcptTo`, 80carrying the ESMTP parameters the server advertised — today SMTPUTF8 and the 81DSN set of [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461): 82 83```zig 84try client.mail("me@example.com", .{ .ret = .hdrs, .envid = "batch 7" }); 85try client.rcpt("bob@example.net", .{ 86 .notify = .{ .on = .{ .failure = true, .delay = true } }, 87 .orcpt = .{ .addr_type = "rfc822", .address = "team@example.net" }, 88}); 89``` 90 91`ENVID` and the `ORCPT` address are xtext-encoded on the way out, so any 92bytes are safe to pass; the length limits RFC 3461 puts on the encoded form 93(100 and 500 characters) are checked and surface as 94`error.ArgumentTooLong`. Check `extensions.dsn` first — a conforming server 95answers an unrecognized parameter with 555. 96 97When the server advertises CHUNKING (`extensions.chunking`), `bdat` and 98`sendMessageChunked` transmit the message with length-framed BDAT chunks 99instead of DATA — verbatim, with no dot-stuffing, so content must already 100use CRLF line endings. 101 102### Authentication 103 104`hello` reports the server's advertised mechanisms in `extensions.auth`; 105`authenticate` picks the best one, or use `authPlain`/`authLogin`/ 106`authCramMd5` directly. A 535 rejection surfaces as 107`error.AuthenticationFailed` with the reply in `last_reply`. 108 109```zig 110const extensions = try client.hello("my-host.example.com"); 111try client.authenticate(extensions, "user", "password"); 112``` 113 114PLAIN and LOGIN send the password in the clear — base64 is not encryption — 115so the client refuses them unless `client.security` is `.encrypted`, 116returning `error.InsecureTransport` instead. The library is handed a reader 117and a writer and cannot see what is underneath them, so it assumes the worst: 118`setTransport` records the answer for a STARTTLS upgrade, and a session 119speaking TLS from the first byte sets `client.security = .encrypted` itself. 120Which mechanism `authenticate` picks follows from that — PLAIN, then LOGIN, 121then CRAM-MD5 once encrypted, and CRAM-MD5 first when it is not, since that 122is the one mechanism of the three that never puts the password on the wire. 123 124For a connection protected by something the library cannot see — a unix 125socket, an SSH tunnel, a loopback test — `client.allow_cleartext_auth = true` 126permits the cleartext mechanisms without claiming the transport is encrypted. 127 128### TLS 129 130`zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and 131verifies against the system trust store by default (a caller-managed CA 132bundle and an insecure mode are also available). The stream reader/writer 133handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and 134`init` must run at the value's final address (the connection holds interior 135pointers). The standard library's TLS client is deliberately not used: it 136requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec 137record, which servers like Exim disable. 138 139Implicit TLS (port 465) — handshake first, then speak SMTP: 140 141```zig 142var tls: zsmtp.Tls = undefined; 143try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{ 144 .host = "smtp.example.com", 145}); 146defer tls.deinit(gpa); 147var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); 148client.security = .encrypted; // the transport is TLS; `init` cannot tell 149// ... greet, hello, sendMail ... 150try client.quit(); 151try tls.end(); // close_notify, before closing the socket 152``` 153 154STARTTLS (port 587) — upgrade mid-session, then EHLO again: 155 156```zig 157_ = try client.greet(); 158_ = try client.hello("my-host.example.com"); // check .starttls in the result 159try client.starttls(); 160var tls: zsmtp.Tls = undefined; 161try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{ 162 .host = "smtp.example.com", 163}); 164client.setTransport(tls.reader(), tls.writer(), .encrypted); 165_ = try client.hello("my-host.example.com"); // server state was reset 166``` 167 168## Server 169 170```zig 171var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 172 .context = &my_state, 173 .vtable = &.{ 174 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN 175 .rcptTo = onRcptTo, // optional; accept/reject each Recipient 176 .message = onMessage, // required; receives envelope + message data 177 }, 178}, .{ .hostname = "mx.example.com" }); 179try session.run(gpa); 180``` 181 182With an `authenticate` callback the session advertises and accepts AUTH 183PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL 184with 530 until the client has authenticated. 185 186Instead of `message` (which collects the whole body in memory, bounded by 187`max_message_size`), a handler can set `messageReader` to stream it: the 188callback receives an `Io.Reader` yielding the unstuffed message content, 189and anything left unread is drained by the session. 190 191`run` serves one connection until QUIT or disconnect, enforcing command 192sequencing, recipient and message-size limits, and un-stuffing message data. 193Messages may also arrive via BDAT chunks (CHUNKING is advertised); both 194the collecting and streaming handler paths receive the reassembled content. 195MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552 196when it exceeds `max_message_size`, `BODY=7BIT`/`BODY=8BITMIME` (RFC 6152) 197are accepted, and unrecognized parameters get 555; the declared size and 198body type reach the handler via `Envelope`. Listening, accepting, and 199concurrency are up to the caller. 200 201DSN ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)) is 202advertised. `RET=` and `ENVID=` on MAIL arrive as `Envelope.ret` and 203`Envelope.envid`, and `NOTIFY=` and `ORCPT=` on RCPT arrive as 204`Recipient.notify` and `Recipient.orcpt` — at the `rcptTo` callback, which 205receives the whole `Recipient`, and again on the `Envelope` afterwards. The 206xtext values are decoded, the length limits enforced, and a malformed value 207answered with 501. Like everything else handed to a callback, those slices 208live only for the duration of the call; keep what you need by copying it. 209 210To advertise and accept STARTTLS (TLS 1.3, via 211[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key 212pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` / 213`zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them: 214 215```zig 216var auth: zsmtp.tls.config.CertKeyPair = 217 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem"); 218defer auth.deinit(gpa); 219 220var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 221 .hostname = "mx.example.com", 222 .tls = .{ .io = io, .auth = &auth }, 223}); 224try session.run(gpa); 225``` 226 227On STARTTLS the session answers 220, performs the server handshake, swaps 228its transport to the encrypted connection, and resets state per RFC 3207 (the 229client must EHLO again). With `.mode = .implicit` the handshake instead runs 230before the greeting (SMTPS, port 465 style): 231 232```zig 233var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 234 .hostname = "mx.example.com", 235 .tls = .{ .io = io, .auth = &auth, .mode = .implicit }, 236}); 237``` 238 239## Demo CLI 240 241```sh 242zig build 243 244# Debug server that prints received messages to stdout 245# (with a cert/key pair it advertises and accepts STARTTLS): 246./zig-out/bin/zsmtp serve 2525 247./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525 248./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem --implicit-tls 2465 249 250# Send a message read from stdin: 251printf 'Subject: hi\r\n\r\nhello\r\n' | \ 252 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net 253 254# Same, over implicit TLS or STARTTLS (--insecure skips cert verification): 255zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 256zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 257 258# Request a delivery status notification (RFC 3461): 259zsmtp send --ret hdrs --envid 'batch 7' --notify success,failure \ 260 --orcpt team@example.net 127.0.0.1 2525 me@example.com you@example.net 261 262# Authenticate. Over a plaintext connection this refuses PLAIN and LOGIN 263# rather than put the password on the wire; --allow-cleartext-auth overrides 264# that for a connection protected by other means: 265zsmtp send --starttls --user me --password secret smtp.example.com 587 \ 266 me@example.com you@example.net 267``` 268 269## Status 270 271TLS is supported on both sides via 272[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 273TLS and STARTTLS via `zsmtp.Tls`, and the server accepts both STARTTLS and 274implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the 275client and PLAIN and LOGIN on the server. Message bodies can be streamed on 276both sides, and the server validates MAIL and RCPT parameters (SIZE=, BODY=, 277and the DSN set RET=, ENVID=, NOTIFY=, ORCPT=). 278 279## Known gaps 280 281Measured against the implementations people are likely to be coming from — 282Postfix, Exim and Haraka on the server side, Go's `net/smtp`, Python's 283`smtplib`, lettre and Nodemailer on the client side. Kept here so the list 284is one thing rather than a rediscovery each time. 285 286### Out of scope, not missing 287 288- **Message composition.** No MIME builder, headers, attachments, transfer 289 encodings, `Message-ID` or `Date` generation. zsmtp carries a message that 290 already exists; building one is RFC 5322's job and belongs in a library of 291 its own. 292- **DSN report generation** 293 ([RFC 3464](https://datatracker.ietf.org/doc/html/rfc3464)). The SMTP half 294 of DSN — RFC 3461's `RET`, `ENVID`, `NOTIFY` and `ORCPT` — is implemented 295 on both sides, but nothing here builds the `multipart/report` message that 296 carries a delivery status back to the sender. That is message composition 297 by another name, so it goes with the library above. 298- **Everything an MTA does around a session.** No queue, no retry schedule, 299 no MX resolution, no routing, no mailbox store. "Server" here means a 300 session handler: listening, accepting and concurrency are the caller's. 301 302### Protocol 303 304- **LMTP** ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)) — no 305 `LHLO`, no per-recipient reply after the final dot. The missing mode for 306 anyone wanting a delivery agent behind Postfix. 307- **PIPELINING** ([RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920)) 308 — advertised and parsed by both sides, used by neither. `sendMail` is 309 strictly request-response. 310- **BINARYMIME** — CHUNKING is implemented but `BODY=BINARYMIME` is refused, 311 which is the other half of 312 [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030). 313- **Modern SASL** — no XOAUTH2 or OAUTHBEARER 314 ([RFC 7628](https://datatracker.ietf.org/doc/html/rfc7628)), which is what 315 Gmail and Microsoft 365 now require; no SCRAM-SHA-256 316 ([RFC 7677](https://datatracker.ietf.org/doc/html/rfc7677)), no EXTERNAL, 317 no `AUTH=` on MAIL FROM. CRAM-MD5 is the most modern mechanism present. 318- **Client certificates** — neither side can present or verify one. 319- **No enhanced status code accessor** — the server emits `x.y.z` on every 320 reply, but `Reply` exposes only `code` and the raw text. 321- `EXPN` is unrecognized rather than unimplemented, so it answers 500 where 322 [RFC 5321 §4.2.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.4) 323 wants 502. 324- Niche and absent: REQUIRETLS, MT-PRIORITY, DELIVERBY, FUTURERELEASE, ETRN. 325 326### Server 327 328- **No `Received:` header.** 329 [RFC 5321 §4.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.4) 330 requires a receiving server to stamp one. 331- **The handler never sees the connection** — no connect callback, no peer 332 address, no TLS state. Greylisting, DNSBLs, SPF and per-IP policy cannot 333 be built on top, and a `Received:` header cannot be written without it. 334- **No timeouts**, so a client that connects and says nothing holds the 335 session forever; 336 [RFC 5321 §4.5.3.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.2) 337 specifies per-command limits. 338- **No abuse limits** beyond `max_recipients`: unlimited failed AUTH 339 attempts, no error-count disconnect, no command budget. 340- **No `require_tls`** to go with `require_auth`. 341- **No PROXY protocol, XCLIENT or XFORWARD**, so the real peer address is 342 lost behind a load balancer. 343- No filter or milter hook, and so no DKIM, SPF, DMARC or ARC. 344- No logging or tracing hooks. 345- `max_message_size` is not enforced in `messageReader` mode. 346 347### Client 348 349- **`sendMail` is all-or-nothing on recipients** — the first rejected RCPT 350 aborts the transaction, where `smtplib.sendmail` reports the refused ones 351 and fails only when every one is refused. 352- **No `SIZE=` or `BODY=` on MAIL**, though the client parses both 353 capabilities off EHLO; `max_size` in particular is read and never used, so 354 nothing checks that a message fits before transmitting it. 355- No MX resolution or connect helper, no 4xx retry or backoff, no connection 356 reuse helper, no pipelined `sendMail`. 357 358## Standards 359 360- [RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321) — Simple Mail 361 Transfer Protocol: the command/reply protocol, multiline replies, 362 dot-stuffing, reply classes, and ESMTP parameter syntax (client and 363 server). 364- [RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870) — SIZE: 365 advertised and enforced by the server (oversize declarations are rejected 366 with 552 before DATA); parsed from EHLO by the client. 367- [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152) — 8BITMIME: 368 advertised by the server and `BODY=` validated; parsed by the client. 369- [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030) — CHUNKING 370 (BDAT): client and server, with length-based framing and no dot-stuffing; 371 the companion BINARYMIME extension is not implemented (`BODY=BINARYMIME` 372 is rejected). 373- [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — DSN: 374 advertised by the server, which parses and validates `RET=`/`ENVID=` on 375 MAIL and `NOTIFY=`/`ORCPT=` on RCPT and hands them to the handler; the 376 client sends them through `mail`/`rcpt`. Includes the xtext codec of §4. 377 Generating the report message itself (RFC 3464) is out of scope. 378- [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING: 379 advertised by the server, whose strictly sequential command loop handles 380 pipelined clients naturally; parsed by the client. 381- [RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207) — STARTTLS: 382 client and server, including the mandatory post-handshake state reset. 383- [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314) — implicit TLS 384 (SMTPS): client (`Tls` before any SMTP traffic) and server 385 (`.mode = .implicit`). 386- [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client 387 and server, including initial responses and `*` cancellation. 388- [RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616) — the PLAIN 389 SASL mechanism (client and server). 390- [RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195) — CRAM-MD5 391 (client only; the server would need plaintext-equivalent credentials). 392- [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 393 — the de-facto AUTH LOGIN mechanism (client and server). 394- [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) / 395 [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced 396 status codes: carried in every server reply and advertised via 397 ENHANCEDSTATUSCODES; detected by the client. 398- [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) — SMTPUTF8: 399 client (`mailFromUtf8`) and server (advertised; non-ASCII addresses 400 require the parameter and must be valid UTF-8, rejected with 553 5.6.7 401 per [RFC 6533](https://datatracker.ietf.org/doc/html/rfc6533) otherwise; 402 the flag reaches handlers via `Envelope.smtputf8`). 403 404TLS itself (TLS 1.3, [RFC 8446](https://datatracker.ietf.org/doc/html/rfc8446)) 405is provided by [ianic/tls.zig](https://github.com/ianic/tls.zig). 406 407## Tests 408 409```sh 410zig build test 411zig build test --fuzz # run the fuzz tests under the fuzzer (endless) 412``` 413 414The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`), 415whole-session robustness against arbitrary bytes on both the client and 416server side, and two differential properties: the streaming `DataWriter` 417must produce byte-identical output to the slice-based `writeStuffed` under 418fuzzer-chosen chunk boundaries, and the collecting and streaming server 419DATA paths must yield identical message content. 420 421### Protocol torture testing with exim's test client 422 423Exim's scriptable SMTP test client (`test/src/client.c` in the exim 424source) sends raw protocol lines and asserts reply prefixes. The exim 425source is declared as a *lazy* Zig dependency, fetched only on demand: 426 427```sh 428zig build -Dexim-client # fetches exim, installs zig-out/bin/exim-client 429./zig-out/bin/zsmtp serve 2525 & 430./zig-out/bin/exim-client 127.0.0.1 2525 < test/protocol-torture.script 431``` 432 433### Address corpus testing with the is_email suite 434 435Dominic Sayers' [is_email](https://github.com/dominicsayers/isemail) test 436suite (BSD-3-Clause) is declared as a *lazy* Zig dependency; nothing from 437it is copied into this repository. On demand, the corpus test embeds its 438XML test files, extracts the 125 addresses valid at the RFC 5321 layer, 439and checks that each passes through the path parser byte-for-byte: 440 441```sh 442zig build test -Disemail-corpus # fetches the suite and runs the corpus test 443``` 444 445Without the option the corpus test is skipped. 446 447`test/protocol-torture.script` is a 28-reply dialogue distilled from 448exim's own test suite (syntax errors, sequencing violations, parameter 449validation, dot-stuffing); the same dialogue is asserted byte-for-byte 450as a unit test in `Server.zig`. 451 452The library is MIT-licensed; the small amount of test-only material adapted 453from exim's test suite (the torture script and the gauntlet unit test's 454dialogue) is GPL-2.0-or-later, marked with SPDX snippet tags and REUSE.toml 455annotations. 456 457Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled 458test runner fails to compile in fuzz mode, and the coverage server panics 459on a test binary with no fuzz tests); both are fixed on Zig master. Until 460then, fuzzing needs a patched copy of the standard library via 461`zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests 462themselves also run once per invocation as part of the normal 463`zig build test` suite. 464 465Interoperability against third-party implementations is covered by a NixOS 466VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix 467and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks 468delivers to the zsmtp server over plaintext and STARTTLS. 469 470```sh 471nix build .#zsmtp # build the package 472nix build .#checks.x86_64-linux.interop # run the VM interop test 473```