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