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