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