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