An SMTP client and server library for Zig implementing RFC 5321.
38 kB
782 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
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.
357Messages may also arrive via BDAT chunks (CHUNKING is advertised); both
358the collecting and streaming handler paths receive the reassembled content.
359MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552
360when it exceeds `max_message_size`, `BODY=7BIT`/`BODY=8BITMIME` (RFC 6152)
361are accepted, and unrecognized parameters get 555; the declared size and
362body type reach the handler via `Envelope`. Listening, accepting, and
363concurrency are up to the caller.
364
365The server holds back the replies that RFC 2920 §3.2 permits — RSET, MAIL
366FROM and RCPT TO — so that a pipelined group is answered in one write, and
367sends everything pending the moment its input is empty. The condition is what
368makes that safe rather than a deadlock: a reply is only ever held while there
369is another command already waiting to be answered.
370
371Setting `Options.protocol = .lmtp` makes the session speak LMTP
372([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)) instead: `LHLO`
373greets and `HELO`/`EHLO` are refused with 500, and the end of a message
374draws one reply per accepted recipient rather than one for the message —
375including a second reply for a recipient named twice. The `recipientResult`
376callback supplies each verdict:
377
378```zig
379fn onRecipientResult(ctx: ?*anyopaque, envelope: smtp.Server.Envelope, index: usize) smtp.Server.Decision {
380 return if (mailboxIsFull(envelope.recipients[index].address))
381 .{ .reject = .{ .code = 452, .text = "4.2.2 Mailbox full" } }
382 else
383 .accept;
384}
385```
386
387Without it every recipient is told the same thing, which is correct but
388gains nothing over SMTP. A message the handler rejected outright is reported
389as that rejection for each recipient, since it failed for all of them. LMTP
390is meant for the hop between a queueing MTA and whatever writes to mailboxes;
391RFC 2033 §5 forbids it on TCP port 25 and advises against wide-area use.
392
393BINARYMIME ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) is
394advertised alongside CHUNKING, which the RFC requires of anything offering
395it. `BODY=BINARYMIME` arrives as `Envelope.body`, DATA for such a message is
396refused with 503, and the content reaches the handler exactly as it was
397sent — the BDAT path copies octets and has no line structure to normalize.
398
399DSN ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)) is
400advertised. `RET=` and `ENVID=` on MAIL arrive as `Envelope.ret` and
401`Envelope.envid`, and `NOTIFY=` and `ORCPT=` on RCPT arrive as
402`Recipient.notify` and `Recipient.orcpt` — at the `rcptTo` callback, which
403receives the whole `Recipient`, and again on the `Envelope` afterwards. The
404xtext values are decoded, the length limits enforced, and a malformed value
405answered with 501. Like everything else handed to a callback, those slices
406live only for the duration of the call; keep what you need by copying it.
407
408To advertise and accept STARTTLS (TLS 1.3, via
409[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key
410pair; the stream buffers must then be at least `smtp.tls.input_buffer_len` /
411`smtp.tls.output_buffer_len` bytes, since the handshake runs over them:
412
413```zig
414var auth: smtp.tls.config.CertKeyPair =
415 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem");
416defer auth.deinit(gpa);
417
418var session: smtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{
419 .hostname = "mx.example.com",
420 .tls = .{ .io = io, .auth = &auth },
421});
422try session.run(gpa);
423```
424
425On STARTTLS the session answers 220, performs the server handshake, swaps
426its transport to the encrypted connection, and resets state per RFC 3207 (the
427client must EHLO again). With `.mode = .implicit` the handshake instead runs
428before the greeting (SMTPS, port 465 style):
429
430```zig
431var session: smtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{
432 .hostname = "mx.example.com",
433 .tls = .{ .io = io, .auth = &auth, .mode = .implicit },
434});
435```
436
437## Demo CLI
438
439```sh
440zig build
441
442# Debug server that prints received messages to stdout
443# (with a cert/key pair it advertises and accepts STARTTLS):
444./zig-out/bin/zig-smtp serve 2525
445./zig-out/bin/zig-smtp serve --tls-cert cert.pem --tls-key key.pem 2525
446./zig-out/bin/zig-smtp serve --tls-cert cert.pem --tls-key key.pem --implicit-tls 2465
447
448# Send a message read from stdin:
449printf 'Subject: hi\r\n\r\nhello\r\n' | \
450 ./zig-out/bin/zig-smtp send 127.0.0.1 2525 me@example.com you@example.net
451
452# Same, over implicit TLS or STARTTLS (--insecure skips cert verification):
453zig-smtp send --tls smtp.example.com 465 me@example.com you@example.net
454zig-smtp send --starttls smtp.example.com 587 me@example.com you@example.net
455
456# Send arbitrary binary content (RFC 3030), framed by BDAT rather than DATA:
457./zig-out/bin/zig-smtp send --binarymime 127.0.0.1 2525 me@example.com you@example.net \
458 < some-binary-file
459
460# Speak LMTP (RFC 2033) instead of SMTP. The server reports one verdict per
461# recipient, and --fail-delivery makes one of them fail to show it:
462./zig-out/bin/zig-smtp serve --lmtp --fail-delivery bad@example.net 2529
463printf 'Subject: hi\r\n\r\nhello\r\n' | \
464 ./zig-out/bin/zig-smtp send --lmtp 127.0.0.1 2529 me@example.com \
465 good@example.net bad@example.net
466
467# Request a delivery status notification (RFC 3461):
468zig-smtp send --ret hdrs --envid 'batch 7' --notify success,failure \
469 --orcpt team@example.net 127.0.0.1 2525 me@example.com you@example.net
470
471# Authenticate. Over a plaintext connection this refuses PLAIN and LOGIN
472# rather than put the password on the wire; --allow-cleartext-auth overrides
473# that for a connection protected by other means:
474zig-smtp send --starttls --user me --password secret smtp.example.com 587 \
475 me@example.com you@example.net
476```
477
478## Status
479
480TLS is supported on both sides via
481[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit
482TLS and STARTTLS via `smtp.Tls`, and the server accepts both STARTTLS and
483implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the
484client and PLAIN and LOGIN on the server. Message bodies can be streamed on
485both sides, and the server validates MAIL and RCPT parameters (SIZE=, BODY=,
486and the DSN set RET=, ENVID=, NOTIFY=, ORCPT=). Both sides also speak LMTP,
487where a message ends with one verdict per recipient rather than one for the
488message, and both use PIPELINING, which collapses an envelope into a single
489round trip.
490
491## Known gaps
492
493Measured against the implementations people are likely to be coming from —
494Postfix, Exim and Haraka on the server side, Go's `net/smtp`, Python's
495`smtplib`, lettre and Nodemailer on the client side. Kept here so the list
496is one thing rather than a rediscovery each time.
497
498### Out of scope, not missing
499
500- **Message composition.** No MIME builder, headers, attachments, transfer
501 encodings, `Message-ID` or `Date` generation. zig-smtp carries a message that
502 already exists; building one is RFC 5322's job and belongs in a library of
503 its own.
504- **DSN report generation**
505 ([RFC 3464](https://datatracker.ietf.org/doc/html/rfc3464)). The SMTP half
506 of DSN — RFC 3461's `RET`, `ENVID`, `NOTIFY` and `ORCPT` — is implemented
507 on both sides, but nothing here builds the `multipart/report` message that
508 carries a delivery status back to the sender. That is message composition
509 by another name, so it goes with the library above.
510- **Everything an MTA does around a session.** No queue, no retry schedule,
511 no MX resolution, no routing, no mailbox store. "Server" here means a
512 session handler: listening, accepting and concurrency are the caller's.
513
514### Protocol
515
516- **Client certificates** — neither side can present or verify one.
517- `EXPN` is unrecognized rather than unimplemented, so it answers 500 where
518 [RFC 5321 §4.2.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.4)
519 wants 502.
520- Niche and absent: REQUIRETLS, MT-PRIORITY, DELIVERBY, FUTURERELEASE, ETRN.
521
522### Server
523
524- **No `Received:` header.**
525 [RFC 5321 §4.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.4)
526 requires a receiving server to stamp one.
527- **The handler sees the identity but not the connection.**
528 `Envelope.authenticated_as` and `Server.identity()` say who authenticated;
529 nothing says where from. No connect callback, no peer address, no TLS
530 state — so greylisting, DNSBLs, SPF and per-IP policy cannot be built on
531 top, and a `Received:` header cannot be written without it.
532- **No timeouts**, so a client that connects and says nothing holds the
533 session forever;
534 [RFC 5321 §4.5.3.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.2)
535 specifies per-command limits. This matters more since LMTP arrived: an
536 LMTP server is what a queueing MTA hands mail to, so it is likelier to be
537 somewhere a stuck peer costs something.
538- **No abuse limits** beyond `max_recipients`: no error-count disconnect, no
539 command budget, and no cap on failed AUTH attempts — which also matters
540 more now, since a session may offer several mechanisms and a client can
541 try each in turn without limit.
542- **No `require_tls`** to go with `require_auth`.
543- **No PROXY protocol, XCLIENT or XFORWARD**, so the real peer address is
544 lost behind a load balancer.
545- No filter or milter hook, and so no DKIM, SPF, DMARC or ARC.
546- No logging or tracing hooks.
547- `max_message_size` is not enforced in `messageReader` mode.
548
549### Client
550
551- **`sendMail` is all-or-nothing on recipients** — a refused RCPT abandons
552 the transaction, where `smtplib.sendmail` delivers to the rest and reports
553 the refusals. `envelope` gives a caller the per-recipient codes to decide
554 for itself, but no higher-level call does that decision for it.
555- **No `SIZE=` on MAIL**, though the client parses the capability off EHLO:
556 `max_size` is read and never used, so nothing checks that a message fits
557 before transmitting it.
558- No MX resolution or connect helper, no 4xx retry or backoff, no connection
559 reuse helper.
560- **`Extensions.auth` is the one field that borrows.** It points into the
561 client's reply buffer and is valid only until the next reply is read, which
562 is long enough for the `hello`-then-`authenticate` sequence and no longer.
563 Everything else on `Extensions` is self-contained, so a caller storing one
564 across commands gets a dangling slice with no compiler help. zig-pop3
565 answered the same question the other way, with a bounded copy, because its
566 `capabilities()` promises nothing borrows the read buffer — the two
567 libraries disagree about this on purpose, and one of them should probably
568 give way.
569
570## Standards
571
572- [RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321) — Simple Mail
573 Transfer Protocol: the command/reply protocol, multiline replies,
574 dot-stuffing, reply classes, and ESMTP parameter syntax (client and
575 server).
576- [RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870) — SIZE:
577 advertised and enforced by the server (oversize declarations are rejected
578 with 552 before DATA); parsed from EHLO by the client.
579- [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152) — 8BITMIME:
580 advertised by the server and `BODY=` validated; parsed by the client.
581- [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030) — CHUNKING
582 (BDAT) and BINARYMIME: client and server, with length-based framing and no
583 dot-stuffing. `BODY=BINARYMIME` is advertised, accepted and delivered bit
584 for bit, and DATA is refused with 503 for a message that declared it,
585 since binary content cannot be framed by a line holding a single dot.
586- [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — DSN:
587 advertised by the server, which parses and validates `RET=`/`ENVID=` on
588 MAIL and `NOTIFY=`/`ORCPT=` on RCPT and hands them to the handler; the
589 client sends them through `mail`/`rcpt`. Includes the xtext codec of §4.
590 Generating the report message itself (RFC 3464) is out of scope.
591- [RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033) — LMTP: client
592 and server, via `Client.mode` and `Server.Options.protocol`. `LHLO`
593 replaces `EHLO` and the end of a message draws one reply per accepted
594 recipient instead of one for the message, after DATA and after `BDAT
595 LAST` alike.
596- [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING:
597 the client sends a whole envelope as one group through `envelope`, and the
598 server holds back the replies it is allowed to (RSET, MAIL, RCPT) so they
599 leave together, sending everything pending the moment its input runs dry.
600- [RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207) — STARTTLS:
601 client and server, including the mandatory post-handshake state reset.
602- [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314) — implicit TLS
603 (SMTPS): client (`Tls` before any SMTP traffic) and server
604 (`.mode = .implicit`).
605- [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client
606 and server, including initial responses, empty challenges, `*`
607 cancellation, and §5's `AUTH=` parameter to MAIL FROM — which the server
608 takes from an unauthenticated client and disregards, as §5 requires. The client drives any mechanism from
609 [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl), and the server offers
610 whichever of their server halves it is handed — PLAIN
611 ([RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616)), the de-facto
612 [LOGIN](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00),
613 CRAM-MD5 and EXTERNAL among them.
614- [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) /
615 [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced
616 status codes: advertised and attached to every reply RFC 2034 asks for,
617 with a test that walks a whole session and checks each one against that
618 rule; read back by the client through `Reply.enhanced`.
619- [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) — SMTPUTF8:
620 client (`mailFromUtf8`) and server (advertised; non-ASCII addresses
621 require the parameter and must be valid UTF-8, rejected with 553 5.6.7
622 per [RFC 6533](https://datatracker.ietf.org/doc/html/rfc6533) otherwise;
623 the flag reaches handlers via `Envelope.smtputf8`).
624
625TLS itself (TLS 1.3, [RFC 8446](https://datatracker.ietf.org/doc/html/rfc8446))
626is provided by [ianic/tls.zig](https://github.com/ianic/tls.zig).
627
628## References cited
629
630The specifications this implementation was written against, and the outside
631work it borrows from, in the RFC citation format so that a reference here
632matches one anywhere else. The **Standards** section above says what is
633implemented of each; this one says what each document *is*. Every entry is
634also filed in the project bibliography, so a citation can be taken from there
635rather than composed; the RFCs are keyed by their DOIs (`10.17487/RFC5321`
636and so on).
637
638- **[RFC1870]** Klensin, J., Freed, N., and K. Moore, "SMTP Service
639 Extension for Message Size Declaration", RFC 1870, November 1995,
640 <https://www.rfc-editor.org/info/rfc1870>.
641- **[RFC2033]** Myers, J., "Local Mail Transfer Protocol", RFC 2033,
642 October 1996, <https://www.rfc-editor.org/info/rfc2033>.
643- **[RFC2034]** Freed, N., "SMTP Service Extension for Returning Enhanced
644 Error Codes", RFC 2034, October 1996,
645 <https://www.rfc-editor.org/info/rfc2034>.
646- **[RFC2195]** Klensin, J., Catoe, R., and P. Krumviede, "IMAP/POP
647 AUTHorize Extension for Simple Challenge/Response", RFC 2195,
648 September 1997, <https://www.rfc-editor.org/info/rfc2195>.
649- **[RFC2920]** Freed, N., "SMTP Service Extension for Command Pipelining",
650 RFC 2920, September 2000, <https://www.rfc-editor.org/info/rfc2920>.
651- **[RFC3030]** Vaudreuil, G., "SMTP Service Extensions for Transmission of
652 Large and Binary MIME Messages", RFC 3030, December 2000,
653 <https://www.rfc-editor.org/info/rfc3030>.
654- **[RFC3207]** Hoffman, P., "SMTP Service Extension for Secure SMTP over
655 Transport Layer Security", RFC 3207, February 2002,
656 <https://www.rfc-editor.org/info/rfc3207>.
657- **[RFC3461]** Moore, K., "Simple Mail Transfer Protocol (SMTP) Service
658 Extension for Delivery Status Notifications (DSNs)", RFC 3461,
659 January 2003, <https://www.rfc-editor.org/info/rfc3461>.
660- **[RFC3463]** Vaudreuil, G., "Enhanced Mail System Status Codes",
661 RFC 3463, January 2003, <https://www.rfc-editor.org/info/rfc3463>.
662- **[RFC3464]** Moore, K. and G. Vaudreuil, "An Extensible Message Format
663 for Delivery Status Notifications", RFC 3464, January 2003,
664 <https://www.rfc-editor.org/info/rfc3464>. *(Cited as out of scope: the
665 report message itself.)*
666- **[RFC4616]** Zeilenga, K., "The PLAIN Simple Authentication and Security
667 Layer (SASL) Mechanism", RFC 4616, August 2006,
668 <https://www.rfc-editor.org/info/rfc4616>.
669- **[RFC4954]** Siemborski, R. and A. Melnikov, "SMTP Service Extension for
670 Authentication", RFC 4954, July 2007,
671 <https://www.rfc-editor.org/info/rfc4954>.
672- **[RFC5321]** Klensin, J., "Simple Mail Transfer Protocol", RFC 5321,
673 October 2008, <https://www.rfc-editor.org/info/rfc5321>.
674- **[RFC5322]** Resnick, P., Ed., "Internet Message Format", RFC 5322,
675 October 2008, <https://www.rfc-editor.org/info/rfc5322>. *(Cited as out
676 of scope: the format of the message this library carries.)*
677- **[RFC6152]** Klensin, J., Freed, N., Rose, M., and D. Crocker, "SMTP
678 Service Extension for 8-bit MIME Transport", RFC 6152, March 2011,
679 <https://www.rfc-editor.org/info/rfc6152>.
680- **[RFC6531]** Yao, J. and W. Mao, "SMTP Extension for Internationalized
681 Email", RFC 6531, February 2012,
682 <https://www.rfc-editor.org/info/rfc6531>.
683- **[RFC6533]** Hansen, T., Ed., Newman, C., and A. Melnikov,
684 "Internationalized Delivery Status and Disposition Notifications",
685 RFC 6533, February 2012, <https://www.rfc-editor.org/info/rfc6533>.
686- **[RFC7628]** Mills, W., Showalter, T., and H. Tschofenig, "A Set of
687 Simple Authentication and Security Layer (SASL) Mechanisms for OAuth",
688 RFC 7628, August 2015, <https://www.rfc-editor.org/info/rfc7628>.
689 *(Cited as a gap.)*
690- **[RFC7677]** Hansen, T., "SCRAM-SHA-256 and SCRAM-SHA-256-PLUS Simple
691 Authentication and Security Layer (SASL) Mechanisms", RFC 7677,
692 November 2015, <https://www.rfc-editor.org/info/rfc7677>. *(Cited as a
693 gap.)*
694- **[RFC8314]** Moore, K. and C. Newman, "Cleartext Considered Obsolete:
695 Use of Transport Layer Security (TLS) for Email Submission and Access",
696 RFC 8314, January 2018, <https://www.rfc-editor.org/info/rfc8314>.
697- **[RFC8446]** Rescorla, E., "The Transport Layer Security (TLS) Protocol
698 Version 1.3", RFC 8446, August 2018,
699 <https://www.rfc-editor.org/info/rfc8446>.
700- **[SASL-LOGIN]** Murchison, K. and M. Crispin, "The LOGIN SASL
701 Mechanism", Work in Progress, Internet-Draft,
702 draft-murchison-sasl-login-00, August 2003,
703 <https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00>.
704 The draft expired and LOGIN was never standardized; it is implemented
705 here because servers still ask for it.
706- **[TLS.ZIG]** Ianic, "tls.zig — TLS 1.2/1.3 implementation in Zig",
707 <https://github.com/ianic/tls.zig>. Provides the TLS on both sides; see
708 the **TLS** section for why the standard library's client is not used.
709- **[ISEMAIL]** Sayers, D., "is_email — an email address validator and its
710 test suite", BSD-3-Clause, <https://github.com/dominicsayers/isemail>.
711 The address corpus the path parser is checked against; see **Tests**.
712- **[EXIM]** The Exim Maintainers, "Exim Internet Mailer",
713 GPL-2.0-or-later, <https://www.exim.org/>. The protocol torture script
714 and the gauntlet unit test's dialogue are adapted from its test suite.
715
716## Tests
717
718```sh
719zig build test
720zig build test --fuzz # run the fuzz tests under the fuzzer (endless)
721```
722
723The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`),
724whole-session robustness against arbitrary bytes on both the client and
725server side, and two differential properties: the streaming `DataWriter`
726must produce byte-identical output to the slice-based `writeStuffed` under
727fuzzer-chosen chunk boundaries, and the collecting and streaming server
728DATA paths must yield identical message content.
729
730### Protocol torture testing with exim's test client
731
732Exim's scriptable SMTP test client (`test/src/client.c` in the exim
733source) sends raw protocol lines and asserts reply prefixes. The exim
734source is declared as a *lazy* Zig dependency, fetched only on demand:
735
736```sh
737zig build -Dexim-client # fetches exim, installs zig-out/bin/exim-client
738./zig-out/bin/zig-smtp serve 2525 &
739./zig-out/bin/exim-client 127.0.0.1 2525 < test/protocol-torture.script
740```
741
742### Address corpus testing with the is_email suite
743
744Dominic Sayers' [is_email](https://github.com/dominicsayers/isemail) test
745suite (BSD-3-Clause) is declared as a *lazy* Zig dependency; nothing from
746it is copied into this repository. On demand, the corpus test embeds its
747XML test files, extracts the 125 addresses valid at the RFC 5321 layer,
748and checks that each passes through the path parser byte-for-byte:
749
750```sh
751zig build test -Disemail-corpus # fetches the suite and runs the corpus test
752```
753
754Without the option the corpus test is skipped.
755
756`test/protocol-torture.script` is a 28-reply dialogue distilled from
757exim's own test suite (syntax errors, sequencing violations, parameter
758validation, dot-stuffing); the same dialogue is asserted byte-for-byte
759as a unit test in `Server.zig`.
760
761The library is MIT-licensed; the small amount of test-only material adapted
762from exim's test suite (the torture script and the gauntlet unit test's
763dialogue) is GPL-2.0-or-later, marked with SPDX snippet tags and REUSE.toml
764annotations.
765
766Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled
767test runner fails to compile in fuzz mode, and the coverage server panics
768on a test binary with no fuzz tests); both are fixed on Zig master. Until
769then, fuzzing needs a patched copy of the standard library via
770`zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests
771themselves also run once per invocation as part of the normal
772`zig build test` suite.
773
774Interoperability against third-party implementations is covered by a NixOS
775VM test (`nix/interop-test.nix`): the zig-smtp client delivers mail to Postfix
776and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks
777delivers to the zig-smtp server over plaintext and STARTTLS.
778
779```sh
780nix build .#zig-smtp # build the package
781nix build .#checks.x86_64-linux.interop # run the VM interop test
782```