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