An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

Select the types of activity you want to include in your feed.

Compose the Received: field for the handler to write

RFC 5321 §4.4 requires a receiving server to stamp a trace field, and this
one had no way to produce one: composing it needs a clock, the peer address
and the session's own state, and the session had the last of those only.

`Options.received` supplies the other two — an `Io` to read the clock from
and the peer description, which the caller has because it accepted the
connection and this library has not, since it is handed a reader and a
writer and never sees an address. With it set, every message arrives at the
handler with `Envelope.received` filled in: the complete field, `Received: `
prefix and trailing CRLF included.

The handler writes it. The library never touches the message bytes — it has
no idea whether they are being spooled, relayed or parsed — and the field
belongs at the beginning of the content, which only the handler can arrange.
Left unset, nothing is composed, and that is the caller's choice rather than
a default worth having.

The layout comes from zig-mime's `received` helper and the timestamp from
zig-datetime, pinned to the same revision zig-mime uses so the two do not
become two incompatible copies of one type. What goes in the field is read
off the session: the greeting name the client gave (escaped, since the peer
chose it), the RFC 3848 protocol name for whether the hop was encrypted,
authenticated, LMTP or SMTPUTF8, and a `for` clause only when there is
exactly one recipient — with more than one it would tell each about the
others.

The demo server writes it ahead of the body, which is what the live check
looked like:

Received: from localhost ([127.0.0.1:32946])
by localhost (zig-smtp) with ESMTP
for <b@example.net>; Sun, 13 Sep 2026 03:23:04 +0000

README: the gap is closed, the Status and Server sections say how it works,
RFC 3848 joins the standards list, and RFC 8689 — implemented but never
cited — joins the references. RFC 3848, RFC 8689, zig-mime and zig-datetime
are filed in the Zotero collection, which is now named for the project
rather than for what it used to be called.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC

+487 -21
+79 -14
README.md
··· 324 324 var session: smtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 325 325 .context = &my_state, 326 326 .vtable = &.{ 327 - .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN 328 - .rcptTo = onRcptTo, // optional; accept/reject each Recipient 329 - .message = onMessage, // required; receives envelope + message data 327 + .rcptTo = onRcptTo, // optional; accept/reject each Recipient 328 + .message = onMessage, // required; receives envelope + message data 330 329 }, 331 330 }, .{ .hostname = "mx.example.com" }); 332 331 try session.run(gpa); ··· 441 440 answered with 501. Like everything else handed to a callback, those slices 442 441 live only for the duration of the call; keep what you need by copying it. 443 442 443 + Setting `Options.received` has the session compose a `Received:` field for 444 + every 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 449 + var 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 + 460 + It arrives as `Envelope.received`, complete with its `Received: ` prefix and 461 + its 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) 463 + wants the field at the beginning of the content, so a handler puts it in 464 + front of whatever it does with the message: 465 + 466 + ```zig 467 + try file.writeAll(envelope.received); 468 + try file.writeAll(message); 469 + ``` 470 + 471 + The division is deliberate. Composing the field needs the clock, the peer and 472 + the session's own state, none of which the handler has; inserting it needs to 473 + know what is being done with the message, which the library does not — it 474 + hands over the bytes it received and transforms nothing. Left unset, 475 + `Envelope.received` is empty and nothing is composed, which is a choice the 476 + caller is making rather than a default worth having. 477 + 478 + What goes in it is decided from the session: the `from` name is what the 479 + client gave in its greeting (escaped if it has to be, since it is a string 480 + the peer chose), the `with` protocol follows 481 + [RFC 3848](https://datatracker.ietf.org/doc/html/rfc3848) — `ESMTP`, `ESMTPA` 482 + when the client authenticated, `ESMTPS` under TLS, `ESMTPSA` for both, the 483 + `LMTP` forms under `.lmtp` and the `UTF8` forms for a SMTPUTF8 transaction — 484 + and a `for` clause appears only when there is exactly one recipient, because 485 + with more than one it would disclose the others to all of them. 486 + 444 487 To advertise and accept STARTTLS (TLS 1.3, via 445 488 [ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key 446 489 pair; the stream buffers must then be at least `smtp.tls.input_buffer_len` / ··· 516 559 TLS is supported on both sides via 517 560 [ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 518 561 TLS and STARTTLS via `smtp.Tls`, and the server accepts both STARTTLS and 519 - implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the 520 - client and PLAIN and LOGIN on the server. Message bodies can be streamed on 521 - both sides, and the server validates MAIL and RCPT parameters (SIZE=, BODY=, 522 - and the DSN set RET=, ENVID=, NOTIFY=, ORCPT=). Both sides also speak LMTP, 523 - where a message ends with one verdict per recipient rather than one for the 524 - message, and both use PIPELINING, which collapses an envelope into a single 525 - round trip. 562 + implicit 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 564 + ones a session offers is the caller's choice rather than this library's. 565 + Message bodies can be streamed on both sides, and the server validates MAIL 566 + and RCPT parameters (SIZE=, BODY=, and the DSN set RET=, ENVID=, NOTIFY=, 567 + ORCPT=) and will compose the `Received:` field for the handler to write. 568 + Both sides also speak LMTP, where a message ends with one verdict per 569 + recipient rather than one for the message, and both use PIPELINING, which 570 + collapses an envelope into a single round trip. 526 571 527 572 ## Known gaps 528 573 ··· 569 614 570 615 ### Server 571 616 572 - - **No `Received:` header.** 573 - [RFC 5321 §4.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.4) 574 - requires a receiving server to stamp one. 575 617 - **The handler sees the identity but not the connection.** 576 618 `Envelope.authenticated_as` and `Server.identity()` say who authenticated; 577 619 nothing says where from. No connect callback, no peer address, no TLS 578 620 state — so greylisting, DNSBLs, SPF and per-IP policy cannot be built on 579 - top, and a `Received:` header cannot be written without it. 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. 580 624 - **No timeouts**, so a client that connects and says nothing holds the 581 625 session forever; 582 626 [RFC 5321 §4.5.3.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.2) ··· 621 665 Transfer Protocol: the command/reply protocol, multiline replies, 622 666 dot-stuffing, reply classes, and ESMTP parameter syntax (client and 623 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. 624 677 - [RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870) — SIZE: 625 678 advertised and enforced by the server (oversize declarations are rejected 626 679 with 552 before DATA); parsed from EHLO by the client. ··· 716 769 for Delivery Status Notifications", RFC 3464, January 2003, 717 770 <https://www.rfc-editor.org/info/rfc3464>. *(Cited as out of scope: the 718 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>. 719 775 - **[RFC4616]** Zeilenga, K., "The PLAIN Simple Authentication and Security 720 776 Layer (SASL) Mechanism", RFC 4616, August 2006, 721 777 <https://www.rfc-editor.org/info/rfc4616>. ··· 750 806 - **[RFC8446]** Rescorla, E., "The Transport Layer Security (TLS) Protocol 751 807 Version 1.3", RFC 8446, August 2018, 752 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>. 753 811 - **[SASL-LOGIN]** Murchison, K. and M. Crispin, "The LOGIN SASL 754 812 Mechanism", Work in Progress, Internet-Draft, 755 813 draft-murchison-sasl-login-00, August 2003, ··· 759 817 - **[TLS.ZIG]** Ianic, "tls.zig — TLS 1.2/1.3 implementation in Zig", 760 818 <https://github.com/ianic/tls.zig>. Provides the TLS on both sides; see 761 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. 762 827 - **[ISEMAIL]** Sayers, D., "is_email — an email address validator and its 763 828 test suite", BSD-3-Clause, <https://github.com/dominicsayers/isemail>. 764 829 The address corpus the path parser is checked against; see **Tests**.
+9
build.zig
··· 39 39 .optimize = optimize, 40 40 }); 41 41 42 + // The `Received:` field an RFC 5321 §4.4 server has to stamp: its 43 + // grammar, its folding, the escaping of the parts the client chose, and 44 + // the ESMTPSA-or-ESMTPS-or-ESMTPA question. Written once, in a library 45 + // that is about message syntax, rather than a second time here. 46 + const mime_dep = b.dependency("mime", .{ .target = target, .optimize = optimize }); 47 + const datetime_dep = b.dependency("datetime", .{ .target = target, .optimize = optimize }); 48 + 42 49 const tls_dep = b.dependency("tls", .{ 43 50 .target = target, 44 51 .optimize = optimize, ··· 58 65 .imports = &.{ 59 66 .{ .name = "tls", .module = tls_dep.module("tls") }, 60 67 .{ .name = "sasl", .module = sasl_dep.module("sasl") }, 68 + .{ .name = "mime", .module = mime_dep.module("mime") }, 69 + .{ .name = "datetime", .module = datetime_dep.module("datetime") }, 61 70 }, 62 71 }); 63 72
+8
build.zig.zon
··· 57 57 .url = "git+https://git.jcollie.dev/jeff/zig-sasl.git#c6452ffcb932c6cbf34651be2acd694cc18f2fd3", 58 58 .hash = "sasl-0.0.0-s3YcOPiJAQCy9rnR9LEZY_Zc_x0oK6yc1FUCD57I2jvc", 59 59 }, 60 + .mime = .{ 61 + .url = "git+https://git.jcollie.dev/jeff/zig-mime.git#70ea89ff4e2f1a89315ce0811376bc7103418f4d", 62 + .hash = "zig_mime-0.0.0-4saPF24RCwBOkIdo4hHfAYfUovszKIL7CXaXgB5gyGC4", 63 + }, 64 + .datetime = .{ 65 + .url = "git+https://git.jcollie.dev/jeff/zig-datetime.git#bd76e05460dba019f7749e44a37ea5afe6c49609", 66 + .hash = "datetime-0.0.1-6-va79gDDwCD7vUCwnd8YeSBQlXh5xq-yjnhzeT7VCYw", 67 + }, 60 68 }, 61 69 .paths = .{ 62 70 "build.zig",
+135
build.zig.zon.nix
··· 157 157 copyFarm name 158 158 [ 159 159 { 160 + name = "N-V-__8AAJ77GgCr4jV_q5d8vuaUZIWMrHbXUMYV7il4sgLB"; 161 + path = fetchZigArtifact { 162 + name = "cldr_core"; 163 + url = "https://registry.npmjs.org/cldr-core/-/cldr-core-48.2.0.tgz"; 164 + hash = "sha256-UxDgx6BsH+uD3I5UyFhLvgucLqgyAXKhjVQZhrEkWF0="; 165 + unpack = false; 166 + }; 167 + } 168 + { 169 + name = "N-V-__8AAGszqAU24FLBIkgdecxizqeniOtXvaJyJNhnerSV"; 170 + path = fetchZigArtifact { 171 + name = "cldr_dates"; 172 + url = "https://registry.npmjs.org/cldr-dates-full/-/cldr-dates-full-48.2.0.tgz"; 173 + hash = "sha256-Albxzv7KFPfVFb5Ict2kj83X51OBQw8lqv0BQ+rltDA="; 174 + unpack = false; 175 + }; 176 + } 177 + { 178 + name = "N-V-__8AAP5iTQJ7vhRS_dLVKhpakujqxJsIbu89VYJgXryk"; 179 + path = fetchZigArtifact { 180 + name = "cldr_numbers"; 181 + url = "https://registry.npmjs.org/cldr-numbers-full/-/cldr-numbers-full-48.2.0.tgz"; 182 + hash = "sha256-LRehRTxVmmIRLK7tUuC8/jy4U5yZ0jmue37U0IJ2edk="; 183 + unpack = false; 184 + }; 185 + } 186 + { 187 + name = "datetime-0.0.1-6-va79gDDwCD7vUCwnd8YeSBQlXh5xq-yjnhzeT7VCYw"; 188 + path = fetchZigArtifact { 189 + name = "datetime"; 190 + url = "git+https://git.jcollie.dev/jeff/zig-datetime.git#bd76e05460dba019f7749e44a37ea5afe6c49609"; 191 + hash = "sha256-hVLUAc9Mm5HjLSkUURlb4nW2sn78ZvrWKsaC1cdzCuE="; 192 + unpack = true; 193 + }; 194 + } 195 + { 196 + name = "dkim-0.0.0-Fy8_qXbjAwCVPGu8Lx3H__hjGGEkS8O8t_kAAvZytOfr"; 197 + path = fetchZigArtifact { 198 + name = "dkim"; 199 + url = "git+https://git.jcollie.dev/jeff/zig-dkim.git#0c185dce9f9c59707e146506d021d1da01401020"; 200 + hash = "sha256-xT7bBJNEZ1UScKHodZa4n5zuMz7A9FbIzl5rkMj6o6g="; 201 + unpack = true; 202 + }; 203 + } 204 + { 160 205 name = "N-V-__8AAFtMiwHRrbE2kejFyc1FTFciBTZJlJOPGt-eIVGJ"; 161 206 path = fetchZigArtifact { 162 207 name = "exim"; ··· 175 220 }; 176 221 } 177 222 { 223 + name = "zig_mime-0.0.0-4saPF24RCwBOkIdo4hHfAYfUovszKIL7CXaXgB5gyGC4"; 224 + path = fetchZigArtifact { 225 + name = "mime"; 226 + url = "git+https://git.jcollie.dev/jeff/zig-mime.git#70ea89ff4e2f1a89315ce0811376bc7103418f4d"; 227 + hash = "sha256-eE1tK6VjImX8BKxhMOFeIlx3245j/QEnupIAlUO4W5E="; 228 + unpack = true; 229 + }; 230 + } 231 + { 232 + name = "N-V-__8AAHNhQgCiWfjOCo_LQgx55jnHBr2Z61ZAqPU9n5Uo"; 233 + path = fetchZigArtifact { 234 + name = "moment"; 235 + url = "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz"; 236 + hash = "sha256-UiGan+5eH6reTHJTbBc8VM7dXiYZJy3QwlGjCur83ow="; 237 + unpack = false; 238 + }; 239 + } 240 + { 178 241 name = "sasl-0.0.0-s3YcOPiJAQCy9rnR9LEZY_Zc_x0oK6yc1FUCD57I2jvc"; 179 242 path = fetchZigArtifact { 180 243 name = "sasl"; ··· 184 247 }; 185 248 } 186 249 { 250 + name = "std_crypto_ext-0.0.0-zmlw4bc3AwBPVhIG9TPuHlFwfZ0eQ1t24AzQx5ERyIC7"; 251 + path = fetchZigArtifact { 252 + name = "std_crypto_ext"; 253 + url = "git+https://git.jcollie.dev/jeff/zig-std-crypto-ext.git#1dfbbe87291143823e52d0aca9339ac4b7d62f69"; 254 + hash = "sha256-8zLut6oDOlt8oonSpYFlM8xSpbltjcbr0rckZIyBv28="; 255 + unpack = true; 256 + }; 257 + } 258 + { 187 259 name = "tls-0.1.0-ER2e0jGpBgCkVC-Yp12NgSdHNUtZr52MleJ8roHlUa54"; 188 260 path = fetchZigArtifact { 189 261 name = "tls"; 190 262 url = "https://github.com/ianic/tls.zig/archive/e04ae448ce7ee70c136d4d48b059314543203809.tar.gz"; 191 263 hash = "sha256-afPTfauIV49IATX0szuOdKLloyqI4XKsyNk8KkasAvg="; 264 + unpack = true; 265 + }; 266 + } 267 + { 268 + name = "N-V-__8AABybDwDd46ZHFqBjb0twea7p9vwNzdSzUHFwA55f"; 269 + path = fetchZigArtifact { 270 + name = "tzcode"; 271 + url = "https://data.iana.org/time-zones/releases/tzcode2026d.tar.gz"; 272 + hash = "sha256-L1yff+Kea4y4Y1g2Z4hLjOF7CkhTVaBUtZHGvfzYF5E="; 273 + unpack = false; 274 + }; 275 + } 276 + { 277 + name = "N-V-__8AAFiAFQDNovBNmFwF3hznlSfpY7KwAN3Jy7rhie29"; 278 + path = fetchZigArtifact { 279 + name = "tzdata"; 280 + url = "https://data.iana.org/time-zones/releases/tzdata2026d.tar.gz"; 281 + hash = "sha256-DLKqjjM8PcBJutxCoMYfIZh7jNROEH+pALrXZKrMd2c="; 282 + unpack = false; 283 + }; 284 + } 285 + { 286 + name = "uri-0.1.0-yCrwNDeWEQAu6MkfDY_ucBK05hxa6fQTpyzrmAOTXrag"; 287 + path = fetchZigArtifact { 288 + name = "uri"; 289 + url = "git+https://git.jcollie.dev/jeff/zig-uri.git#8ed08e670ee3c32711bef2f8eadb65726a68e53d"; 290 + hash = "sha256-jcv24dQLi9WgVHd/m03yUIF0mapDWiV8+PXO1TB2TIE="; 291 + unpack = true; 292 + }; 293 + } 294 + { 295 + name = "uucode-0.2.0-ZZjBPh-6VADBlunHbwABTPng0DH6uJqd4CvvtjZ19tny"; 296 + path = fetchZigArtifact { 297 + name = "uucode"; 298 + url = "git+https://github.com/jacobsandlund/uucode#61e54266895f833b307de81a0e3038cf1f1bebd4"; 299 + hash = "sha256-6Riz1CowKgQr70YAcfozHETUqfOR4gYIwUE5txjX70o="; 300 + unpack = true; 301 + }; 302 + } 303 + { 304 + name = "z46-0.1.0-_AxhwBHeAwDelVOsDdnw7Rg1ZzCY2uH99YdFQknXdy1J"; 305 + path = fetchZigArtifact { 306 + name = "z46"; 307 + url = "git+https://git.jcollie.dev/jeff/z46.git#52bc1256116cde4b3e7480d8664da09e5901490a"; 308 + hash = "sha256-U2dGmTMhQI+Yc0Zw9K9rGq1vRSqqTOxI51zxbtiI1Bk="; 309 + unpack = true; 310 + }; 311 + } 312 + { 313 + name = "win32-42.0.39-preview-mX5pFS564gPTezZn4v3TMxRnfJUrZNx1B_F2p2HKXOeG"; 314 + path = fetchZigArtifact { 315 + name = "zigwin32"; 316 + url = "git+https://github.com/marlersoft/zigwin32#9f15c276b4e9d05afd34a10d8662a7dfc34647ea"; 317 + hash = "sha256-JCmUrieEnOKQViUGjPNrlGwJMMM4X/BjC3iur5fRbqA="; 318 + unpack = true; 319 + }; 320 + } 321 + { 322 + name = "zuucode-0.0.0-0vGVIUdzAQB9KHmXYgy0yzxjZly9shdXSHUnxDYo8hCc"; 323 + path = fetchZigArtifact { 324 + name = "zuucode"; 325 + url = "git+https://git.jcollie.dev/jeff/zuucode.git#702a8aeec6251961cc880d1a2539ad72714f6881"; 326 + hash = "sha256-qWNdJ9igjZ0pF902vheTA1o9MQ1TS9tHr2ajJ3vNKD0="; 192 327 unpack = true; 193 328 }; 194 329 }
+234 -6
src/Server.zig
··· 22 22 const tls = @import("tls"); 23 23 const protocol = @import("protocol.zig"); 24 24 const sasl = @import("sasl"); 25 + const mime = @import("mime"); 26 + const datetime = @import("datetime"); 25 27 26 28 reader: *Io.Reader, 27 29 writer: *Io.Writer, ··· 33 35 /// session and reported on every `Envelope`. 34 36 identity_buf: [255]u8 = undefined, 35 37 identity_len: usize = 0, 38 + /// The name the client gave in HELO, EHLO or LHLO, copied because it points 39 + /// into the reader's buffer and does not survive the next command. A domain 40 + /// is at most 255 octets; a client claiming more is truncated rather than 41 + /// refused, since the name is a claim and not a credential. 42 + greeting_buf: [255]u8 = undefined, 43 + greeting_len: usize = 0, 36 44 tls_connection: tls.Connection = undefined, 37 45 tls_reader: tls.Connection.Reader = undefined, 38 46 tls_writer: tls.Connection.Writer = undefined, ··· 95 103 /// base64 expands by, so the usable message is about three sevenths of 96 104 /// what is given. 97 105 sasl_buffer: []u8 = &.{}, 106 + /// Stamp a `Received:` field on every message. See `ReceivedOptions`. 107 + received: ?ReceivedOptions = null, 98 108 /// Reject MAIL with 530 until the client has authenticated. Requires at 99 109 /// least one entry in `auth_mechanisms`. 100 110 require_auth: bool = false, ··· 114 124 /// wide-area use at all: it is for the hop between a queueing MTA and 115 125 /// the thing that writes to mailboxes. 116 126 lmtp, 127 + }; 128 + 129 + /// What `Envelope.received` needs that a session cannot work out for itself. 130 + /// 131 + /// Set it to have every message carry a composed `Received:` field. Left 132 + /// null, `Envelope.received` is empty and nothing is stamped — which is a 133 + /// choice the caller is making, since 134 + /// [RFC 5321 §4.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.4) 135 + /// requires a receiving server to insert one. 136 + pub const ReceivedOptions = struct { 137 + /// Read for the clock, once per message. 138 + io: Io, 139 + /// What goes in parentheses after the client's greeting name: what this 140 + /// server *observed* about the peer, as against what the peer said. 141 + /// Conventionally the reverse-DNS name and the address literal — 142 + /// `client.example.com [192.0.2.1]` — and the only part of the trace 143 + /// worth believing. 144 + /// 145 + /// The caller's, because this library is handed a reader and a writer 146 + /// and has never seen an address. It is also why a session that leaves 147 + /// this null still produces a usable trace: the greeting name alone is 148 + /// worth little, but a trace with a `by` and a timestamp is still a 149 + /// trace. 150 + peer: ?[]const u8 = null, 151 + /// What goes in parentheses after `by`, conventionally the software. 152 + by_info: ?[]const u8 = null, 117 153 }; 118 154 119 155 pub const TlsOptions = struct { ··· 212 248 /// meet the requirement should report 5.7.30, "REQUIRETLS support 213 249 /// required", which RFC 8689 defines for exactly that. 214 250 require_tls: bool = false, 251 + /// The `Received:` field this server would stamp, as a complete field 252 + /// ready to write — name, value, folding and terminating CRLF — or empty 253 + /// when `Options.received` was not set. 254 + /// 255 + /// **Write it before the message.** 256 + /// [RFC 5321 §4.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.4) 257 + /// requires a receiving server to insert trace information "at the 258 + /// beginning of the message content", and this library does not touch 259 + /// the bytes it hands over — it unstuffs them and nothing else — so the 260 + /// stamping is the handler's to do and the composing is done for it. 261 + /// 262 + /// Valid for the duration of the callback, like everything else here. 263 + received: []const u8 = "", 264 + /// The name the client gave in its greeting, which is a string the peer 265 + /// chose and is worth exactly that. What this server observed about the 266 + /// peer instead is `ReceivedOptions.peer`, which the caller supplies. 267 + greeting: []const u8 = "", 215 268 /// The identity the client authenticated as, or null if it did not. 216 269 /// 217 270 /// This is what the mechanism reported, which is not always the username ··· 242 295 t.* = .{}; 243 296 } 244 297 245 - fn envelope(t: Transaction, authenticated_as: ?[]const u8) Envelope { 298 + fn envelope( 299 + t: Transaction, 300 + authenticated_as: ?[]const u8, 301 + received: []const u8, 302 + greeting_name: []const u8, 303 + ) Envelope { 246 304 return .{ 247 305 .from = t.from.?, 248 306 .authenticated_as = authenticated_as, 307 + .received = received, 308 + .greeting = greeting_name, 249 309 .submitter = t.submitter, 250 310 .require_tls = t.require_tls, 251 311 .recipients = t.recipients.items, ··· 342 402 continue; 343 403 }; 344 404 switch (command) { 345 - .helo => { 405 + .helo => |name| { 346 406 // RFC 2033 §4: an LMTP server must not answer HELO or EHLO 347 407 // with a positive completion, and 500 is what it suggests. 348 408 if (s.options.protocol == .lmtp) { ··· 350 410 continue; 351 411 } 352 412 greeted = true; 413 + s.setGreeting(name); 353 414 transaction.clear(); 354 415 _ = arena_state.reset(.retain_capacity); 355 416 try s.reply(250, s.options.hostname); 356 417 }, 357 - .ehlo => { 418 + .ehlo => |name| { 358 419 if (s.options.protocol == .lmtp) { 359 420 try s.reply(500, "5.5.1 This is LMTP, use LHLO"); 360 421 continue; 361 422 } 362 423 greeted = true; 424 + s.setGreeting(name); 363 425 transaction.clear(); 364 426 _ = arena_state.reset(.retain_capacity); 365 427 try s.greetExtended(authenticated); 366 428 }, 367 - .lhlo => { 429 + .lhlo => |name| { 368 430 if (s.options.protocol == .smtp) { 369 431 try s.reply(500, "5.5.2 Command not recognized"); 370 432 continue; 371 433 } 372 434 greeted = true; 435 + s.setGreeting(name); 373 436 transaction.clear(); 374 437 _ = arena_state.reset(.retain_capacity); 375 438 try s.greetExtended(authenticated); ··· 572 635 try s.reply(503, "5.5.1 BINARYMIME requires BDAT"); 573 636 continue; 574 637 } 575 - try s.receiveData(arena, transaction.envelope(s.identity())); 638 + try s.receiveData(arena, transaction.envelope( 639 + s.identity(), 640 + try s.composeReceived(arena, transaction, authenticated), 641 + s.greetingName(), 642 + )); 576 643 transaction.clear(); 577 644 _ = arena_state.reset(.retain_capacity); 578 645 }, ··· 587 654 try s.reply(503, "5.5.1 Need RCPT command first"); 588 655 continue; 589 656 } 590 - const outcome = try s.receiveChunked(arena, transaction.envelope(s.identity()), args); 657 + const outcome = try s.receiveChunked(arena, transaction.envelope( 658 + s.identity(), 659 + try s.composeReceived(arena, transaction, authenticated), 660 + s.greetingName(), 661 + ), args); 591 662 transaction.clear(); 592 663 _ = arena_state.reset(.retain_capacity); 593 664 switch (outcome) { ··· 813 884 pub fn identity(s: *const Server) ?[]const u8 { 814 885 if (s.identity_len == 0) return null; 815 886 return s.identity_buf[0..s.identity_len]; 887 + } 888 + 889 + /// Keeps the name the client greeted with, for the trace field. 890 + fn setGreeting(s: *Server, name: []const u8) void { 891 + s.greeting_len = @min(name.len, s.greeting_buf.len); 892 + @memcpy(s.greeting_buf[0..s.greeting_len], name[0..s.greeting_len]); 893 + } 894 + 895 + /// The name the client gave in its greeting, which is a string the peer 896 + /// chose and is worth exactly that. 897 + pub fn greetingName(s: *const Server) []const u8 { 898 + return s.greeting_buf[0..s.greeting_len]; 899 + } 900 + 901 + /// Composes the `Received:` field for the message about to be handed over. 902 + /// 903 + /// Everything in it that came from the client — the greeting name above all 904 + /// — is escaped by `mime.received`, which is the point of composing it 905 + /// there: a `Received:` is a header written from attacker-supplied text, and 906 + /// a greeting carrying a line break must not become two fields. 907 + fn composeReceived( 908 + s: *Server, 909 + arena: std.mem.Allocator, 910 + transaction: Transaction, 911 + authenticated: bool, 912 + ) std.mem.Allocator.Error![]const u8 { 913 + const options = s.options.received orelse return ""; 914 + 915 + const trace: mime.received.Received = .{ 916 + .from = if (s.greeting_len == 0) null else s.greetingName(), 917 + .from_info = options.peer, 918 + .by = s.options.hostname, 919 + .by_info = options.by_info, 920 + .with = mime.received.protocolFor(.{ 921 + .tls = s.secured, 922 + .authenticated = authenticated, 923 + .lmtp = s.options.protocol == .lmtp, 924 + .utf8 = transaction.smtputf8, 925 + }), 926 + // RFC 5321 §4.4: only with exactly one recipient. More than one and 927 + // the trace tells each of them who the others were, which is how a 928 + // Bcc gets broken by the transport rather than by the sender. 929 + .for_recipient = if (transaction.recipients.items.len == 1) 930 + transaction.recipients.items[0].address 931 + else 932 + null, 933 + .received_at = datetime.DateTime.utc(options.io), 934 + }; 935 + 936 + var field: Io.Writer.Allocating = .init(arena); 937 + field.writer.writeAll("Received: ") catch return error.OutOfMemory; 938 + trace.write(&field.writer) catch return error.OutOfMemory; 939 + field.writer.writeAll(protocol.crlf) catch return error.OutOfMemory; 940 + return field.written(); 816 941 } 817 942 818 943 /// Keeps the authenticated identity for the rest of the session. ··· 1265 1390 last_orcpt_address: std.ArrayList(u8) = .empty, 1266 1391 ret: ?protocol.Ret = null, 1267 1392 require_tls: bool = false, 1393 + received: std.ArrayList(u8) = .empty, 1268 1394 submitter: ?protocol.Submitter = null, 1269 1395 submitter_mailbox: std.ArrayList(u8) = .empty, 1270 1396 identity: std.ArrayList(u8) = .empty, ··· 1278 1404 h.recipients.deinit(std.testing.allocator); 1279 1405 h.data.deinit(std.testing.allocator); 1280 1406 h.envid.deinit(std.testing.allocator); 1407 + h.received.deinit(std.testing.allocator); 1281 1408 h.identity.deinit(std.testing.allocator); 1282 1409 h.submitter_mailbox.deinit(std.testing.allocator); 1283 1410 h.last_orcpt_type.deinit(std.testing.allocator); ··· 1357 1484 h.ret = envelope.ret; 1358 1485 h.submitter = envelope.submitter; 1359 1486 h.require_tls = envelope.require_tls; 1487 + h.received.appendSlice(gpa, envelope.received) catch return .{ .reject = .{} }; 1360 1488 if (envelope.submitter) |who| switch (who) { 1361 1489 // Copied: it points into the session arena, which is reset the 1362 1490 // moment this transaction ends. ··· 1938 2066 // and carrying it over would be a promise nobody made. 1939 2067 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1940 2068 try std.testing.expect(!h.require_tls); 2069 + } 2070 + 2071 + test "the Received field says what the session was" { 2072 + var h: TestHandler = .{}; 2073 + defer h.deinit(); 2074 + 2075 + var out_buf: [4096]u8 = undefined; 2076 + _ = try runScript( 2077 + "EHLO client.example.org\r\n" ++ 2078 + "MAIL FROM:<a@example.com>\r\n" ++ 2079 + "RCPT TO:<b@example.net>\r\n" ++ 2080 + "DATA\r\nbody\r\n.\r\nQUIT\r\n", 2081 + &out_buf, 2082 + h.handler(), 2083 + .{ 2084 + .hostname = "mx.test", 2085 + .received = .{ 2086 + .io = std.testing.io, 2087 + .peer = "client.example.org [192.0.2.1]", 2088 + .by_info = "zig-smtp", 2089 + }, 2090 + }, 2091 + ); 2092 + 2093 + const field = h.received.items; 2094 + try std.testing.expect(std.mem.startsWith(u8, field, "Received: from client.example.org")); 2095 + // What the peer said, then what this server observed about it -- the 2096 + // second being the half worth believing. 2097 + try std.testing.expect(std.mem.indexOf(u8, field, "(client.example.org [192.0.2.1])") != null); 2098 + try std.testing.expect(std.mem.indexOf(u8, field, "by mx.test (zig-smtp)") != null); 2099 + // Plain ESMTP: no TLS, no AUTH, not LMTP. Getting this wrong is the 2100 + // thing `protocolFor` exists to prevent. 2101 + // Followed by the fold before `for`, not a space -- and the exact 2102 + // string matters: ESMTPS, ESMTPA and ESMTPSA would all contain "ESMTP". 2103 + try std.testing.expect(std.mem.indexOf(u8, field, "with ESMTP\r\n") != null); 2104 + // Exactly one recipient, so RFC 5321 §4.4 permits naming it. 2105 + try std.testing.expect(std.mem.indexOf(u8, field, "for <b@example.net>") != null); 2106 + try std.testing.expect(std.mem.endsWith(u8, field, "\r\n")); 2107 + } 2108 + 2109 + test "a greeting that would forge a header is escaped, not trusted" { 2110 + var h: TestHandler = .{}; 2111 + defer h.deinit(); 2112 + 2113 + // The greeting name is a string the peer chose, and it goes into a 2114 + // header. A client that ends the field early could append fields of its 2115 + // own -- a Bcc, a Return-Path -- to every message it sends. 2116 + var out_buf: [4096]u8 = undefined; 2117 + _ = try runScript( 2118 + "EHLO evil\tBcc:victim@example.net\r\n" ++ 2119 + "MAIL FROM:<a@example.com>\r\n" ++ 2120 + "RCPT TO:<b@example.net>\r\n" ++ 2121 + "DATA\r\nbody\r\n.\r\nQUIT\r\n", 2122 + &out_buf, 2123 + h.handler(), 2124 + .{ .hostname = "mx.test", .received = .{ .io = std.testing.io } }, 2125 + ); 2126 + 2127 + const field = h.received.items; 2128 + // Exactly one line ending, at the end: the field is one field. 2129 + try std.testing.expectEqual( 2130 + @as(usize, 1), 2131 + std.mem.count(u8, field, protocol.crlf) - std.mem.count(u8, field, "\r\n\t"), 2132 + ); 2133 + try std.testing.expect(std.mem.indexOf(u8, field, "\r\nBcc:") == null); 2134 + } 2135 + 2136 + test "several recipients mean no for clause" { 2137 + var h: TestHandler = .{}; 2138 + defer h.deinit(); 2139 + 2140 + var out_buf: [4096]u8 = undefined; 2141 + _ = try runScript( 2142 + "EHLO client.example.org\r\n" ++ 2143 + "MAIL FROM:<a@example.com>\r\n" ++ 2144 + "RCPT TO:<b@example.net>\r\n" ++ 2145 + "RCPT TO:<c@example.net>\r\n" ++ 2146 + "DATA\r\nbody\r\n.\r\nQUIT\r\n", 2147 + &out_buf, 2148 + h.handler(), 2149 + .{ .hostname = "mx.test", .received = .{ .io = std.testing.io } }, 2150 + ); 2151 + // RFC 5321 §4.4 allows `for` only with exactly one recipient, because 2152 + // naming several tells each of them who the others were. 2153 + try std.testing.expect(std.mem.indexOf(u8, h.received.items, "for <") == null); 2154 + } 2155 + 2156 + test "no ReceivedOptions means nothing is stamped" { 2157 + var h: TestHandler = .{}; 2158 + defer h.deinit(); 2159 + 2160 + var out_buf: [4096]u8 = undefined; 2161 + _ = try runScript( 2162 + "EHLO client.example.org\r\nMAIL FROM:<a@example.com>\r\n" ++ 2163 + "RCPT TO:<b@example.net>\r\nDATA\r\nbody\r\n.\r\nQUIT\r\n", 2164 + &out_buf, 2165 + h.handler(), 2166 + .{}, 2167 + ); 2168 + try std.testing.expectEqualStrings("", h.received.items); 1941 2169 } 1942 2170 1943 2171 test "REQUIRETLS is not offered without TLS, and not taken without being offered" {
+22 -1
src/main.zig
··· 529 529 defer gpa.free(write_buf); 530 530 var stream_reader = stream.reader(io, read_buf); 531 531 var stream_writer = stream.writer(io, write_buf); 532 + // What this server observed about the peer, which is the half of the 533 + // trace worth believing. `Socket.address` after `accept` is the far 534 + // end, which is exactly what is wanted here. 535 + var peer_buf: [64]u8 = undefined; 536 + const peer_text = std.fmt.bufPrint(&peer_buf, "[{f}]", .{stream.socket.address}) catch "[unknown]"; 532 537 var session: smtp.Server = .init( 533 538 &stream_reader.interface, 534 539 &stream_writer.interface, ··· 539 544 .{ 540 545 .protocol = config.protocol, 541 546 .hostname = "localhost", 547 + .received = .{ 548 + .io = io, 549 + // A real server puts the peer's reverse-DNS name and 550 + // address literal here; this one has the address and 551 + // does not resolve. 552 + .peer = peer_text, 553 + .by_info = "zig-smtp", 554 + }, 542 555 .tls = tls_options, 543 556 .auth_mechanisms = mechanisms, 544 557 .sasl_buffer = &sasl_scratch, ··· 623 636 .mailbox => |mailbox| try printer.out.print(" AUTH={s}", .{mailbox}), 624 637 }; 625 638 if (envelope.authenticated_as) |who| try printer.out.print(" (authenticated as {s})", .{who}); 626 - try printer.out.print(" ({d} bytes)\n{s}---\n", .{ data.len, data }); 639 + // RFC 5321 §4.4 wants the trace at the beginning of the content, so 640 + // it goes out before the body. The library composes it and does not 641 + // touch the message; writing it is the handler's, which is what this 642 + // line is here to show. 643 + try printer.out.print(" ({d} bytes)\n{s}{s}---\n", .{ 644 + data.len, 645 + envelope.received, 646 + data, 647 + }); 627 648 try printer.out.flush(); 628 649 } 629 650 };