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