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.

Speak LMTP, and cite the specifications properly

LMTP (RFC 2033) is SMTP with two differences that matter: the greeting is
LHLO and HELO/EHLO are refused, and the end of a message is answered with
one reply per accepted recipient rather than one for the message. The
second is the whole point -- a delivery agent can say that one mailbox is
full while another is fine, which SMTP gives it no way to express -- and
it is why this is a mode rather than a separate protocol.

The server takes `Options.protocol = .lmtp` and a new `recipientResult`
callback, asked once per accepted recipient after the message callback has
returned. A message rejected outright is reported as that rejection for
every recipient, since it failed for all of them, and a recipient named
twice is answered twice, which RFC 2033 §4.2 is explicit about. BDAT LAST
draws the same per-recipient answer as the final dot.

The client takes `Client.mode = .lmtp` -- spelled `mode` only because the
`protocol` module import already holds that name in the struct's scope --
and tracks how many recipients the server accepted, since that is how many
replies the end of the message will bring. `DataWriter.endResults` hands
them back one at a time with the index they belong to; `end` reads them all
and says `error.RecipientRejected`, which is a different error from
`UnexpectedReply` precisely because it cannot say which recipient failed:
the replies share one buffer and reading the next overwrites the previous.

Along the way the transaction state became a `Transaction` struct. It was
seven copies of the same seven-line reset by the time LHLO wanted an
eighth, and adding a field to six of seven places is a bug waiting to be
written.

Verified against real implementations: exim now routes a two-recipient
message to a zsmtp LMTP server that accepts one mailbox and refuses the
other, and reads the two verdicts back as a delivery and a permanent
failure of the same message; the zsmtp LMTP client delivers to dovecot,
reports which recipient dovecot refused, and both servers are checked for
refusing EHLO as RFC 2033 §4 requires.

The README grows a "References cited" section, in the RFC citation format
so that an entry here matches one anywhere else. Every author and date in
it came from the IETF's own bibliography rather than from memory. The
Standards section says what is implemented of each document; this one says
what each document is, and covers the ones cited only as gaps or as out of
scope, plus tls.zig, the is_email corpus and exim's test suite.

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

+869 -123
+143 -4
README.md
··· 94 94 `error.ArgumentTooLong`. Check `extensions.dsn` first — a conforming server 95 95 answers an unrecognized parameter with 555. 96 96 97 + Setting `client.mode = .lmtp` before `hello` speaks LMTP: `LHLO` goes out in 98 + place of `EHLO`, and the end of a message brings back one verdict per 99 + accepted recipient, in the order the RCPT commands were issued. `endResults` 100 + is how to read them: 101 + 102 + ```zig 103 + var data_writer = try client.data(); 104 + try data_writer.interface.writeAll(message); 105 + var verdicts = try data_writer.endResults(); 106 + while (try verdicts.next()) |reply| { 107 + // verdicts.index counts the recipients as they are answered. 108 + std.log.info("{s}: {d} {s}", .{ recipients[verdicts.index - 1], reply.code, reply.text }); 109 + } 110 + ``` 111 + 112 + Every verdict must be read before the session is used again, or the next 113 + command is answered by a leftover reply. The simpler `end` reads them all 114 + and reports `error.RecipientRejected` if any was a refusal — without saying 115 + which, because the replies share one buffer and reading the next overwrites 116 + the previous. 117 + 97 118 When the server advertises CHUNKING (`extensions.chunking`), `bdat` and 98 119 `sendMessageChunked` transmit the message with length-framed BDAT chunks 99 120 instead of DATA — verbatim, with no dot-stuffing, so content must already ··· 198 219 body type reach the handler via `Envelope`. Listening, accepting, and 199 220 concurrency are up to the caller. 200 221 222 + Setting `Options.protocol = .lmtp` makes the session speak LMTP 223 + ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)) instead: `LHLO` 224 + greets and `HELO`/`EHLO` are refused with 500, and the end of a message 225 + draws one reply per accepted recipient rather than one for the message — 226 + including a second reply for a recipient named twice. The `recipientResult` 227 + callback supplies each verdict: 228 + 229 + ```zig 230 + fn onRecipientResult(ctx: ?*anyopaque, envelope: zsmtp.Server.Envelope, index: usize) zsmtp.Server.Decision { 231 + return if (mailboxIsFull(envelope.recipients[index].address)) 232 + .{ .reject = .{ .code = 452, .text = "4.2.2 Mailbox full" } } 233 + else 234 + .accept; 235 + } 236 + ``` 237 + 238 + Without it every recipient is told the same thing, which is correct but 239 + gains nothing over SMTP. A message the handler rejected outright is reported 240 + as that rejection for each recipient, since it failed for all of them. LMTP 241 + is meant for the hop between a queueing MTA and whatever writes to mailboxes; 242 + RFC 2033 §5 forbids it on TCP port 25 and advises against wide-area use. 243 + 201 244 DSN ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)) is 202 245 advertised. `RET=` and `ENVID=` on MAIL arrive as `Envelope.ret` and 203 246 `Envelope.envid`, and `NOTIFY=` and `ORCPT=` on RCPT arrive as ··· 255 298 zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 256 299 zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 257 300 301 + # Speak LMTP (RFC 2033) instead of SMTP. The server reports one verdict per 302 + # recipient, and --fail-delivery makes one of them fail to show it: 303 + ./zig-out/bin/zsmtp serve --lmtp --fail-delivery bad@example.net 2529 304 + printf 'Subject: hi\r\n\r\nhello\r\n' | \ 305 + ./zig-out/bin/zsmtp send --lmtp 127.0.0.1 2529 me@example.com \ 306 + good@example.net bad@example.net 307 + 258 308 # Request a delivery status notification (RFC 3461): 259 309 zsmtp send --ret hdrs --envid 'batch 7' --notify success,failure \ 260 310 --orcpt team@example.net 127.0.0.1 2525 me@example.com you@example.net ··· 274 324 implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the 275 325 client and PLAIN and LOGIN on the server. Message bodies can be streamed on 276 326 both sides, and the server validates MAIL and RCPT parameters (SIZE=, BODY=, 277 - and the DSN set RET=, ENVID=, NOTIFY=, ORCPT=). 327 + and the DSN set RET=, ENVID=, NOTIFY=, ORCPT=). Both sides also speak LMTP, 328 + where a message ends with one verdict per recipient rather than one for the 329 + message. 278 330 279 331 ## Known gaps 280 332 ··· 301 353 302 354 ### Protocol 303 355 304 - - **LMTP** ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)) — no 305 - `LHLO`, no per-recipient reply after the final dot. The missing mode for 306 - anyone wanting a delivery agent behind Postfix. 307 356 - **PIPELINING** ([RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920)) 308 357 — advertised and parsed by both sides, used by neither. `sendMail` is 309 358 strictly request-response. ··· 375 424 MAIL and `NOTIFY=`/`ORCPT=` on RCPT and hands them to the handler; the 376 425 client sends them through `mail`/`rcpt`. Includes the xtext codec of §4. 377 426 Generating the report message itself (RFC 3464) is out of scope. 427 + - [RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033) — LMTP: client 428 + and server, via `Client.mode` and `Server.Options.protocol`. `LHLO` 429 + replaces `EHLO` and the end of a message draws one reply per accepted 430 + recipient instead of one for the message, after DATA and after `BDAT 431 + LAST` alike. 378 432 - [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING: 379 433 advertised by the server, whose strictly sequential command loop handles 380 434 pipelined clients naturally; parsed by the client. ··· 403 457 404 458 TLS itself (TLS 1.3, [RFC 8446](https://datatracker.ietf.org/doc/html/rfc8446)) 405 459 is provided by [ianic/tls.zig](https://github.com/ianic/tls.zig). 460 + 461 + ## References cited 462 + 463 + The specifications this implementation was written against, and the outside 464 + work it borrows from, in the RFC citation format so that a reference here 465 + matches one anywhere else. The **Standards** section above says what is 466 + implemented of each; this one says what each document *is*. 467 + 468 + - **[RFC1870]** Klensin, J., Freed, N., and K. Moore, "SMTP Service 469 + Extension for Message Size Declaration", RFC 1870, November 1995, 470 + <https://www.rfc-editor.org/info/rfc1870>. 471 + - **[RFC2033]** Myers, J., "Local Mail Transfer Protocol", RFC 2033, 472 + October 1996, <https://www.rfc-editor.org/info/rfc2033>. 473 + - **[RFC2034]** Freed, N., "SMTP Service Extension for Returning Enhanced 474 + Error Codes", RFC 2034, October 1996, 475 + <https://www.rfc-editor.org/info/rfc2034>. 476 + - **[RFC2195]** Klensin, J., Catoe, R., and P. Krumviede, "IMAP/POP 477 + AUTHorize Extension for Simple Challenge/Response", RFC 2195, 478 + September 1997, <https://www.rfc-editor.org/info/rfc2195>. 479 + - **[RFC2920]** Freed, N., "SMTP Service Extension for Command Pipelining", 480 + RFC 2920, September 2000, <https://www.rfc-editor.org/info/rfc2920>. 481 + - **[RFC3030]** Vaudreuil, G., "SMTP Service Extensions for Transmission of 482 + Large and Binary MIME Messages", RFC 3030, December 2000, 483 + <https://www.rfc-editor.org/info/rfc3030>. 484 + - **[RFC3207]** Hoffman, P., "SMTP Service Extension for Secure SMTP over 485 + Transport Layer Security", RFC 3207, February 2002, 486 + <https://www.rfc-editor.org/info/rfc3207>. 487 + - **[RFC3461]** Moore, K., "Simple Mail Transfer Protocol (SMTP) Service 488 + Extension for Delivery Status Notifications (DSNs)", RFC 3461, 489 + January 2003, <https://www.rfc-editor.org/info/rfc3461>. 490 + - **[RFC3463]** Vaudreuil, G., "Enhanced Mail System Status Codes", 491 + RFC 3463, January 2003, <https://www.rfc-editor.org/info/rfc3463>. 492 + - **[RFC3464]** Moore, K. and G. Vaudreuil, "An Extensible Message Format 493 + for Delivery Status Notifications", RFC 3464, January 2003, 494 + <https://www.rfc-editor.org/info/rfc3464>. *(Cited as out of scope: the 495 + report message itself.)* 496 + - **[RFC4616]** Zeilenga, K., "The PLAIN Simple Authentication and Security 497 + Layer (SASL) Mechanism", RFC 4616, August 2006, 498 + <https://www.rfc-editor.org/info/rfc4616>. 499 + - **[RFC4954]** Siemborski, R. and A. Melnikov, "SMTP Service Extension for 500 + Authentication", RFC 4954, July 2007, 501 + <https://www.rfc-editor.org/info/rfc4954>. 502 + - **[RFC5321]** Klensin, J., "Simple Mail Transfer Protocol", RFC 5321, 503 + October 2008, <https://www.rfc-editor.org/info/rfc5321>. 504 + - **[RFC5322]** Resnick, P., Ed., "Internet Message Format", RFC 5322, 505 + October 2008, <https://www.rfc-editor.org/info/rfc5322>. *(Cited as out 506 + of scope: the format of the message this library carries.)* 507 + - **[RFC6152]** Klensin, J., Freed, N., Rose, M., and D. Crocker, "SMTP 508 + Service Extension for 8-bit MIME Transport", RFC 6152, March 2011, 509 + <https://www.rfc-editor.org/info/rfc6152>. 510 + - **[RFC6531]** Yao, J. and W. Mao, "SMTP Extension for Internationalized 511 + Email", RFC 6531, February 2012, 512 + <https://www.rfc-editor.org/info/rfc6531>. 513 + - **[RFC6533]** Hansen, T., Ed., Newman, C., and A. Melnikov, 514 + "Internationalized Delivery Status and Disposition Notifications", 515 + RFC 6533, February 2012, <https://www.rfc-editor.org/info/rfc6533>. 516 + - **[RFC7628]** Mills, W., Showalter, T., and H. Tschofenig, "A Set of 517 + Simple Authentication and Security Layer (SASL) Mechanisms for OAuth", 518 + RFC 7628, August 2015, <https://www.rfc-editor.org/info/rfc7628>. 519 + *(Cited as a gap.)* 520 + - **[RFC7677]** Hansen, T., "SCRAM-SHA-256 and SCRAM-SHA-256-PLUS Simple 521 + Authentication and Security Layer (SASL) Mechanisms", RFC 7677, 522 + November 2015, <https://www.rfc-editor.org/info/rfc7677>. *(Cited as a 523 + gap.)* 524 + - **[RFC8314]** Moore, K. and C. Newman, "Cleartext Considered Obsolete: 525 + Use of Transport Layer Security (TLS) for Email Submission and Access", 526 + RFC 8314, January 2018, <https://www.rfc-editor.org/info/rfc8314>. 527 + - **[RFC8446]** Rescorla, E., "The Transport Layer Security (TLS) Protocol 528 + Version 1.3", RFC 8446, August 2018, 529 + <https://www.rfc-editor.org/info/rfc8446>. 530 + - **[SASL-LOGIN]** Murchison, K. and M. Crispin, "The LOGIN SASL 531 + Mechanism", Work in Progress, Internet-Draft, 532 + draft-murchison-sasl-login-00, August 2003, 533 + <https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00>. 534 + The draft expired and LOGIN was never standardized; it is implemented 535 + here because servers still ask for it. 536 + - **[TLS.ZIG]** Ianic, "tls.zig — TLS 1.2/1.3 implementation in Zig", 537 + <https://github.com/ianic/tls.zig>. Provides the TLS on both sides; see 538 + the **TLS** section for why the standard library's client is not used. 539 + - **[ISEMAIL]** Sayers, D., "is_email — an email address validator and its 540 + test suite", BSD-3-Clause, <https://github.com/dominicsayers/isemail>. 541 + The address corpus the path parser is checked against; see **Tests**. 542 + - **[EXIM]** The Exim Maintainers, "Exim Internet Mailer", 543 + GPL-2.0-or-later, <https://www.exim.org/>. The protocol torture script 544 + and the gauntlet unit test's dialogue are adapted from its test suite. 406 545 407 546 ## Tests 408 547
+135
nix/interop-test.nix
··· 7 7 # delivery to alice's mailbox 8 8 # - swaks -> zsmtp server: plaintext and STARTTLS, verified by checking 9 9 # the received message in the server's journal 10 + # - exim -> zsmtp LMTP server (2529): a two-recipient delivery where the 11 + # server accepts one mailbox and refuses the other, which is the thing 12 + # LMTP exists to express and which exim has to read correctly 13 + # - zsmtp LMTP client -> dovecot LMTP (2024) 10 14 11 15 { 12 16 testers, ··· 39 43 environment.systemPackages = [ 40 44 zsmtp 41 45 pkgs.swaks 46 + pkgs.netcat 42 47 ]; 43 48 44 49 users.users.alice.isNormalUser = true; 50 + 51 + # A real LMTP server for the zsmtp LMTP client to deliver to. 52 + services.dovecot2 = { 53 + enable = true; 54 + # alice is a normal user declared above, not one for dovecot to make. 55 + createMailUser = false; 56 + settings = { 57 + protocols = [ "lmtp" ]; 58 + # Dovecot 2.4 requires both of these to be stated rather than 59 + # inferred, so that a version bump cannot silently change meaning. 60 + dovecot_config_version = "2.4.5"; 61 + dovecot_storage_version = "2.4.5"; 62 + mail_driver = "maildir"; 63 + mail_path = "/var/spool/dovecot-mail/%{user}"; 64 + # Deliveries arrive addressed to alice@localhost; the mailbox is 65 + # alice, so the domain is stripped before the userdb lookup. 66 + auth_username_format = "%{user | username}"; 67 + mail_uid = "alice"; 68 + mail_gid = "users"; 69 + # LMTP resolves each recipient through the userdb, and dovecot's 70 + # auth process refuses to start without a passdb beside it even 71 + # though nothing here authenticates. 72 + "userdb passwd" = { }; 73 + "passdb pam" = { }; 74 + # Dovecot 2.4 takes the address from the global `listen` rather 75 + # than from the listener block, which only names the port. 76 + listen = "127.0.0.1"; 77 + service = [ 78 + { 79 + _section.name = "lmtp"; 80 + "inet_listener lmtp".port = 2024; 81 + } 82 + ]; 83 + }; 84 + }; 45 85 46 86 services.postfix = { 47 87 enable = true; ··· 87 127 88 128 begin routers 89 129 130 + # Everything for lmtp.test goes to the zsmtp LMTP server, which 131 + # accepts one of the two mailboxes below and refuses the other. 132 + # Listed first because the first matching router wins. 133 + lmtp_route: 134 + driver = manualroute 135 + domains = lmtp.test 136 + transport = lmtp_out 137 + route_list = * 127.0.0.1 138 + # 127.0.0.1 is this machine, which exim otherwise refuses to 139 + # route to; `self = send` and the transport's allow_localhost 140 + # are the two halves of saying "yes, really, deliver there". 141 + self = send 142 + 90 143 local_users: 91 144 driver = accept 92 145 local_parts = alice : bob 93 146 transport = local_delivery 94 147 95 148 begin transports 149 + 150 + lmtp_out: 151 + driver = smtp 152 + protocol = lmtp 153 + # Stated numerically because exim otherwise looks up the 154 + # service name "lmtp", which /etc/services does not have. 155 + port = 2529 156 + # Without this exim refuses to deliver to its own machine. 157 + allow_localhost 158 + hosts_try_fastopen = 96 159 97 160 local_delivery: 98 161 driver = appendfile ··· 122 185 123 186 systemd.tmpfiles.rules = [ 124 187 "d /var/spool/exim-mail 0755 exim exim -" 188 + "d /var/spool/dovecot-mail 0755 alice users -" 125 189 ]; 126 190 127 191 systemd.services.zsmtp-server = { ··· 151 215 }; 152 216 }; 153 217 218 + systemd.services.zsmtp-server-lmtp = { 219 + description = "zsmtp debug server (LMTP)"; 220 + wantedBy = [ "multi-user.target" ]; 221 + serviceConfig = { 222 + ExecStart = "${zsmtp}/bin/zsmtp serve --lmtp --fail-delivery bad@lmtp.test 2529"; 223 + DynamicUser = true; 224 + }; 225 + }; 226 + 154 227 systemd.services.zsmtp-server-auth = { 155 228 description = "zsmtp debug server (authentication required)"; 156 229 wantedBy = [ "multi-user.target" ]; ··· 172 245 machine.wait_for_unit("zsmtp-server-tls.service") 173 246 machine.wait_for_unit("zsmtp-server-tlsc.service") 174 247 machine.wait_for_unit("zsmtp-server-auth.service") 248 + machine.wait_for_unit("zsmtp-server-lmtp.service") 249 + machine.wait_for_unit("dovecot.service") 250 + machine.wait_for_open_port(2529) 251 + machine.wait_for_open_port(2024) 175 252 machine.wait_for_open_port(2525) 176 253 machine.wait_for_open_port(2526) 177 254 machine.wait_for_open_port(2527) ··· 234 311 " RET=FULL ENVID=batch 7'", 235 312 timeout=60, 236 313 ) 314 + 315 + # RFC 2033. The point of LMTP is a separate verdict per mailbox, so the 316 + # cases that matter are the ones where those verdicts differ. 317 + with subtest("exim to zsmtp LMTP server, one mailbox accepted and one refused"): 318 + machine.succeed( 319 + "swaks --server 127.0.0.1:2625 --from bob@example.com" 320 + # One --to with both, since a second --to replaces the first. 321 + " --to good@lmtp.test,bad@lmtp.test" 322 + " --header 'Subject: lmtp' --body 'exim to zsmtp lmtp'" 323 + ) 324 + # Both recipients in one transaction, which is what makes the two 325 + # differing verdicts possible. 326 + machine.wait_until_succeeds( 327 + "journalctl -u zsmtp-server-lmtp | grep -F" 328 + " '<good@lmtp.test> <bad@lmtp.test>'", 329 + timeout=60, 330 + ) 331 + # Exim read the two replies and applied them separately: '=>' is a 332 + # delivery and '**' a permanent failure, both for the one message, 333 + # which is exactly what SMTP could not have told it. 334 + machine.wait_until_succeeds( 335 + "journalctl -u exim | grep -F '=> good@lmtp.test'", timeout=60 336 + ) 337 + machine.wait_until_succeeds( 338 + "journalctl -u exim | grep -F '** bad@lmtp.test'", timeout=60 339 + ) 340 + machine.succeed("journalctl -u exim | grep -F 'Mailbox disabled'") 341 + 342 + with subtest("zsmtp LMTP client to dovecot"): 343 + machine.succeed( 344 + "printf 'Subject: interop\\r\\n\\r\\nzsmtp to dovecot lmtp\\r\\n'" 345 + " | zsmtp send --lmtp 127.0.0.1 2024 bob@example.com alice@localhost" 346 + ) 347 + machine.wait_until_succeeds( 348 + "grep -r 'zsmtp to dovecot lmtp' /var/spool/dovecot-mail/alice/", timeout=60 349 + ) 350 + 351 + with subtest("zsmtp LMTP client reports dovecot's refusal of one recipient"): 352 + # Two recipients, one of whom does not exist: dovecot accepts the 353 + # RCPT for alice and refuses nosuchuser outright, so this fails at 354 + # RCPT rather than at the end of data -- still per-recipient, and 355 + # still the client's job to report which. 356 + status, output = machine.execute( 357 + "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 358 + " | zsmtp send --lmtp 127.0.0.1 2024 bob@example.com nosuchuser@localhost 2>&1" 359 + ) 360 + assert status != 0, f"expected a failure, got: {output}" 361 + 362 + with subtest("dovecot refuses EHLO, as an LMTP server must"): 363 + status, output = machine.execute( 364 + "printf 'EHLO x\\r\\nQUIT\\r\\n' | timeout 5 nc 127.0.0.1 2024" 365 + ) 366 + assert "250-" not in output, f"dovecot answered EHLO positively: {output}" 367 + # And zsmtp's own LMTP server says the same thing. 368 + status, output = machine.execute( 369 + "printf 'EHLO x\\r\\nQUIT\\r\\n' | timeout 5 nc 127.0.0.1 2529" 370 + ) 371 + assert "500" in output, f"expected a 500 for EHLO, got: {output}" 237 372 238 373 with subtest("zsmtp client to postfix, SMTPUTF8"): 239 374 machine.succeed(
+186 -2
src/Client.zig
··· 37 37 /// upgrade. A session that speaks TLS from the first byte (port 465) hands 38 38 /// `init` an already-encrypted transport, and sets this itself. 39 39 security: Security = .plaintext, 40 + /// Which protocol to speak. Set before `hello`; see `Protocol`. (Spelled 41 + /// `mode` rather than `protocol` only because this file's `protocol` 42 + /// module import already holds that name in this scope; the server's 43 + /// equivalent is `Server.Options.protocol`.) 44 + mode: Protocol = .smtp, 45 + /// Recipients the server has accepted since the last MAIL, which in LMTP 46 + /// is how many replies the end of the message will draw. 47 + accepted_recipients: usize = 0, 40 48 /// Permits `authenticate`, `authPlain` and `authLogin` to send credentials 41 49 /// over a `.plaintext` transport, which they otherwise refuse with 42 50 /// `error.InsecureTransport`. ··· 50 58 /// Whether the transport encrypts what is written to it. 51 59 pub const Security = enum { plaintext, encrypted }; 52 60 61 + /// Which protocol this session speaks. `.lmtp` sends `LHLO` in place of 62 + /// `EHLO` and expects one reply per accepted recipient at the end of a 63 + /// message instead of one for the message 64 + /// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)); everything 65 + /// else is the same. Set it before `hello`. 66 + pub const Protocol = enum { smtp, lmtp }; 67 + 53 68 pub const Error = error{ 54 69 WriteFailed, 55 70 ReadFailed, ··· 59 74 ReplyTooLong, 60 75 /// The server answered with an unexpected code; see `last_reply`. 61 76 UnexpectedReply, 77 + /// LMTP only: at least one recipient's verdict at the end of the 78 + /// message was not a 2xx. 79 + /// 80 + /// It is a separate error from `UnexpectedReply` because `last_reply` 81 + /// cannot answer "which one": the replies arrive one after another into 82 + /// a single buffer, so reading the next overwrites the previous, and by 83 + /// the time the last has been read the failing one's text is gone. Use 84 + /// `DataWriter.endResults` to see each verdict as it arrives. 85 + RecipientRejected, 62 86 }; 63 87 64 88 pub const ArgumentError = error{ ··· 176 200 /// to plain HELO for servers that do not speak ESMTP. 177 201 pub fn hello(c: *Client, client_name: []const u8) (Error || ArgumentError)!Extensions { 178 202 if (!protocol.isSafeArgument(client_name)) return error.UnsafeArgument; 203 + c.accepted_recipients = 0; 204 + if (c.mode == .lmtp) { 205 + // LHLO has EHLO's semantics, and there is no older greeting to fall 206 + // back to: an LMTP server that will not take LHLO is not one. 207 + try c.send("LHLO {s}", .{client_name}); 208 + return Extensions.parse(try c.expectClass(2)); 209 + } 179 210 try c.send("EHLO {s}", .{client_name}); 180 211 const reply = try c.readReply(); 181 212 if (reply.isPositiveCompletion()) return Extensions.parse(reply); ··· 389 420 try c.writer.writeAll(protocol.crlf); 390 421 try c.writer.flush(); 391 422 _ = try c.expectClass(2); 423 + c.accepted_recipients = 0; 392 424 } 393 425 394 426 /// Adds a recipient to the current transaction. Returns ··· 414 446 try c.writer.writeAll(protocol.crlf); 415 447 try c.writer.flush(); 416 448 _ = try c.expectClass(2); 449 + c.accepted_recipients += 1; 417 450 } 418 451 419 452 /// Sends the message content for the current transaction (DATA). Line ··· 469 502 470 503 /// Terminates the message (adding a final CRLF if the content did not 471 504 /// end with one, then ".\r\n") and reads the server's verdict. 505 + /// 506 + /// In LMTP that is one verdict per accepted recipient rather than one 507 + /// for the message. All of them are read — leaving any unread would 508 + /// desynchronize the session — and a non-2xx among them becomes 509 + /// `error.RecipientRejected`, which unlike `error.UnexpectedReply` 510 + /// leaves nothing useful in `last_reply`. A caller that needs to know 511 + /// *which* recipients failed, the whole reason for speaking LMTP, 512 + /// wants `endResults`. 472 513 pub fn end(dw: *DataWriter) Error!void { 514 + var verdicts = try dw.endResults(); 515 + const per_recipient = verdicts.remaining > 1; 516 + var rejected = false; 517 + while (try verdicts.next()) |reply| { 518 + if (!reply.isPositiveCompletion()) rejected = true; 519 + } 520 + if (!rejected) return; 521 + // With one reply there is no ambiguity: `last_reply` holds it. 522 + return if (per_recipient) error.RecipientRejected else error.UnexpectedReply; 523 + } 524 + 525 + /// Terminates the message and returns the verdicts to read: one in 526 + /// SMTP, one per accepted recipient in LMTP, in the order the RCPT 527 + /// commands were issued. Every one of them must be read before the 528 + /// session is used again. 529 + pub fn endResults(dw: *DataWriter) Error!Results { 473 530 try dw.interface.flush(); 474 531 const c = dw.client; 475 532 if (dw.pending_cr) { ··· 482 539 if (!dw.at_line_start) try c.writer.writeAll(protocol.crlf); 483 540 try c.writer.writeAll("." ++ protocol.crlf); 484 541 try c.writer.flush(); 485 - _ = try c.expectClass(2); 542 + return c.results(); 486 543 } 487 544 488 545 fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize { ··· 563 620 } 564 621 }; 565 622 623 + /// The verdicts a server sends at the end of a message: one in SMTP, one 624 + /// per accepted recipient in LMTP. Each `next` overwrites the client's 625 + /// reply buffer, so a reply must be used before the following call. 626 + pub const Results = struct { 627 + client: *Client, 628 + remaining: usize, 629 + /// The index into the recipients accepted since the last MAIL that the 630 + /// next reply belongs to. Meaningful in LMTP, where replies come back 631 + /// in the order the RCPT commands were issued. 632 + index: usize = 0, 633 + 634 + pub fn next(r: *Results) Error!?Reply { 635 + if (r.remaining == 0) return null; 636 + r.remaining -= 1; 637 + r.index += 1; 638 + return try r.client.readReply(); 639 + } 640 + }; 641 + 642 + /// The verdicts still to be read after a message has been terminated. Use 643 + /// `DataWriter.endResults`, which sends the terminator first; this is the 644 + /// reading half on its own, for a caller that framed the message itself. 645 + pub fn results(c: *Client) Results { 646 + return .{ 647 + .client = c, 648 + .remaining = switch (c.mode) { 649 + .smtp => 1, 650 + .lmtp => c.accepted_recipients, 651 + }, 652 + }; 653 + } 654 + 566 655 /// Like `mailFrom`, but requests the SMTPUTF8 extension 567 656 /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)) so the 568 657 /// envelope addresses and message headers may contain UTF-8. Use only when ··· 585 674 } 586 675 try c.writer.writeAll(chunk); 587 676 try c.writer.flush(); 588 - _ = try c.expectClass(2); 677 + if (!last) { 678 + _ = try c.expectClass(2); 679 + return; 680 + } 681 + // RFC 2033 gives the LAST chunk the same per-recipient answer that the 682 + // final dot of DATA gets, so it is read the same way. 683 + var chunk_results = c.results(); 684 + const per_recipient = chunk_results.remaining > 1; 685 + var rejected = false; 686 + while (try chunk_results.next()) |reply| { 687 + if (!reply.isPositiveCompletion()) rejected = true; 688 + } 689 + if (!rejected) return; 690 + return if (per_recipient) error.RecipientRejected else error.UnexpectedReply; 589 691 } 590 692 591 693 /// Sends the message content for the current transaction as a single BDAT ··· 606 708 pub fn rset(c: *Client) Error!void { 607 709 try c.send("RSET", .{}); 608 710 _ = try c.expectClass(2); 711 + c.accepted_recipients = 0; 609 712 } 610 713 611 714 pub fn noop(c: *Client) Error!void { ··· 816 919 client.authenticate(.{}, "u", "p"), 817 920 ); 818 921 } 922 + } 923 + 924 + test "LMTP greets with LHLO and reads one verdict per recipient" { 925 + const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ // LHLO 926 + "250 2.1.0 Ok\r\n" ++ // MAIL 927 + "250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n" ++ // two RCPTs 928 + "354 End data\r\n" ++ 929 + "250 2.0.0 Ok\r\n550 5.2.1 Mailbox disabled\r\n"; // one per recipient 930 + var reader: Io.Reader = .fixed(responses); 931 + var out_buf: [512]u8 = undefined; 932 + var writer: Io.Writer = .fixed(&out_buf); 933 + var reply_buf: [256]u8 = undefined; 934 + var client: Client = .init(&reader, &writer, &reply_buf); 935 + client.mode = .lmtp; 936 + 937 + _ = try client.hello("client.example.org"); 938 + try client.mailFrom("alice@example.com"); 939 + try client.rcptTo("good@example.net"); 940 + try client.rcptTo("bad@example.net"); 941 + 942 + var data_writer = try client.data(); 943 + try data_writer.interface.writeAll("hi\r\n"); 944 + var verdicts = try data_writer.endResults(); 945 + 946 + const first = (try verdicts.next()).?; 947 + try std.testing.expectEqual(@as(u16, 250), first.code); 948 + try std.testing.expectEqual(@as(usize, 1), verdicts.index); 949 + const second = (try verdicts.next()).?; 950 + try std.testing.expectEqual(@as(u16, 550), second.code); 951 + try std.testing.expectEqualStrings("5.2.1 Mailbox disabled", second.text); 952 + try std.testing.expectEqual(@as(?Reply, null), try verdicts.next()); 953 + 954 + try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "LHLO client.example.org\r\n")); 955 + } 956 + 957 + test "end reports an LMTP rejection distinctly from an SMTP one" { 958 + const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n354 End data\r\n" ++ 959 + "250 2.0.0 Ok\r\n550 5.2.1 Mailbox disabled\r\n"; 960 + var reader: Io.Reader = .fixed(responses); 961 + var out_buf: [512]u8 = undefined; 962 + var writer: Io.Writer = .fixed(&out_buf); 963 + var reply_buf: [256]u8 = undefined; 964 + var client: Client = .init(&reader, &writer, &reply_buf); 965 + client.mode = .lmtp; 966 + 967 + try client.mailFrom("alice@example.com"); 968 + try client.rcptTo("good@example.net"); 969 + try client.rcptTo("bad@example.net"); 970 + // Both verdicts are read even though the first already decided the 971 + // outcome, or the next command would be answered by a stale reply. 972 + try std.testing.expectError(error.RecipientRejected, client.sendMessage("hi\r\n")); 973 + 974 + // The single-reply case keeps `error.UnexpectedReply`, where 975 + // `last_reply` can actually say what happened. 976 + var smtp_reader: Io.Reader = .fixed("354 End data\r\n550 5.7.1 Rejected\r\n"); 977 + var smtp_out: [256]u8 = undefined; 978 + var smtp_writer: Io.Writer = .fixed(&smtp_out); 979 + var smtp_reply_buf: [256]u8 = undefined; 980 + var smtp: Client = .init(&smtp_reader, &smtp_writer, &smtp_reply_buf); 981 + try std.testing.expectError(error.UnexpectedReply, smtp.sendMessage("hi\r\n")); 982 + try std.testing.expectEqualStrings("5.7.1 Rejected", smtp.last_reply.?.text); 983 + } 984 + 985 + test "the recipient count resets with each new transaction" { 986 + const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n" ++ // MAIL, RCPT 987 + "250 2.0.0 Ok\r\n" ++ // RSET 988 + "250 2.1.0 Ok\r\n"; // MAIL again 989 + var reader: Io.Reader = .fixed(responses); 990 + var out_buf: [512]u8 = undefined; 991 + var writer: Io.Writer = .fixed(&out_buf); 992 + var reply_buf: [256]u8 = undefined; 993 + var client: Client = .init(&reader, &writer, &reply_buf); 994 + client.mode = .lmtp; 995 + 996 + try client.mailFrom("alice@example.com"); 997 + try client.rcptTo("bob@example.net"); 998 + try std.testing.expectEqual(@as(usize, 1), client.results().remaining); 999 + try client.rset(); 1000 + try std.testing.expectEqual(@as(usize, 0), client.results().remaining); 1001 + try client.mailFrom("alice@example.com"); 1002 + try std.testing.expectEqual(@as(usize, 0), client.results().remaining); 819 1003 } 820 1004 821 1005 test "mail and rcpt carry the DSN parameters" {
+321 -110
src/Server.zig
··· 35 35 tls_write_buffer: [4096]u8 = undefined, 36 36 37 37 pub const Options = struct { 38 + /// Which protocol the session speaks. See `Protocol`. 39 + protocol: Protocol = .smtp, 38 40 /// Hostname announced in the greeting and the EHLO response. 39 41 hostname: []const u8 = "localhost", 40 42 /// Advertised via the SIZE extension and enforced during DATA. ··· 49 51 /// Reject MAIL with 530 until the client has authenticated. Requires a 50 52 /// handler with an `authenticate` callback. 51 53 require_auth: bool = false, 54 + }; 55 + 56 + /// SMTP, or its local-delivery sibling LMTP 57 + /// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)). 58 + pub const Protocol = enum { 59 + smtp, 60 + /// LMTP differs from SMTP in two ways that matter here: the greeting is 61 + /// `LHLO` and `HELO`/`EHLO` are refused, and the end of a message is 62 + /// answered with one reply per accepted recipient instead of one for 63 + /// the message. It exists so that a delivery agent can report a 64 + /// different outcome for each mailbox, which SMTP gives no way to say. 65 + /// 66 + /// RFC 2033 §5 forbids running it on TCP port 25 and advises against 67 + /// wide-area use at all: it is for the hop between a queueing MTA and 68 + /// the thing that writes to mailboxes. 69 + lmtp, 52 70 }; 53 71 54 72 pub const TlsOptions = struct { ··· 123 141 pub const Body = enum { unspecified, seven_bit, eight_bit_mime }; 124 142 }; 125 143 144 + /// What a mail transaction accumulates between MAIL and the end of the 145 + /// message. Kept together so that resetting it cannot forget a field — 146 + /// RSET, a completed message and a new (L)HLO all discard the lot. 147 + const Transaction = struct { 148 + from: ?[]const u8 = null, 149 + recipients: std.ArrayList(Recipient) = .empty, 150 + declared_size: ?u64 = null, 151 + body: Envelope.Body = .unspecified, 152 + smtputf8: bool = false, 153 + ret: ?protocol.Ret = null, 154 + envid: ?[]const u8 = null, 155 + 156 + /// The memory all of this points into is the session arena, which the 157 + /// caller resets alongside. 158 + fn clear(t: *Transaction) void { 159 + t.* = .{}; 160 + } 161 + 162 + fn envelope(t: Transaction) Envelope { 163 + return .{ 164 + .from = t.from.?, 165 + .recipients = t.recipients.items, 166 + .declared_size = t.declared_size, 167 + .body = t.body, 168 + .smtputf8 = t.smtputf8, 169 + .ret = t.ret, 170 + .envid = t.envid, 171 + }; 172 + } 173 + }; 174 + 126 175 /// Callbacks invoked during a session. All slices passed to callbacks are 127 176 /// only valid for the duration of the call. 128 177 pub const Handler = struct { ··· 143 192 /// CRLF line endings and dot-stuffing already removed. Exactly one 144 193 /// of `message` and `messageReader` must be set. 145 194 message: ?*const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision = null, 195 + /// LMTP only: the verdict for one recipient of the message just 196 + /// received, `envelope.recipients[index]`, called once per accepted 197 + /// recipient after `message` or `messageReader` has returned 198 + /// `.accept`. This is what LMTP exists for — one mailbox can be 199 + /// full while another is fine — so a `.lmtp` session without it 200 + /// answers every recipient identically and gains nothing over SMTP. 201 + /// 202 + /// Not called when the message itself was rejected: that verdict 203 + /// applies to every recipient and is sent for each of them. 204 + recipientResult: ?*const fn (context: ?*anyopaque, envelope: Envelope, index: usize) Decision = null, 146 205 /// Streaming alternative to `message`: called after DATA with a 147 206 /// reader that yields the message content (dot-stuffing removed, 148 207 /// line endings normalized to CRLF) until end of stream. Anything ··· 177 236 178 237 var greeted = false; 179 238 var authenticated = false; 180 - var from: ?[]const u8 = null; 181 - var recipients: std.ArrayList(Recipient) = .empty; 182 - var declared_size: ?u64 = null; 183 - var body: Envelope.Body = .unspecified; 184 - var smtputf8 = false; 185 - var ret: ?protocol.Ret = null; 186 - var envid: ?[]const u8 = null; 239 + var transaction: Transaction = .{}; 187 240 188 241 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname}); 189 242 try s.writer.flush(); ··· 204 257 }; 205 258 switch (command) { 206 259 .helo => { 260 + // RFC 2033 §4: an LMTP server must not answer HELO or EHLO 261 + // with a positive completion, and 500 is what it suggests. 262 + if (s.options.protocol == .lmtp) { 263 + try s.reply(500, "5.5.1 This is LMTP, use LHLO"); 264 + continue; 265 + } 207 266 greeted = true; 208 - from = null; 209 - recipients = .empty; 210 - declared_size = null; 211 - body = .unspecified; 212 - smtputf8 = false; 213 - ret = null; 214 - envid = null; 267 + transaction.clear(); 215 268 _ = arena_state.reset(.retain_capacity); 216 269 try s.reply(250, s.options.hostname); 217 270 }, 218 271 .ehlo => { 219 - greeted = true; 220 - from = null; 221 - recipients = .empty; 222 - declared_size = null; 223 - body = .unspecified; 224 - smtputf8 = false; 225 - ret = null; 226 - envid = null; 227 - _ = arena_state.reset(.retain_capacity); 228 - // Every reply carries an enhanced status code (RFC 3463), so 229 - // the ENHANCEDSTATUSCODES extension (RFC 2034) is advertised. 230 - try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n", .{s.options.hostname}); 231 - if (s.options.tls) |config| { 232 - if (config.mode == .starttls and !s.secured) 233 - try s.writer.writeAll("250-STARTTLS\r\n"); 272 + if (s.options.protocol == .lmtp) { 273 + try s.reply(500, "5.5.1 This is LMTP, use LHLO"); 274 + continue; 234 275 } 235 - if (s.handler.vtable.authenticate != null and !authenticated) 236 - try s.writer.writeAll("250-AUTH PLAIN LOGIN\r\n"); 237 - try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); 238 - try s.writer.flush(); 276 + greeted = true; 277 + transaction.clear(); 278 + _ = arena_state.reset(.retain_capacity); 279 + try s.greetExtended(authenticated); 280 + }, 281 + .lhlo => { 282 + if (s.options.protocol == .smtp) { 283 + try s.reply(500, "5.5.2 Command not recognized"); 284 + continue; 285 + } 286 + greeted = true; 287 + transaction.clear(); 288 + _ = arena_state.reset(.retain_capacity); 289 + try s.greetExtended(authenticated); 239 290 }, 240 291 .mail => |args| { 241 292 if (!greeted) { ··· 246 297 try s.reply(530, "5.7.0 Authentication required"); 247 298 continue; 248 299 } 249 - if (from != null) { 300 + if (transaction.from != null) { 250 301 try s.reply(503, "5.5.1 Nested MAIL command"); 251 302 continue; 252 303 } ··· 324 375 }, 325 376 } 326 377 } 327 - from = try arena.dupe(u8, args.path); 328 - declared_size = mail_declared_size; 329 - body = mail_body; 330 - smtputf8 = mail_smtputf8; 331 - ret = mail_ret; 332 - envid = mail_envid; 378 + transaction.from = try arena.dupe(u8, args.path); 379 + transaction.declared_size = mail_declared_size; 380 + transaction.body = mail_body; 381 + transaction.smtputf8 = mail_smtputf8; 382 + transaction.ret = mail_ret; 383 + transaction.envid = mail_envid; 333 384 try s.reply(250, "2.1.0 Ok"); 334 385 }, 335 386 .rcpt => |args| { 336 - if (from == null) { 387 + if (transaction.from == null) { 337 388 try s.reply(503, "5.5.1 Need MAIL command first"); 338 389 continue; 339 390 } ··· 366 417 } 367 418 } 368 419 if (!params_ok) continue; 369 - if (!try s.validateAddress(args.path, smtputf8)) continue; 370 - if (recipients.items.len >= s.options.max_recipients) { 420 + if (!try s.validateAddress(args.path, transaction.smtputf8)) continue; 421 + if (transaction.recipients.items.len >= s.options.max_recipients) { 371 422 try s.reply(452, "4.5.3 Too many recipients"); 372 423 continue; 373 424 } ··· 382 433 } 383 434 recipient.address = try arena.dupe(u8, args.path); 384 435 if (recipient.orcpt) |*orcpt| orcpt.addr_type = try arena.dupe(u8, orcpt.addr_type); 385 - try recipients.append(arena, recipient); 436 + try transaction.recipients.append(arena, recipient); 386 437 try s.reply(250, "2.1.5 Ok"); 387 438 }, 388 439 .data => { 389 - if (recipients.items.len == 0) { 440 + if (transaction.recipients.items.len == 0) { 390 441 try s.reply(503, "5.5.1 Need RCPT command first"); 391 442 continue; 392 443 } 393 - try s.receiveData(arena, .{ 394 - .from = from.?, 395 - .recipients = recipients.items, 396 - .declared_size = declared_size, 397 - .body = body, 398 - .smtputf8 = smtputf8, 399 - .ret = ret, 400 - .envid = envid, 401 - }); 402 - from = null; 403 - recipients = .empty; 404 - declared_size = null; 405 - body = .unspecified; 406 - smtputf8 = false; 407 - ret = null; 408 - envid = null; 444 + try s.receiveData(arena, transaction.envelope()); 445 + transaction.clear(); 409 446 _ = arena_state.reset(.retain_capacity); 410 447 }, 411 448 .bdat => |args| { 412 - if (recipients.items.len == 0) { 449 + if (transaction.recipients.items.len == 0) { 413 450 // The chunk's octets follow regardless; consume them to 414 451 // keep the length-framed stream in sync. 415 452 s.reader.discardAll64(args.size) catch |err| switch (err) { ··· 419 456 try s.reply(503, "5.5.1 Need RCPT command first"); 420 457 continue; 421 458 } 422 - const outcome = try s.receiveChunked(arena, .{ 423 - .from = from.?, 424 - .recipients = recipients.items, 425 - .declared_size = declared_size, 426 - .body = body, 427 - .smtputf8 = smtputf8, 428 - .ret = ret, 429 - .envid = envid, 430 - }, args); 431 - from = null; 432 - recipients = .empty; 433 - declared_size = null; 434 - body = .unspecified; 435 - smtputf8 = false; 436 - ret = null; 437 - envid = null; 459 + const outcome = try s.receiveChunked(arena, transaction.envelope(), args); 460 + transaction.clear(); 438 461 _ = arena_state.reset(.retain_capacity); 439 462 switch (outcome) { 440 463 .done => {}, ··· 442 465 } 443 466 }, 444 467 .rset => { 445 - from = null; 446 - recipients = .empty; 447 - declared_size = null; 448 - body = .unspecified; 449 - smtputf8 = false; 450 - ret = null; 451 - envid = null; 468 + transaction.clear(); 452 469 _ = arena_state.reset(.retain_capacity); 453 470 try s.reply(250, "2.0.0 Ok"); 454 471 }, ··· 474 491 // the client must EHLO again. 475 492 greeted = false; 476 493 authenticated = false; 477 - from = null; 478 - recipients = .empty; 479 - declared_size = null; 480 - body = .unspecified; 481 - smtputf8 = false; 482 - ret = null; 483 - envid = null; 494 + transaction.clear(); 484 495 _ = arena_state.reset(.retain_capacity); 485 496 }, 486 497 .quit => { ··· 501 512 try s.reply(503, "5.5.1 Already authenticated"); 502 513 continue; 503 514 } 504 - if (from != null) { 515 + if (transaction.from != null) { 505 516 try s.reply(503, "5.5.1 MAIL transaction in progress"); 506 517 continue; 507 518 } ··· 514 525 .unknown => try s.reply(500, "5.5.2 Command not recognized"), 515 526 } 516 527 } 528 + } 529 + 530 + /// Writes the EHLO or LHLO response: the hostname, then one line per 531 + /// extension. The two are the same list — RFC 2033 gives LHLO the semantics 532 + /// of EHLO — and it requires PIPELINING and ENHANCEDSTATUSCODES of an LMTP 533 + /// server, both of which are here for every session anyway. 534 + fn greetExtended(s: *Server, authenticated: bool) error{WriteFailed}!void { 535 + // Every reply carries an enhanced status code (RFC 3463), so the 536 + // ENHANCEDSTATUSCODES extension (RFC 2034) is advertised. 537 + try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n", .{s.options.hostname}); 538 + if (s.options.tls) |config| { 539 + if (config.mode == .starttls and !s.secured) 540 + try s.writer.writeAll("250-STARTTLS\r\n"); 541 + } 542 + if (s.handler.vtable.authenticate != null and !authenticated) 543 + try s.writer.writeAll("250-AUTH PLAIN LOGIN\r\n"); 544 + try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); 545 + try s.writer.flush(); 517 546 } 518 547 519 548 /// Performs the server-side TLS handshake over the current transport and ··· 688 717 .quit, .disconnected => return .end_session, 689 718 .transport_failure => return error.ReadFailed, 690 719 }; 691 - switch (decision) { 692 - .accept => try s.reply(250, "2.0.0 Ok, message accepted"), 693 - .reject => |r| try s.reply(r.code, r.text), 694 - } 720 + try s.replyMessage(envelope, decision); 695 721 return .done; 696 722 } 697 723 ··· 756 782 try s.reply(552, "5.3.4 Message exceeds maximum size"); 757 783 return .done; 758 784 } 759 - switch (s.handler.vtable.message.?(s.handler.context, envelope, data.items)) { 760 - .accept => try s.reply(250, "2.0.0 Ok, message accepted"), 761 - .reject => |r| try s.reply(r.code, r.text), 762 - } 785 + try s.replyMessage(envelope, s.handler.vtable.message.?(s.handler.context, envelope, data.items)); 763 786 return .done; 764 787 } 765 788 ··· 878 901 }; 879 902 if (std.mem.eql(u8, line, ".")) break; 880 903 } 881 - switch (decision) { 882 - .accept => try s.reply(250, "2.0.0 Ok, message accepted"), 883 - .reject => |r| try s.reply(r.code, r.text), 884 - } 904 + try s.replyMessage(envelope, decision); 885 905 return; 886 906 } 887 907 ··· 914 934 try s.reply(552, "5.3.4 Message exceeds maximum size"); 915 935 return; 916 936 } 917 - switch (s.handler.vtable.message.?(s.handler.context, envelope, data.items)) { 918 - .accept => try s.reply(250, "2.0.0 Ok, message accepted"), 919 - .reject => |r| try s.reply(r.code, r.text), 920 - } 937 + try s.replyMessage(envelope, s.handler.vtable.message.?(s.handler.context, envelope, data.items)); 921 938 } 922 939 923 940 /// Adapts the session's line-based DATA phase into an `Io.Reader` of the ··· 980 997 } 981 998 982 999 fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 983 - try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text }); 1000 + try s.replyLine(code, text); 984 1001 try s.writer.flush(); 1002 + } 1003 + 1004 + /// A reply without the flush, for when several are going out together. 1005 + fn replyLine(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 1006 + try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text }); 1007 + } 1008 + 1009 + /// Answers a completed message. 1010 + /// 1011 + /// SMTP gets one reply. LMTP gets one for each previously successful RCPT, 1012 + /// in the order they were issued 1013 + /// ([RFC 2033 §4.2](https://datatracker.ietf.org/doc/html/rfc2033#section-4.2)) 1014 + /// — including a repeat for a recipient named twice, which is why this 1015 + /// walks the accepted list rather than a set of addresses. 1016 + fn replyMessage(s: *Server, envelope: Envelope, decision: Decision) error{WriteFailed}!void { 1017 + if (s.options.protocol == .smtp) { 1018 + try s.writeVerdict(decision); 1019 + try s.writer.flush(); 1020 + return; 1021 + } 1022 + for (envelope.recipients, 0..) |_, index| { 1023 + // A rejected message is rejected for everybody; there is nothing 1024 + // left to ask about an individual recipient. 1025 + const verdict: Decision = switch (decision) { 1026 + .reject => decision, 1027 + .accept => if (s.handler.vtable.recipientResult) |callback| 1028 + callback(s.handler.context, envelope, index) 1029 + else 1030 + .accept, 1031 + }; 1032 + try s.writeVerdict(verdict); 1033 + } 1034 + try s.writer.flush(); 1035 + } 1036 + 1037 + fn writeVerdict(s: *Server, decision: Decision) error{WriteFailed}!void { 1038 + switch (decision) { 1039 + .accept => try s.replyLine(250, "2.0.0 Ok, message accepted"), 1040 + .reject => |r| try s.replyLine(r.code, r.text), 1041 + } 985 1042 } 986 1043 987 1044 /// Discards input through the next newline after `error.LineTooLong`, which ··· 999 1056 data: std.ArrayList(u8) = .empty, 1000 1057 messages_accepted: usize = 0, 1001 1058 reject_recipient: ?[]const u8 = null, 1059 + /// Accepted at RCPT time and then failed per-recipient at the end of 1060 + /// the message, which only LMTP can express. 1061 + fail_delivery: ?[]const u8 = null, 1062 + /// Returned for the message as a whole, before any per-recipient 1063 + /// verdict is asked for. 1064 + reject_message: ?Decision.Rejection = null, 1002 1065 declared_size: ?u64 = null, 1003 1066 body: Envelope.Body = .unspecified, 1004 1067 smtputf8: bool = false, ··· 1029 1092 .authenticate = onAuthenticate, 1030 1093 .rcptTo = onRcptTo, 1031 1094 .message = onMessage, 1095 + .recipientResult = onRecipientResult, 1032 1096 } else &.{ 1033 1097 .rcptTo = onRcptTo, 1034 1098 .message = onMessage, 1099 + .recipientResult = onRecipientResult, 1035 1100 } }; 1101 + } 1102 + 1103 + /// LMTP's per-recipient verdict: everybody is fine except the one 1104 + /// address `fail_delivery` names, which is the outcome that has no 1105 + /// spelling in SMTP. 1106 + fn onRecipientResult(context: ?*anyopaque, envelope: Envelope, index: usize) Decision { 1107 + const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1108 + const failing = h.fail_delivery orelse return .accept; 1109 + if (std.mem.eql(u8, envelope.recipients[index].address, failing)) 1110 + return .{ .reject = .{ .code = 550, .text = "5.2.1 Mailbox disabled" } }; 1111 + return .accept; 1036 1112 } 1037 1113 1038 1114 fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool { ··· 1061 1137 1062 1138 fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision { 1063 1139 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1140 + if (h.reject_message) |rejection| return .{ .reject = rejection }; 1064 1141 const gpa = std.testing.allocator; 1065 1142 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} }; 1066 1143 for (envelope.recipients) |recipient| { ··· 1084 1161 var session: Server = .init(&reader, &writer, handler, options); 1085 1162 try session.run(std.testing.allocator); 1086 1163 return writer.buffered(); 1164 + } 1165 + 1166 + test "LMTP answers once per accepted recipient" { 1167 + var h: TestHandler = .{ .fail_delivery = "bad@example.net" }; 1168 + defer h.deinit(); 1169 + 1170 + var out_buf: [2048]u8 = undefined; 1171 + const out = try runScript( 1172 + "LHLO client.example.org\r\n" ++ 1173 + "MAIL FROM:<alice@example.com>\r\n" ++ 1174 + "RCPT TO:<good@example.net>\r\n" ++ 1175 + "RCPT TO:<bad@example.net>\r\n" ++ 1176 + // RFC 2033 §4.2 is explicit that a repeated forward-path still 1177 + // gets a reply of its own. 1178 + "RCPT TO:<good@example.net>\r\n" ++ 1179 + "DATA\r\nhi\r\n.\r\nQUIT\r\n", 1180 + &out_buf, 1181 + h.handler(), 1182 + .{ .protocol = .lmtp, .hostname = "mx.test" }, 1183 + ); 1184 + 1185 + const tail = out[std.mem.indexOf(u8, out, "354").?..]; 1186 + try std.testing.expectEqualStrings( 1187 + "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 1188 + "250 2.0.0 Ok, message accepted\r\n" ++ 1189 + "550 5.2.1 Mailbox disabled\r\n" ++ 1190 + "250 2.0.0 Ok, message accepted\r\n" ++ 1191 + "221 2.0.0 Bye\r\n", 1192 + tail, 1193 + ); 1194 + } 1195 + 1196 + test "a message rejected outright is rejected for every LMTP recipient" { 1197 + var h: TestHandler = .{ 1198 + .reject_message = .{ .code = 452, .text = "4.3.1 Out of storage" }, 1199 + }; 1200 + defer h.deinit(); 1201 + 1202 + var out_buf: [2048]u8 = undefined; 1203 + const out = try runScript( 1204 + "LHLO client.example.org\r\n" ++ 1205 + "MAIL FROM:<alice@example.com>\r\n" ++ 1206 + "RCPT TO:<a@example.net>\r\n" ++ 1207 + "RCPT TO:<b@example.net>\r\n" ++ 1208 + "DATA\r\nhi\r\n.\r\nQUIT\r\n", 1209 + &out_buf, 1210 + h.handler(), 1211 + .{ .protocol = .lmtp, .hostname = "mx.test" }, 1212 + ); 1213 + 1214 + const tail = out[std.mem.indexOf(u8, out, "354").?..]; 1215 + try std.testing.expectEqualStrings( 1216 + "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 1217 + "452 4.3.1 Out of storage\r\n" ++ 1218 + "452 4.3.1 Out of storage\r\n" ++ 1219 + "221 2.0.0 Bye\r\n", 1220 + tail, 1221 + ); 1222 + } 1223 + 1224 + test "BDAT LAST also answers once per LMTP recipient" { 1225 + var h: TestHandler = .{ .fail_delivery = "bad@example.net" }; 1226 + defer h.deinit(); 1227 + 1228 + var out_buf: [2048]u8 = undefined; 1229 + const out = try runScript( 1230 + "LHLO client.example.org\r\n" ++ 1231 + "MAIL FROM:<alice@example.com>\r\n" ++ 1232 + "RCPT TO:<good@example.net>\r\n" ++ 1233 + "RCPT TO:<bad@example.net>\r\n" ++ 1234 + "BDAT 4 LAST\r\nhi\r\nQUIT\r\n", 1235 + &out_buf, 1236 + h.handler(), 1237 + .{ .protocol = .lmtp, .hostname = "mx.test" }, 1238 + ); 1239 + 1240 + const tail = out[std.mem.lastIndexOf(u8, out, "250 2.1.5 Ok\r\n").? + "250 2.1.5 Ok\r\n".len ..]; 1241 + try std.testing.expectEqualStrings( 1242 + "250 2.0.0 Ok, message accepted\r\n" ++ 1243 + "550 5.2.1 Mailbox disabled\r\n" ++ 1244 + "221 2.0.0 Bye\r\n", 1245 + tail, 1246 + ); 1247 + } 1248 + 1249 + test "each protocol refuses the other's greeting" { 1250 + var h: TestHandler = .{}; 1251 + defer h.deinit(); 1252 + 1253 + var out_buf: [2048]u8 = undefined; 1254 + // RFC 2033 §4: an LMTP server must not answer HELO or EHLO positively. 1255 + const lmtp = try runScript( 1256 + "EHLO client.example.org\r\nHELO client.example.org\r\nQUIT\r\n", 1257 + &out_buf, 1258 + h.handler(), 1259 + .{ .protocol = .lmtp, .hostname = "mx.test" }, 1260 + ); 1261 + try std.testing.expectEqualStrings( 1262 + "220 mx.test ESMTP ready\r\n" ++ 1263 + "500 5.5.1 This is LMTP, use LHLO\r\n" ++ 1264 + "500 5.5.1 This is LMTP, use LHLO\r\n" ++ 1265 + "221 2.0.0 Bye\r\n", 1266 + lmtp, 1267 + ); 1268 + 1269 + var smtp_buf: [2048]u8 = undefined; 1270 + const smtp = try runScript( 1271 + "LHLO client.example.org\r\nQUIT\r\n", 1272 + &smtp_buf, 1273 + h.handler(), 1274 + .{ .hostname = "mx.test" }, 1275 + ); 1276 + try std.testing.expectEqualStrings( 1277 + "220 mx.test ESMTP ready\r\n" ++ 1278 + "500 5.5.2 Command not recognized\r\n" ++ 1279 + "221 2.0.0 Bye\r\n", 1280 + smtp, 1281 + ); 1282 + } 1283 + 1284 + test "LHLO advertises what LMTP requires" { 1285 + var h: TestHandler = .{}; 1286 + defer h.deinit(); 1287 + 1288 + var out_buf: [2048]u8 = undefined; 1289 + const out = try runScript( 1290 + "LHLO client.example.org\r\nQUIT\r\n", 1291 + &out_buf, 1292 + h.handler(), 1293 + .{ .protocol = .lmtp, .hostname = "mx.test" }, 1294 + ); 1295 + // RFC 2033 §5 requires both of these of an LMTP server. 1296 + try std.testing.expect(std.mem.indexOf(u8, out, "250-PIPELINING\r\n") != null); 1297 + try std.testing.expect(std.mem.indexOf(u8, out, "250-ENHANCEDSTATUSCODES\r\n") != null); 1087 1298 } 1088 1299 1089 1300 test "DSN parameters reach the handler" {
+74 -6
src/main.zig
··· 8 8 //! [--auth-method plain|login|cram-md5] 9 9 //! [--ret full|hdrs] [--envid <id>] 10 10 //! [--notify never|success,failure,delay] [--orcpt <address>] 11 - //! <host> <port> <from> <to>... 11 + //! [--lmtp] <host> <port> <from> <to>... 12 12 //! send a message read from stdin; --tls speaks TLS from the first 13 13 //! byte (port 465 style), --starttls upgrades after EHLO (port 587 14 14 //! style), --insecure skips certificate verification, --user/--password ··· 18 18 //! options (RFC 3461) are --ret and --envid on the message and 19 19 //! --notify and --orcpt on every recipient 20 20 //! zsmtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] 21 - //! [--auth <user>:<pass>] <port> 21 + //! [--auth <user>:<pass>] [--lmtp [--fail-delivery <address>]] 22 + //! <port> 22 23 //! run a debug server on 127.0.0.1 that prints received messages; 23 24 //! --auth requires authentication with the given credentials; with a 24 25 //! certificate and key it advertises and accepts STARTTLS, or speaks 25 - //! TLS from the first byte with --implicit-tls 26 + //! TLS from the first byte with --implicit-tls; --lmtp speaks LMTP 27 + //! instead of SMTP, where --fail-delivery names one recipient to 28 + //! report as undeliverable at the end of the message 26 29 27 30 const std = @import("std"); 28 31 const Io = std.Io; ··· 49 52 config.chunking = true; 50 53 } else if (std.mem.eql(u8, rest[0], "--smtputf8")) { 51 54 config.smtputf8 = true; 55 + } else if (std.mem.eql(u8, rest[0], "--lmtp")) { 56 + config.protocol = .lmtp; 52 57 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--ret")) { 53 58 config.ret = zsmtp.protocol.Ret.parse(rest[1]) catch return usage(); 54 59 rest = rest[1..]; ··· 101 106 rest = rest[1..]; 102 107 } else if (std.mem.eql(u8, rest[0], "--implicit-tls")) { 103 108 config.implicit_tls = true; 109 + } else if (std.mem.eql(u8, rest[0], "--lmtp")) { 110 + config.protocol = .lmtp; 111 + } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--fail-delivery")) { 112 + config.fail_delivery = rest[1]; 113 + rest = rest[1..]; 104 114 } else { 105 115 return usage(); 106 116 } ··· 109 119 if (rest.len != 1) return usage(); 110 120 if ((config.cert_path == null) != (config.key_path == null)) return usage(); 111 121 if (config.implicit_tls and config.cert_path == null) return usage(); 122 + if (config.fail_delivery != null and config.protocol != .lmtp) return usage(); 112 123 return serve(io, arena, config, rest[0]); 113 124 } 114 125 return usage(); ··· 118 129 cert_path: ?[]const u8 = null, 119 130 key_path: ?[]const u8 = null, 120 131 implicit_tls: bool = false, 132 + protocol: zsmtp.Server.Protocol = .smtp, 133 + /// Accepted at RCPT time and then failed at the end of the message, 134 + /// which only LMTP can say. 135 + fail_delivery: ?[]const u8 = null, 121 136 username: ?[]const u8 = null, 122 137 password: ?[]const u8 = null, 123 138 }; ··· 128 143 allow_cleartext_auth: bool = false, 129 144 chunking: bool = false, 130 145 smtputf8: bool = false, 146 + protocol: zsmtp.Client.Protocol = .smtp, 131 147 ret: ?zsmtp.protocol.Ret = null, 132 148 envid: ?[]const u8 = null, 133 149 notify: ?zsmtp.protocol.Notify = null, ··· 145 161 \\ [--auth-method plain|login|cram-md5] 146 162 \\ [--ret full|hdrs] [--envid <id>] 147 163 \\ [--notify never|success,failure,delay] [--orcpt <address>] 148 - \\ <host> <port> <from> <to>... 164 + \\ [--lmtp] <host> <port> <from> <to>... 149 165 \\ (message is read from stdin) 150 166 \\ zsmtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] 151 - \\ [--auth <user>:<pass>] <port> 167 + \\ [--auth <user>:<pass>] [--lmtp [--fail-delivery <address>]] 168 + \\ <port> 152 169 , .{}); 153 170 std.process.exit(1); 154 171 } ··· 190 207 var reply_buf: [1024]u8 = undefined; 191 208 var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 192 209 client.allow_cleartext_auth = config.allow_cleartext_auth; 210 + client.mode = config.protocol; 193 211 194 212 if (config.mode == .tls) { 195 213 try tls.init(io, arena, &stream_reader.interface, &stream_writer.interface, tls_options); ··· 293 311 } 294 312 try client.bdat("", true); 295 313 } else { 296 - try client.sendMessageReader(message); 314 + var data_writer = try client.data(); 315 + while (true) { 316 + const chunk = message.peekGreedy(1) catch |err| switch (err) { 317 + error.EndOfStream => break, 318 + error.ReadFailed => return error.ReadFailed, 319 + }; 320 + try data_writer.interface.writeAll(chunk); 321 + message.toss(chunk.len); 322 + } 323 + // In LMTP there is one verdict per recipient rather than one for 324 + // the message, and reporting them individually is the only reason 325 + // to be speaking it. 326 + var verdicts = try data_writer.endResults(); 327 + var failed = false; 328 + while (try verdicts.next()) |reply| { 329 + if (config.protocol == .lmtp) { 330 + std.log.info("{s}: {d} {s}", .{ 331 + recipients[verdicts.index - 1], 332 + reply.code, 333 + reply.text, 334 + }); 335 + } 336 + if (!reply.isPositiveCompletion()) failed = true; 337 + } 338 + // Each verdict was reported above, so the error only has to say 339 + // that one of them was a refusal. 340 + if (failed) return if (config.protocol == .lmtp) 341 + error.RecipientRejected 342 + else 343 + error.UnexpectedReply; 297 344 } 298 345 } 299 346 ··· 327 374 .out = &stdout.interface, 328 375 .username = config.username, 329 376 .password = config.password, 377 + .fail_delivery = config.fail_delivery, 330 378 }; 331 379 while (true) { 332 380 const stream = try listener.accept(io); ··· 344 392 .{ .context = &printer, .vtable = if (config.username != null) &.{ 345 393 .authenticate = MessagePrinter.onAuthenticate, 346 394 .message = MessagePrinter.onMessage, 395 + .recipientResult = MessagePrinter.onRecipientResult, 347 396 } else &.{ 348 397 .message = MessagePrinter.onMessage, 398 + .recipientResult = MessagePrinter.onRecipientResult, 349 399 } }, 350 400 .{ 401 + .protocol = config.protocol, 351 402 .hostname = "localhost", 352 403 .tls = tls_options, 353 404 .require_auth = config.username != null, ··· 363 414 out: *Io.Writer, 364 415 username: ?[]const u8 = null, 365 416 password: ?[]const u8 = null, 417 + fail_delivery: ?[]const u8 = null, 366 418 367 419 fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool { 368 420 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); ··· 374 426 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 375 427 printer.print(envelope, data) catch 376 428 return .{ .reject = .{ .code = 451, .text = "4.3.0 Local error" } }; 429 + return .accept; 430 + } 431 + 432 + /// LMTP's per-recipient verdict. Everything was already printed by 433 + /// `onMessage`; this only reports the one address `--fail-delivery` 434 + /// names as undeliverable, which is the outcome SMTP has no way to 435 + /// express for one recipient out of several. 436 + fn onRecipientResult( 437 + context: ?*anyopaque, 438 + envelope: zsmtp.Server.Envelope, 439 + index: usize, 440 + ) zsmtp.Server.Decision { 441 + const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 442 + const failing = printer.fail_delivery orelse return .accept; 443 + if (std.mem.eql(u8, envelope.recipients[index].address, failing)) 444 + return .{ .reject = .{ .code = 550, .text = "5.2.1 Mailbox disabled" } }; 377 445 return .accept; 378 446 } 379 447
+10 -1
src/protocol.zig
··· 167 167 pub const Command = union(enum) { 168 168 helo: []const u8, 169 169 ehlo: []const u8, 170 + /// LHLO, the LMTP greeting 171 + /// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)), which 172 + /// has the same semantics as EHLO. An LMTP server takes this one and 173 + /// refuses HELO and EHLO; an SMTP server does the reverse. 174 + lhlo: []const u8, 170 175 /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`). 171 176 mail: PathArgs, 172 177 /// RCPT TO. ··· 235 240 if (ieql(verb, "EHLO")) { 236 241 if (rest.len == 0) return error.Syntax; 237 242 return .{ .ehlo = rest }; 243 + } 244 + if (ieql(verb, "LHLO")) { 245 + if (rest.len == 0) return error.Syntax; 246 + return .{ .lhlo = rest }; 238 247 } 239 248 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") }; 240 249 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") }; ··· 826 835 const command = Command.parse(line) catch return; 827 836 // Payload slices must always lie within the parsed line. 828 837 switch (command) { 829 - .helo, .ehlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len), 838 + .helo, .ehlo, .lhlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len), 830 839 .mail, .rcpt => |args| { 831 840 try std.testing.expect(args.path.len <= line.len); 832 841 try std.testing.expect(args.params.len <= line.len);