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 97Setting `client.mode = .lmtp` before `hello` speaks LMTP: `LHLO` goes out in 98place of `EHLO`, and the end of a message brings back one verdict per 99accepted recipient, in the order the RCPT commands were issued. `endResults` 100is how to read them: 101 102```zig 103var data_writer = try client.data(); 104try data_writer.interface.writeAll(message); 105var verdicts = try data_writer.endResults(); 106while (try verdicts.next()) |reply| { 107 // verdicts.index counts the recipients as they are answered. 108 std.log.info("{s}: {d} {s}", .{ recipients[verdicts.index - 1], reply.code, reply.text }); 109} 110``` 111 112Every verdict must be read before the session is used again, or the next 113command is answered by a leftover reply. The simpler `end` reads them all 114and reports `error.RecipientRejected` if any was a refusal — without saying 115which, because the replies share one buffer and reading the next overwrites 116the previous. 117 118When the server advertises CHUNKING (`extensions.chunking`), `bdat` and 119`sendMessageChunked` transmit the message with length-framed BDAT chunks 120instead of DATA — verbatim, with no dot-stuffing, so content must already 121use CRLF line endings. 122 123### Authentication 124 125`hello` reports the server's advertised mechanisms in `extensions.auth`; 126`authenticate` picks the best one, or use `authPlain`/`authLogin`/ 127`authCramMd5` directly. A 535 rejection surfaces as 128`error.AuthenticationFailed` with the reply in `last_reply`. 129 130```zig 131const extensions = try client.hello("my-host.example.com"); 132try client.authenticate(extensions, "user", "password"); 133``` 134 135PLAIN and LOGIN send the password in the clear — base64 is not encryption — 136so the client refuses them unless `client.security` is `.encrypted`, 137returning `error.InsecureTransport` instead. The library is handed a reader 138and a writer and cannot see what is underneath them, so it assumes the worst: 139`setTransport` records the answer for a STARTTLS upgrade, and a session 140speaking TLS from the first byte sets `client.security = .encrypted` itself. 141Which mechanism `authenticate` picks follows from that — PLAIN, then LOGIN, 142then CRAM-MD5 once encrypted, and CRAM-MD5 first when it is not, since that 143is the one mechanism of the three that never puts the password on the wire. 144 145For a connection protected by something the library cannot see — a unix 146socket, an SSH tunnel, a loopback test — `client.allow_cleartext_auth = true` 147permits the cleartext mechanisms without claiming the transport is encrypted. 148 149### TLS 150 151`zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and 152verifies against the system trust store by default (a caller-managed CA 153bundle and an insecure mode are also available). The stream reader/writer 154handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and 155`init` must run at the value's final address (the connection holds interior 156pointers). The standard library's TLS client is deliberately not used: it 157requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec 158record, which servers like Exim disable. 159 160Implicit TLS (port 465) — handshake first, then speak SMTP: 161 162```zig 163var tls: zsmtp.Tls = undefined; 164try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{ 165 .host = "smtp.example.com", 166}); 167defer tls.deinit(gpa); 168var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); 169client.security = .encrypted; // the transport is TLS; `init` cannot tell 170// ... greet, hello, sendMail ... 171try client.quit(); 172try tls.end(); // close_notify, before closing the socket 173``` 174 175STARTTLS (port 587) — upgrade mid-session, then EHLO again: 176 177```zig 178_ = try client.greet(); 179_ = try client.hello("my-host.example.com"); // check .starttls in the result 180try client.starttls(); 181var tls: zsmtp.Tls = undefined; 182try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{ 183 .host = "smtp.example.com", 184}); 185client.setTransport(tls.reader(), tls.writer(), .encrypted); 186_ = try client.hello("my-host.example.com"); // server state was reset 187``` 188 189## Server 190 191```zig 192var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 193 .context = &my_state, 194 .vtable = &.{ 195 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN 196 .rcptTo = onRcptTo, // optional; accept/reject each Recipient 197 .message = onMessage, // required; receives envelope + message data 198 }, 199}, .{ .hostname = "mx.example.com" }); 200try session.run(gpa); 201``` 202 203With an `authenticate` callback the session advertises and accepts AUTH 204PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL 205with 530 until the client has authenticated. 206 207Instead of `message` (which collects the whole body in memory, bounded by 208`max_message_size`), a handler can set `messageReader` to stream it: the 209callback receives an `Io.Reader` yielding the unstuffed message content, 210and anything left unread is drained by the session. 211 212`run` serves one connection until QUIT or disconnect, enforcing command 213sequencing, recipient and message-size limits, and un-stuffing message data. 214Messages may also arrive via BDAT chunks (CHUNKING is advertised); both 215the collecting and streaming handler paths receive the reassembled content. 216MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552 217when it exceeds `max_message_size`, `BODY=7BIT`/`BODY=8BITMIME` (RFC 6152) 218are accepted, and unrecognized parameters get 555; the declared size and 219body type reach the handler via `Envelope`. Listening, accepting, and 220concurrency are up to the caller. 221 222Setting `Options.protocol = .lmtp` makes the session speak LMTP 223([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)) instead: `LHLO` 224greets and `HELO`/`EHLO` are refused with 500, and the end of a message 225draws one reply per accepted recipient rather than one for the message — 226including a second reply for a recipient named twice. The `recipientResult` 227callback supplies each verdict: 228 229```zig 230fn onRecipientResult(ctx: ?*anyopaque, envelope: zsmtp.Server.Envelope, index: usize) zsmtp.Server.Decision { 231 return if (mailboxIsFull(envelope.recipients[index].address)) 232 .{ .reject = .{ .code = 452, .text = "4.2.2 Mailbox full" } } 233 else 234 .accept; 235} 236``` 237 238Without it every recipient is told the same thing, which is correct but 239gains nothing over SMTP. A message the handler rejected outright is reported 240as that rejection for each recipient, since it failed for all of them. LMTP 241is meant for the hop between a queueing MTA and whatever writes to mailboxes; 242RFC 2033 §5 forbids it on TCP port 25 and advises against wide-area use. 243 244DSN ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)) is 245advertised. `RET=` and `ENVID=` on MAIL arrive as `Envelope.ret` and 246`Envelope.envid`, and `NOTIFY=` and `ORCPT=` on RCPT arrive as 247`Recipient.notify` and `Recipient.orcpt` — at the `rcptTo` callback, which 248receives the whole `Recipient`, and again on the `Envelope` afterwards. The 249xtext values are decoded, the length limits enforced, and a malformed value 250answered with 501. Like everything else handed to a callback, those slices 251live only for the duration of the call; keep what you need by copying it. 252 253To advertise and accept STARTTLS (TLS 1.3, via 254[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key 255pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` / 256`zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them: 257 258```zig 259var auth: zsmtp.tls.config.CertKeyPair = 260 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem"); 261defer auth.deinit(gpa); 262 263var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 264 .hostname = "mx.example.com", 265 .tls = .{ .io = io, .auth = &auth }, 266}); 267try session.run(gpa); 268``` 269 270On STARTTLS the session answers 220, performs the server handshake, swaps 271its transport to the encrypted connection, and resets state per RFC 3207 (the 272client must EHLO again). With `.mode = .implicit` the handshake instead runs 273before the greeting (SMTPS, port 465 style): 274 275```zig 276var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 277 .hostname = "mx.example.com", 278 .tls = .{ .io = io, .auth = &auth, .mode = .implicit }, 279}); 280``` 281 282## Demo CLI 283 284```sh 285zig build 286 287# Debug server that prints received messages to stdout 288# (with a cert/key pair it advertises and accepts STARTTLS): 289./zig-out/bin/zsmtp serve 2525 290./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525 291./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem --implicit-tls 2465 292 293# Send a message read from stdin: 294printf 'Subject: hi\r\n\r\nhello\r\n' | \ 295 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net 296 297# Same, over implicit TLS or STARTTLS (--insecure skips cert verification): 298zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 299zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 300 301# Speak LMTP (RFC 2033) instead of SMTP. The server reports one verdict per 302# recipient, and --fail-delivery makes one of them fail to show it: 303./zig-out/bin/zsmtp serve --lmtp --fail-delivery bad@example.net 2529 304printf 'Subject: hi\r\n\r\nhello\r\n' | \ 305 ./zig-out/bin/zsmtp send --lmtp 127.0.0.1 2529 me@example.com \ 306 good@example.net bad@example.net 307 308# Request a delivery status notification (RFC 3461): 309zsmtp send --ret hdrs --envid 'batch 7' --notify success,failure \ 310 --orcpt team@example.net 127.0.0.1 2525 me@example.com you@example.net 311 312# Authenticate. Over a plaintext connection this refuses PLAIN and LOGIN 313# rather than put the password on the wire; --allow-cleartext-auth overrides 314# that for a connection protected by other means: 315zsmtp send --starttls --user me --password secret smtp.example.com 587 \ 316 me@example.com you@example.net 317``` 318 319## Status 320 321TLS is supported on both sides via 322[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 323TLS and STARTTLS via `zsmtp.Tls`, and the server accepts both STARTTLS and 324implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the 325client and PLAIN and LOGIN on the server. Message bodies can be streamed on 326both sides, and the server validates MAIL and RCPT parameters (SIZE=, BODY=, 327and the DSN set RET=, ENVID=, NOTIFY=, ORCPT=). Both sides also speak LMTP, 328where a message ends with one verdict per recipient rather than one for the 329message. 330 331## Known gaps 332 333Measured against the implementations people are likely to be coming from — 334Postfix, Exim and Haraka on the server side, Go's `net/smtp`, Python's 335`smtplib`, lettre and Nodemailer on the client side. Kept here so the list 336is one thing rather than a rediscovery each time. 337 338### Out of scope, not missing 339 340- **Message composition.** No MIME builder, headers, attachments, transfer 341 encodings, `Message-ID` or `Date` generation. zsmtp carries a message that 342 already exists; building one is RFC 5322's job and belongs in a library of 343 its own. 344- **DSN report generation** 345 ([RFC 3464](https://datatracker.ietf.org/doc/html/rfc3464)). The SMTP half 346 of DSN — RFC 3461's `RET`, `ENVID`, `NOTIFY` and `ORCPT` — is implemented 347 on both sides, but nothing here builds the `multipart/report` message that 348 carries a delivery status back to the sender. That is message composition 349 by another name, so it goes with the library above. 350- **Everything an MTA does around a session.** No queue, no retry schedule, 351 no MX resolution, no routing, no mailbox store. "Server" here means a 352 session handler: listening, accepting and concurrency are the caller's. 353 354### Protocol 355 356- **PIPELINING** ([RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920)) 357 — advertised and parsed by both sides, used by neither. `sendMail` is 358 strictly request-response. 359- **BINARYMIME** — CHUNKING is implemented but `BODY=BINARYMIME` is refused, 360 which is the other half of 361 [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030). 362- **Modern SASL** — no XOAUTH2 or OAUTHBEARER 363 ([RFC 7628](https://datatracker.ietf.org/doc/html/rfc7628)), which is what 364 Gmail and Microsoft 365 now require; no SCRAM-SHA-256 365 ([RFC 7677](https://datatracker.ietf.org/doc/html/rfc7677)), no EXTERNAL, 366 no `AUTH=` on MAIL FROM. CRAM-MD5 is the most modern mechanism present. 367- **Client certificates** — neither side can present or verify one. 368- **No enhanced status code accessor** — the server emits `x.y.z` on every 369 reply, but `Reply` exposes only `code` and the raw text. 370- `EXPN` is unrecognized rather than unimplemented, so it answers 500 where 371 [RFC 5321 §4.2.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.4) 372 wants 502. 373- Niche and absent: REQUIRETLS, MT-PRIORITY, DELIVERBY, FUTURERELEASE, ETRN. 374 375### Server 376 377- **No `Received:` header.** 378 [RFC 5321 §4.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.4) 379 requires a receiving server to stamp one. 380- **The handler never sees the connection** — no connect callback, no peer 381 address, no TLS state. Greylisting, DNSBLs, SPF and per-IP policy cannot 382 be built on top, and a `Received:` header cannot be written without it. 383- **No timeouts**, so a client that connects and says nothing holds the 384 session forever; 385 [RFC 5321 §4.5.3.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.2) 386 specifies per-command limits. 387- **No abuse limits** beyond `max_recipients`: unlimited failed AUTH 388 attempts, no error-count disconnect, no command budget. 389- **No `require_tls`** to go with `require_auth`. 390- **No PROXY protocol, XCLIENT or XFORWARD**, so the real peer address is 391 lost behind a load balancer. 392- No filter or milter hook, and so no DKIM, SPF, DMARC or ARC. 393- No logging or tracing hooks. 394- `max_message_size` is not enforced in `messageReader` mode. 395 396### Client 397 398- **`sendMail` is all-or-nothing on recipients** — the first rejected RCPT 399 aborts the transaction, where `smtplib.sendmail` reports the refused ones 400 and fails only when every one is refused. 401- **No `SIZE=` or `BODY=` on MAIL**, though the client parses both 402 capabilities off EHLO; `max_size` in particular is read and never used, so 403 nothing checks that a message fits before transmitting it. 404- No MX resolution or connect helper, no 4xx retry or backoff, no connection 405 reuse helper, no pipelined `sendMail`. 406 407## Standards 408 409- [RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321) — Simple Mail 410 Transfer Protocol: the command/reply protocol, multiline replies, 411 dot-stuffing, reply classes, and ESMTP parameter syntax (client and 412 server). 413- [RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870) — SIZE: 414 advertised and enforced by the server (oversize declarations are rejected 415 with 552 before DATA); parsed from EHLO by the client. 416- [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152) — 8BITMIME: 417 advertised by the server and `BODY=` validated; parsed by the client. 418- [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030) — CHUNKING 419 (BDAT): client and server, with length-based framing and no dot-stuffing; 420 the companion BINARYMIME extension is not implemented (`BODY=BINARYMIME` 421 is rejected). 422- [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — DSN: 423 advertised by the server, which parses and validates `RET=`/`ENVID=` on 424 MAIL and `NOTIFY=`/`ORCPT=` on RCPT and hands them to the handler; the 425 client sends them through `mail`/`rcpt`. Includes the xtext codec of §4. 426 Generating the report message itself (RFC 3464) is out of scope. 427- [RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033) — LMTP: client 428 and server, via `Client.mode` and `Server.Options.protocol`. `LHLO` 429 replaces `EHLO` and the end of a message draws one reply per accepted 430 recipient instead of one for the message, after DATA and after `BDAT 431 LAST` alike. 432- [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING: 433 advertised by the server, whose strictly sequential command loop handles 434 pipelined clients naturally; parsed by the client. 435- [RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207) — STARTTLS: 436 client and server, including the mandatory post-handshake state reset. 437- [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314) — implicit TLS 438 (SMTPS): client (`Tls` before any SMTP traffic) and server 439 (`.mode = .implicit`). 440- [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client 441 and server, including initial responses and `*` cancellation. 442- [RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616) — the PLAIN 443 SASL mechanism (client and server). 444- [RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195) — CRAM-MD5 445 (client only; the server would need plaintext-equivalent credentials). 446- [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 447 — the de-facto AUTH LOGIN mechanism (client and server). 448- [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) / 449 [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced 450 status codes: carried in every server reply and advertised via 451 ENHANCEDSTATUSCODES; detected by the client. 452- [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) — SMTPUTF8: 453 client (`mailFromUtf8`) and server (advertised; non-ASCII addresses 454 require the parameter and must be valid UTF-8, rejected with 553 5.6.7 455 per [RFC 6533](https://datatracker.ietf.org/doc/html/rfc6533) otherwise; 456 the flag reaches handlers via `Envelope.smtputf8`). 457 458TLS itself (TLS 1.3, [RFC 8446](https://datatracker.ietf.org/doc/html/rfc8446)) 459is provided by [ianic/tls.zig](https://github.com/ianic/tls.zig). 460 461## References cited 462 463The specifications this implementation was written against, and the outside 464work it borrows from, in the RFC citation format so that a reference here 465matches one anywhere else. The **Standards** section above says what is 466implemented of each; this one says what each document *is*. 467 468- **[RFC1870]** Klensin, J., Freed, N., and K. Moore, "SMTP Service 469 Extension for Message Size Declaration", RFC 1870, November 1995, 470 <https://www.rfc-editor.org/info/rfc1870>. 471- **[RFC2033]** Myers, J., "Local Mail Transfer Protocol", RFC 2033, 472 October 1996, <https://www.rfc-editor.org/info/rfc2033>. 473- **[RFC2034]** Freed, N., "SMTP Service Extension for Returning Enhanced 474 Error Codes", RFC 2034, October 1996, 475 <https://www.rfc-editor.org/info/rfc2034>. 476- **[RFC2195]** Klensin, J., Catoe, R., and P. Krumviede, "IMAP/POP 477 AUTHorize Extension for Simple Challenge/Response", RFC 2195, 478 September 1997, <https://www.rfc-editor.org/info/rfc2195>. 479- **[RFC2920]** Freed, N., "SMTP Service Extension for Command Pipelining", 480 RFC 2920, September 2000, <https://www.rfc-editor.org/info/rfc2920>. 481- **[RFC3030]** Vaudreuil, G., "SMTP Service Extensions for Transmission of 482 Large and Binary MIME Messages", RFC 3030, December 2000, 483 <https://www.rfc-editor.org/info/rfc3030>. 484- **[RFC3207]** Hoffman, P., "SMTP Service Extension for Secure SMTP over 485 Transport Layer Security", RFC 3207, February 2002, 486 <https://www.rfc-editor.org/info/rfc3207>. 487- **[RFC3461]** Moore, K., "Simple Mail Transfer Protocol (SMTP) Service 488 Extension for Delivery Status Notifications (DSNs)", RFC 3461, 489 January 2003, <https://www.rfc-editor.org/info/rfc3461>. 490- **[RFC3463]** Vaudreuil, G., "Enhanced Mail System Status Codes", 491 RFC 3463, January 2003, <https://www.rfc-editor.org/info/rfc3463>. 492- **[RFC3464]** Moore, K. and G. Vaudreuil, "An Extensible Message Format 493 for Delivery Status Notifications", RFC 3464, January 2003, 494 <https://www.rfc-editor.org/info/rfc3464>. *(Cited as out of scope: the 495 report message itself.)* 496- **[RFC4616]** Zeilenga, K., "The PLAIN Simple Authentication and Security 497 Layer (SASL) Mechanism", RFC 4616, August 2006, 498 <https://www.rfc-editor.org/info/rfc4616>. 499- **[RFC4954]** Siemborski, R. and A. Melnikov, "SMTP Service Extension for 500 Authentication", RFC 4954, July 2007, 501 <https://www.rfc-editor.org/info/rfc4954>. 502- **[RFC5321]** Klensin, J., "Simple Mail Transfer Protocol", RFC 5321, 503 October 2008, <https://www.rfc-editor.org/info/rfc5321>. 504- **[RFC5322]** Resnick, P., Ed., "Internet Message Format", RFC 5322, 505 October 2008, <https://www.rfc-editor.org/info/rfc5322>. *(Cited as out 506 of scope: the format of the message this library carries.)* 507- **[RFC6152]** Klensin, J., Freed, N., Rose, M., and D. Crocker, "SMTP 508 Service Extension for 8-bit MIME Transport", RFC 6152, March 2011, 509 <https://www.rfc-editor.org/info/rfc6152>. 510- **[RFC6531]** Yao, J. and W. Mao, "SMTP Extension for Internationalized 511 Email", RFC 6531, February 2012, 512 <https://www.rfc-editor.org/info/rfc6531>. 513- **[RFC6533]** Hansen, T., Ed., Newman, C., and A. Melnikov, 514 "Internationalized Delivery Status and Disposition Notifications", 515 RFC 6533, February 2012, <https://www.rfc-editor.org/info/rfc6533>. 516- **[RFC7628]** Mills, W., Showalter, T., and H. Tschofenig, "A Set of 517 Simple Authentication and Security Layer (SASL) Mechanisms for OAuth", 518 RFC 7628, August 2015, <https://www.rfc-editor.org/info/rfc7628>. 519 *(Cited as a gap.)* 520- **[RFC7677]** Hansen, T., "SCRAM-SHA-256 and SCRAM-SHA-256-PLUS Simple 521 Authentication and Security Layer (SASL) Mechanisms", RFC 7677, 522 November 2015, <https://www.rfc-editor.org/info/rfc7677>. *(Cited as a 523 gap.)* 524- **[RFC8314]** Moore, K. and C. Newman, "Cleartext Considered Obsolete: 525 Use of Transport Layer Security (TLS) for Email Submission and Access", 526 RFC 8314, January 2018, <https://www.rfc-editor.org/info/rfc8314>. 527- **[RFC8446]** Rescorla, E., "The Transport Layer Security (TLS) Protocol 528 Version 1.3", RFC 8446, August 2018, 529 <https://www.rfc-editor.org/info/rfc8446>. 530- **[SASL-LOGIN]** Murchison, K. and M. Crispin, "The LOGIN SASL 531 Mechanism", Work in Progress, Internet-Draft, 532 draft-murchison-sasl-login-00, August 2003, 533 <https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00>. 534 The draft expired and LOGIN was never standardized; it is implemented 535 here because servers still ask for it. 536- **[TLS.ZIG]** Ianic, "tls.zig — TLS 1.2/1.3 implementation in Zig", 537 <https://github.com/ianic/tls.zig>. Provides the TLS on both sides; see 538 the **TLS** section for why the standard library's client is not used. 539- **[ISEMAIL]** Sayers, D., "is_email — an email address validator and its 540 test suite", BSD-3-Clause, <https://github.com/dominicsayers/isemail>. 541 The address corpus the path parser is checked against; see **Tests**. 542- **[EXIM]** The Exim Maintainers, "Exim Internet Mailer", 543 GPL-2.0-or-later, <https://www.exim.org/>. The protocol torture script 544 and the gauntlet unit test's dialogue are adapted from its test suite. 545 546## Tests 547 548```sh 549zig build test 550zig build test --fuzz # run the fuzz tests under the fuzzer (endless) 551``` 552 553The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`), 554whole-session robustness against arbitrary bytes on both the client and 555server side, and two differential properties: the streaming `DataWriter` 556must produce byte-identical output to the slice-based `writeStuffed` under 557fuzzer-chosen chunk boundaries, and the collecting and streaming server 558DATA paths must yield identical message content. 559 560### Protocol torture testing with exim's test client 561 562Exim's scriptable SMTP test client (`test/src/client.c` in the exim 563source) sends raw protocol lines and asserts reply prefixes. The exim 564source is declared as a *lazy* Zig dependency, fetched only on demand: 565 566```sh 567zig build -Dexim-client # fetches exim, installs zig-out/bin/exim-client 568./zig-out/bin/zsmtp serve 2525 & 569./zig-out/bin/exim-client 127.0.0.1 2525 < test/protocol-torture.script 570``` 571 572### Address corpus testing with the is_email suite 573 574Dominic Sayers' [is_email](https://github.com/dominicsayers/isemail) test 575suite (BSD-3-Clause) is declared as a *lazy* Zig dependency; nothing from 576it is copied into this repository. On demand, the corpus test embeds its 577XML test files, extracts the 125 addresses valid at the RFC 5321 layer, 578and checks that each passes through the path parser byte-for-byte: 579 580```sh 581zig build test -Disemail-corpus # fetches the suite and runs the corpus test 582``` 583 584Without the option the corpus test is skipped. 585 586`test/protocol-torture.script` is a 28-reply dialogue distilled from 587exim's own test suite (syntax errors, sequencing violations, parameter 588validation, dot-stuffing); the same dialogue is asserted byte-for-byte 589as a unit test in `Server.zig`. 590 591The library is MIT-licensed; the small amount of test-only material adapted 592from exim's test suite (the torture script and the gauntlet unit test's 593dialogue) is GPL-2.0-or-later, marked with SPDX snippet tags and REUSE.toml 594annotations. 595 596Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled 597test runner fails to compile in fuzz mode, and the coverage server panics 598on a test binary with no fuzz tests); both are fixed on Zig master. Until 599then, fuzzing needs a patched copy of the standard library via 600`zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests 601themselves also run once per invocation as part of the normal 602`zig build test` suite. 603 604Interoperability against third-party implementations is covered by a NixOS 605VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix 606and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks 607delivers to the zsmtp server over plaintext and STARTTLS. 608 609```sh 610nix build .#zsmtp # build the package 611nix build .#checks.x86_64-linux.interop # run the VM interop test 612```