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.

Write down the known gaps, and implement RFC 3461 DSN

The README grows a "Known gaps" section, because the list was being
rediscovered each time somebody asked what zsmtp does not do. It opens
with what is deliberately absent -- message composition, and everything an
MTA does around a session -- so that the rest reads as a list of things to
do rather than a list of complaints.

The first of them is now done. DSN is the SMTP extension of RFC 3461 and
nothing else: `RET` and `ENVID` on MAIL, `NOTIFY` and `ORCPT` on RCPT.
Generating the `multipart/report` that carries a delivery status back to
the sender is RFC 3464, which is message composition wearing a protocol
hat, and it stays out.

The server advertises DSN, validates all four parameters and answers a bad
one with 501 as RFC 3461 §6 asks. `RET` and `ENVID` land on the
`Envelope`; `NOTIFY` and `ORCPT` belong to a recipient rather than a
message, so `Envelope.recipients` is now a slice of `Recipient` and the
`rcptTo` callback receives one instead of a bare address -- which is the
breaking part of this change, along with RCPT parameters no longer being
refused wholesale with 555.

On the client, `mail` and `rcpt` are the parameterized forms of `mailFrom`
and `rcptTo`, and `mailFromUtf8` becomes a wrapper over `mail`. The demo
CLI exposes the four as --ret, --envid, --notify and --orcpt.

Both `ENVID` and the `ORCPT` address are xtext (RFC 3461 §4), so that
codec is in `protocol`: `writeXtext` escapes everything that is not an
xchar, which means the encoded form can never end the command line and a
value from untrusted input is safe by construction rather than by
checking. Decoding is strict in the other direction -- a byte the encoder
was obliged to escape is rejected rather than passed through -- so one
sequence of bytes has one spelling. The length limits are on the encoded
form, which is why they are checked there: 100 characters for ENVID, 500
for the whole ORCPT parameter.

Verified against real implementations in the interop test, which now sends
the DSN parameters to postfix and to exim (told to advertise DSN, which it
does not do by default) and round-trips them through zsmtp's own server,
where the ENVID comes back with its space intact and the ORCPT with the
'+' that had to be encoded.

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

+828 -33
+118 -2
README.md
··· 76 76 try data_writer.end(); // terminates the message, reads the verdict 77 77 ``` 78 78 79 + `mail` and `rcpt` are the parameterized forms of `mailFrom` and `rcptTo`, 80 + carrying the ESMTP parameters the server advertised — today SMTPUTF8 and the 81 + DSN set of [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461): 82 + 83 + ```zig 84 + try client.mail("me@example.com", .{ .ret = .hdrs, .envid = "batch 7" }); 85 + try client.rcpt("bob@example.net", .{ 86 + .notify = .{ .on = .{ .failure = true, .delay = true } }, 87 + .orcpt = .{ .addr_type = "rfc822", .address = "team@example.net" }, 88 + }); 89 + ``` 90 + 91 + `ENVID` and the `ORCPT` address are xtext-encoded on the way out, so any 92 + bytes are safe to pass; the length limits RFC 3461 puts on the encoded form 93 + (100 and 500 characters) are checked and surface as 94 + `error.ArgumentTooLong`. Check `extensions.dsn` first — a conforming server 95 + answers an unrecognized parameter with 555. 96 + 79 97 When the server advertises CHUNKING (`extensions.chunking`), `bdat` and 80 98 `sendMessageChunked` transmit the message with length-framed BDAT chunks 81 99 instead of DATA — verbatim, with no dot-stuffing, so content must already ··· 154 172 .context = &my_state, 155 173 .vtable = &.{ 156 174 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN 157 - .rcptTo = onRcptTo, // optional; accept/reject each recipient 175 + .rcptTo = onRcptTo, // optional; accept/reject each Recipient 158 176 .message = onMessage, // required; receives envelope + message data 159 177 }, 160 178 }, .{ .hostname = "mx.example.com" }); ··· 179 197 are accepted, and unrecognized parameters get 555; the declared size and 180 198 body type reach the handler via `Envelope`. Listening, accepting, and 181 199 concurrency are up to the caller. 200 + 201 + DSN ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)) is 202 + advertised. `RET=` and `ENVID=` on MAIL arrive as `Envelope.ret` and 203 + `Envelope.envid`, and `NOTIFY=` and `ORCPT=` on RCPT arrive as 204 + `Recipient.notify` and `Recipient.orcpt` — at the `rcptTo` callback, which 205 + receives the whole `Recipient`, and again on the `Envelope` afterwards. The 206 + xtext values are decoded, the length limits enforced, and a malformed value 207 + answered with 501. Like everything else handed to a callback, those slices 208 + live only for the duration of the call; keep what you need by copying it. 182 209 183 210 To advertise and accept STARTTLS (TLS 1.3, via 184 211 [ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key ··· 228 255 zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 229 256 zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 230 257 258 + # Request a delivery status notification (RFC 3461): 259 + zsmtp send --ret hdrs --envid 'batch 7' --notify success,failure \ 260 + --orcpt team@example.net 127.0.0.1 2525 me@example.com you@example.net 261 + 231 262 # Authenticate. Over a plaintext connection this refuses PLAIN and LOGIN 232 263 # rather than put the password on the wire; --allow-cleartext-auth overrides 233 264 # that for a connection protected by other means: ··· 242 273 TLS and STARTTLS via `zsmtp.Tls`, and the server accepts both STARTTLS and 243 274 implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the 244 275 client and PLAIN and LOGIN on the server. Message bodies can be streamed on 245 - both sides, and the server validates MAIL parameters (SIZE=, BODY=). 276 + both sides, and the server validates MAIL and RCPT parameters (SIZE=, BODY=, 277 + and the DSN set RET=, ENVID=, NOTIFY=, ORCPT=). 278 + 279 + ## Known gaps 280 + 281 + Measured against the implementations people are likely to be coming from — 282 + Postfix, Exim and Haraka on the server side, Go's `net/smtp`, Python's 283 + `smtplib`, lettre and Nodemailer on the client side. Kept here so the list 284 + is one thing rather than a rediscovery each time. 285 + 286 + ### Out of scope, not missing 287 + 288 + - **Message composition.** No MIME builder, headers, attachments, transfer 289 + encodings, `Message-ID` or `Date` generation. zsmtp carries a message that 290 + already exists; building one is RFC 5322's job and belongs in a library of 291 + its own. 292 + - **DSN report generation** 293 + ([RFC 3464](https://datatracker.ietf.org/doc/html/rfc3464)). The SMTP half 294 + of DSN — RFC 3461's `RET`, `ENVID`, `NOTIFY` and `ORCPT` — is implemented 295 + on both sides, but nothing here builds the `multipart/report` message that 296 + carries a delivery status back to the sender. That is message composition 297 + by another name, so it goes with the library above. 298 + - **Everything an MTA does around a session.** No queue, no retry schedule, 299 + no MX resolution, no routing, no mailbox store. "Server" here means a 300 + session handler: listening, accepting and concurrency are the caller's. 301 + 302 + ### Protocol 303 + 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 + - **PIPELINING** ([RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920)) 308 + — advertised and parsed by both sides, used by neither. `sendMail` is 309 + strictly request-response. 310 + - **BINARYMIME** — CHUNKING is implemented but `BODY=BINARYMIME` is refused, 311 + which is the other half of 312 + [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030). 313 + - **Modern SASL** — no XOAUTH2 or OAUTHBEARER 314 + ([RFC 7628](https://datatracker.ietf.org/doc/html/rfc7628)), which is what 315 + Gmail and Microsoft 365 now require; no SCRAM-SHA-256 316 + ([RFC 7677](https://datatracker.ietf.org/doc/html/rfc7677)), no EXTERNAL, 317 + no `AUTH=` on MAIL FROM. CRAM-MD5 is the most modern mechanism present. 318 + - **Client certificates** — neither side can present or verify one. 319 + - **No enhanced status code accessor** — the server emits `x.y.z` on every 320 + reply, but `Reply` exposes only `code` and the raw text. 321 + - `EXPN` is unrecognized rather than unimplemented, so it answers 500 where 322 + [RFC 5321 §4.2.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.4) 323 + wants 502. 324 + - Niche and absent: REQUIRETLS, MT-PRIORITY, DELIVERBY, FUTURERELEASE, ETRN. 325 + 326 + ### Server 327 + 328 + - **No `Received:` header.** 329 + [RFC 5321 §4.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.4) 330 + requires a receiving server to stamp one. 331 + - **The handler never sees the connection** — no connect callback, no peer 332 + address, no TLS state. Greylisting, DNSBLs, SPF and per-IP policy cannot 333 + be built on top, and a `Received:` header cannot be written without it. 334 + - **No timeouts**, so a client that connects and says nothing holds the 335 + session forever; 336 + [RFC 5321 §4.5.3.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.2) 337 + specifies per-command limits. 338 + - **No abuse limits** beyond `max_recipients`: unlimited failed AUTH 339 + attempts, no error-count disconnect, no command budget. 340 + - **No `require_tls`** to go with `require_auth`. 341 + - **No PROXY protocol, XCLIENT or XFORWARD**, so the real peer address is 342 + lost behind a load balancer. 343 + - No filter or milter hook, and so no DKIM, SPF, DMARC or ARC. 344 + - No logging or tracing hooks. 345 + - `max_message_size` is not enforced in `messageReader` mode. 346 + 347 + ### Client 348 + 349 + - **`sendMail` is all-or-nothing on recipients** — the first rejected RCPT 350 + aborts the transaction, where `smtplib.sendmail` reports the refused ones 351 + and fails only when every one is refused. 352 + - **No `SIZE=` or `BODY=` on MAIL**, though the client parses both 353 + capabilities off EHLO; `max_size` in particular is read and never used, so 354 + nothing checks that a message fits before transmitting it. 355 + - No MX resolution or connect helper, no 4xx retry or backoff, no connection 356 + reuse helper, no pipelined `sendMail`. 246 357 247 358 ## Standards 248 359 ··· 259 370 (BDAT): client and server, with length-based framing and no dot-stuffing; 260 371 the companion BINARYMIME extension is not implemented (`BODY=BINARYMIME` 261 372 is rejected). 373 + - [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — DSN: 374 + advertised by the server, which parses and validates `RET=`/`ENVID=` on 375 + MAIL and `NOTIFY=`/`ORCPT=` on RCPT and hands them to the handler; the 376 + client sends them through `mail`/`rcpt`. Includes the xtext codec of §4. 377 + Generating the report message itself (RFC 3464) is out of scope. 262 378 - [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING: 263 379 advertised by the server, whose strictly sequential command loop handles 264 380 pipelined clients naturally; parsed by the client.
+29
nix/interop-test.nix
··· 74 74 daemon_smtp_ports = 2625 : 2626 75 75 tls_on_connect_ports = 2626 76 76 tls_advertise_hosts = * 77 + # Off by default in exim, and the point of the DSN subtest below. 78 + dsn_advertise_hosts = * 77 79 tls_certificate = ${snakeoil}/cert.pem 78 80 tls_privatekey = ${snakeoil}/key.pem 79 81 acl_smtp_rcpt = acl_rcpt ··· 205 207 for name, (port, tls_port, mailbox) in servers.items(): 206 208 with subtest(f"zsmtp client to {name}, CHUNKING"): 207 209 deliver("--chunking", port, f"zsmtp to {name} chunked", mailbox) 210 + 211 + # RFC 3461. Postfix advertises DSN out of the box; exim is told to above. 212 + for name, (port, tls_port, mailbox) in servers.items(): 213 + with subtest(f"zsmtp client to {name}, DSN parameters"): 214 + deliver( 215 + "--ret hdrs --envid batch7 --notify success,failure" 216 + " --orcpt team@example.net", 217 + port, 218 + f"zsmtp to {name} dsn", 219 + mailbox, 220 + ) 221 + 222 + with subtest("zsmtp client to zsmtp server, DSN parameters round trip"): 223 + machine.succeed( 224 + "printf 'Subject: interop\\r\\n\\r\\nzsmtp dsn round trip\\r\\n'" 225 + " | zsmtp send --ret full --envid 'batch 7'" 226 + " --notify success,delay --orcpt 'team+list@example.net'" 227 + " 127.0.0.1 2525 bob@example.com alice@example.net" 228 + ) 229 + # The server prints what it parsed: the ENVID comes back with its 230 + # space, and the ORCPT with the '+' that had to be xtext-encoded. 231 + machine.wait_until_succeeds( 232 + "journalctl -u zsmtp-server | grep -F" 233 + " 'NOTIFY=SUCCESS,DELAY ORCPT=rfc822;team+2Blist@example.net" 234 + " RET=FULL ENVID=batch 7'", 235 + timeout=60, 236 + ) 208 237 209 238 with subtest("zsmtp client to postfix, SMTPUTF8"): 210 239 machine.succeed(
+148 -5
src/Client.zig
··· 65 65 /// An argument contained CR, LF or NUL and was not sent. See 66 66 /// `protocol.isSafeArgument` for why those three bytes and no others. 67 67 UnsafeArgument, 68 + /// An ESMTP parameter value exceeded the length its RFC allows — 69 + /// `ENVID` past 100 characters or `ORCPT` past 500, measured on the 70 + /// xtext-encoded form that would go on the wire. 71 + ArgumentTooLong, 68 72 }; 69 73 70 74 /// Extensions advertised in the server's EHLO response. ··· 75 79 smtputf8: bool = false, 76 80 chunking: bool = false, 77 81 enhanced_status_codes: bool = false, 82 + /// The server accepts the DSN parameters of 83 + /// [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — `RET` and 84 + /// `ENVID` on MAIL, `NOTIFY` and `ORCPT` on RCPT. 85 + dsn: bool = false, 78 86 /// AUTH mechanisms advertised by the server. 79 87 auth: Auth = .{}, 80 88 /// Value of the SIZE extension, if advertised with a value. ··· 130 138 ext.chunking = true; 131 139 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) { 132 140 ext.enhanced_status_codes = true; 141 + } else if (ieql(kw, "DSN")) { 142 + ext.dsn = true; 133 143 } else if (ieql(kw, "AUTH")) { 134 144 ext.auth = Auth.parse(arg); 135 145 } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) { ··· 321 331 if (reply.code != 235) return error.AuthenticationFailed; 322 332 } 323 333 334 + /// Parameters for the MAIL command. Send only what the server advertised: 335 + /// an unrecognized parameter is a 555 from a conforming server, so check 336 + /// `Extensions` first. 337 + pub const MailOptions = struct { 338 + /// Requests the SMTPUTF8 extension 339 + /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)), which 340 + /// lets the envelope and headers carry UTF-8. Needs `Extensions.smtputf8`. 341 + smtputf8: bool = false, 342 + /// DSN `RET=`: how much of the message a failure report should carry 343 + /// back. Needs `Extensions.dsn`. 344 + ret: ?protocol.Ret = null, 345 + /// DSN `ENVID=`: an identifier quoted back in any report about this 346 + /// message. Sent xtext-encoded, so any bytes are safe to pass, and 347 + /// rejected with `error.ArgumentTooLong` if the encoded form exceeds the 348 + /// 100 characters RFC 3461 allows. Needs `Extensions.dsn`. 349 + envid: ?[]const u8 = null, 350 + }; 351 + 352 + /// Parameters for the RCPT command, which in this library means the DSN 353 + /// ones. Needs `Extensions.dsn`; see `MailOptions`. 354 + pub const RcptOptions = struct { 355 + /// DSN `NOTIFY=`: when the sender wants to hear about this recipient. 356 + /// Leave null to let the receiver apply its default. 357 + notify: ?protocol.Notify = null, 358 + /// DSN `ORCPT=`: the address the message was originally addressed to, 359 + /// carried through aliasing so a report can name what the sender wrote. 360 + /// The address is sent xtext-encoded; the `addr_type` is not, so it is 361 + /// checked instead, and the whole parameter is capped at the 500 362 + /// characters RFC 3461 allows. 363 + orcpt: ?protocol.Orcpt = null, 364 + }; 365 + 324 366 /// Starts a mail transaction. An empty `from` sends the null reverse-path 325 367 /// (`MAIL FROM:<>`), used for bounces. 326 368 /// 327 369 /// Returns `error.UnsafeArgument` for an address that would break out of 328 370 /// the command line; see `protocol.isSafeArgument`. 329 371 pub fn mailFrom(c: *Client, from: []const u8) (Error || ArgumentError)!void { 372 + return c.mail(from, .{}); 373 + } 374 + 375 + /// `mailFrom` with ESMTP parameters. 376 + pub fn mail(c: *Client, from: []const u8, options: MailOptions) (Error || ArgumentError)!void { 330 377 if (!protocol.isSafeArgument(from)) return error.UnsafeArgument; 331 - try c.send("MAIL FROM:<{s}>", .{from}); 378 + if (options.envid) |envid| { 379 + if (protocol.xtextEncodedLen(envid) > protocol.max_envid_len) 380 + return error.ArgumentTooLong; 381 + } 382 + try c.writer.print("MAIL FROM:<{s}>", .{from}); 383 + if (options.smtputf8) try c.writer.writeAll(" SMTPUTF8"); 384 + if (options.ret) |ret| try c.writer.print(" RET={f}", .{ret}); 385 + if (options.envid) |envid| { 386 + try c.writer.writeAll(" ENVID="); 387 + try protocol.writeXtext(c.writer, envid); 388 + } 389 + try c.writer.writeAll(protocol.crlf); 390 + try c.writer.flush(); 332 391 _ = try c.expectClass(2); 333 392 } 334 393 ··· 336 395 /// `error.UnsafeArgument` for an address that would break out of the 337 396 /// command line; see `protocol.isSafeArgument`. 338 397 pub fn rcptTo(c: *Client, to: []const u8) (Error || ArgumentError)!void { 398 + return c.rcpt(to, .{}); 399 + } 400 + 401 + /// `rcptTo` with ESMTP parameters. 402 + pub fn rcpt(c: *Client, to: []const u8, options: RcptOptions) (Error || ArgumentError)!void { 339 403 if (!protocol.isSafeArgument(to)) return error.UnsafeArgument; 340 - try c.send("RCPT TO:<{s}>", .{to}); 404 + if (options.orcpt) |orcpt| { 405 + if (orcpt.addr_type.len == 0 or !protocol.isSafeArgument(orcpt.addr_type) or 406 + std.mem.findScalar(u8, orcpt.addr_type, ';') != null) 407 + return error.UnsafeArgument; 408 + if (orcpt.addr_type.len + 1 + protocol.xtextEncodedLen(orcpt.address) > protocol.Orcpt.max_len) 409 + return error.ArgumentTooLong; 410 + } 411 + try c.writer.print("RCPT TO:<{s}>", .{to}); 412 + if (options.notify) |notify| try c.writer.print(" NOTIFY={f}", .{notify}); 413 + if (options.orcpt) |orcpt| try c.writer.print(" ORCPT={f}", .{orcpt}); 414 + try c.writer.writeAll(protocol.crlf); 415 + try c.writer.flush(); 341 416 _ = try c.expectClass(2); 342 417 } 343 418 ··· 493 568 /// envelope addresses and message headers may contain UTF-8. Use only when 494 569 /// `Extensions.smtputf8` was advertised. 495 570 pub fn mailFromUtf8(c: *Client, from: []const u8) (Error || ArgumentError)!void { 496 - if (!protocol.isSafeArgument(from)) return error.UnsafeArgument; 497 - try c.send("MAIL FROM:<{s}> SMTPUTF8", .{from}); 498 - _ = try c.expectClass(2); 571 + return c.mail(from, .{ .smtputf8 = true }); 499 572 } 500 573 501 574 /// Sends one BDAT chunk (the CHUNKING extension, ··· 743 816 client.authenticate(.{}, "u", "p"), 744 817 ); 745 818 } 819 + } 820 + 821 + test "mail and rcpt carry the DSN parameters" { 822 + const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n"; 823 + var reader: Io.Reader = .fixed(responses); 824 + var out_buf: [256]u8 = undefined; 825 + var writer: Io.Writer = .fixed(&out_buf); 826 + var reply_buf: [64]u8 = undefined; 827 + var client: Client = .init(&reader, &writer, &reply_buf); 828 + 829 + try client.mail("me@example.com", .{ .ret = .hdrs, .envid = "batch 7" }); 830 + try client.rcpt("bob@example.net", .{ 831 + .notify = .{ .on = .{ .failure = true, .delay = true } }, 832 + .orcpt = .{ .addr_type = "rfc822", .address = "team@example.net" }, 833 + }); 834 + try std.testing.expectEqualStrings( 835 + "MAIL FROM:<me@example.com> RET=HDRS ENVID=batch+207\r\n" ++ 836 + "RCPT TO:<bob@example.net> NOTIFY=FAILURE,DELAY ORCPT=rfc822;team@example.net\r\n", 837 + writer.buffered(), 838 + ); 839 + } 840 + 841 + test "NOTIFY=NEVER is written on its own" { 842 + var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); 843 + var out_buf: [128]u8 = undefined; 844 + var writer: Io.Writer = .fixed(&out_buf); 845 + var reply_buf: [64]u8 = undefined; 846 + var client: Client = .init(&reader, &writer, &reply_buf); 847 + 848 + try client.rcpt("bob@example.net", .{ .notify = .never }); 849 + try std.testing.expectEqualStrings( 850 + "RCPT TO:<bob@example.net> NOTIFY=NEVER\r\n", 851 + writer.buffered(), 852 + ); 853 + } 854 + 855 + test "DSN parameter values that exceed their limits are refused" { 856 + var reader: Io.Reader = .fixed(""); 857 + var out_buf: [1024]u8 = undefined; 858 + var writer: Io.Writer = .fixed(&out_buf); 859 + var reply_buf: [64]u8 = undefined; 860 + var client: Client = .init(&reader, &writer, &reply_buf); 861 + 862 + // 34 spaces encode to 102 characters, over the ENVID limit of 100, 863 + // though the value itself is well under it. 864 + const spaces = " " ** 34; 865 + try std.testing.expectError( 866 + error.ArgumentTooLong, 867 + client.mail("me@example.com", .{ .envid = spaces }), 868 + ); 869 + try std.testing.expectError(error.ArgumentTooLong, client.rcpt("bob@example.net", .{ 870 + .orcpt = .{ .addr_type = "rfc822", .address = "x" ** 500 }, 871 + })); 872 + // An addr-type is written literally, so it is checked rather than encoded. 873 + try std.testing.expectError(error.UnsafeArgument, client.rcpt("bob@example.net", .{ 874 + .orcpt = .{ .addr_type = "rfc822;evil", .address = "x@example.net" }, 875 + })); 876 + try std.testing.expectEqualStrings("", writer.buffered()); 877 + } 878 + 879 + test "hello reports DSN support" { 880 + const responses = "250-mx.example.com\r\n250-DSN\r\n250 8BITMIME\r\n"; 881 + var reader: Io.Reader = .fixed(responses); 882 + var out_buf: [128]u8 = undefined; 883 + var writer: Io.Writer = .fixed(&out_buf); 884 + var reply_buf: [256]u8 = undefined; 885 + var client: Client = .init(&reader, &writer, &reply_buf); 886 + 887 + const ext = try client.hello("client.example.org"); 888 + try std.testing.expect(ext.dsn); 746 889 } 747 890 748 891 test "an address carrying CRLF cannot inject a command" {
+199 -16
src/Server.zig
··· 81 81 }; 82 82 }; 83 83 84 + /// One accepted recipient, with whatever the client attached to it. 85 + pub const Recipient = struct { 86 + /// The forward-path from RCPT TO. 87 + address: []const u8, 88 + /// Value of the RCPT `NOTIFY=` parameter 89 + /// ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)), if the 90 + /// client sent one. Absent means the client did not say, which RFC 3461 91 + /// lets a reporting MTA read as either `FAILURE` or `FAILURE,DELAY`. 92 + notify: ?protocol.Notify = null, 93 + /// Value of the RCPT `ORCPT=` parameter, xtext-decoded: the address the 94 + /// message was originally addressed to, before whatever aliasing led 95 + /// here. 96 + orcpt: ?protocol.Orcpt = null, 97 + }; 98 + 84 99 pub const Envelope = struct { 85 100 /// Empty for the null reverse-path (`MAIL FROM:<>`). 86 101 from: []const u8, 87 - recipients: []const []const u8, 102 + recipients: []const Recipient, 88 103 /// Value of the MAIL SIZE= parameter 89 104 /// ([RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870)), if the client 90 105 /// declared one. Already validated against `Options.max_message_size`. ··· 96 111 /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)); the 97 112 /// envelope addresses and message headers may then contain UTF-8. 98 113 smtputf8: bool = false, 114 + /// Value of the MAIL `RET=` parameter 115 + /// ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)): how much 116 + /// of the message the sender wants carried back in a failure DSN. 117 + /// Absent leaves the choice to whoever reports. 118 + ret: ?protocol.Ret = null, 119 + /// Value of the MAIL `ENVID=` parameter, xtext-decoded: an identifier 120 + /// the sender wants quoted back in any DSN for this message. 121 + envid: ?[]const u8 = null, 99 122 100 123 pub const Body = enum { unspecified, seven_bit, eight_bit_mime }; 101 124 }; ··· 113 136 authenticate: ?*const fn (context: ?*anyopaque, username: []const u8, password: []const u8) bool = null, 114 137 /// Called for MAIL FROM. Null accepts every sender. 115 138 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null, 116 - /// Called for each RCPT TO. Null accepts every recipient. 117 - rcptTo: ?*const fn (context: ?*anyopaque, to: []const u8) Decision = null, 139 + /// Called for each RCPT TO, with the address and any DSN 140 + /// parameters that came with it. Null accepts every recipient. 141 + rcptTo: ?*const fn (context: ?*anyopaque, recipient: Recipient) Decision = null, 118 142 /// Called once the complete message has been received. The data has 119 143 /// CRLF line endings and dot-stuffing already removed. Exactly one 120 144 /// of `message` and `messageReader` must be set. ··· 154 178 var greeted = false; 155 179 var authenticated = false; 156 180 var from: ?[]const u8 = null; 157 - var recipients: std.ArrayList([]const u8) = .empty; 181 + var recipients: std.ArrayList(Recipient) = .empty; 158 182 var declared_size: ?u64 = null; 159 183 var body: Envelope.Body = .unspecified; 160 184 var smtputf8 = false; 185 + var ret: ?protocol.Ret = null; 186 + var envid: ?[]const u8 = null; 161 187 162 188 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname}); 163 189 try s.writer.flush(); ··· 184 210 declared_size = null; 185 211 body = .unspecified; 186 212 smtputf8 = false; 213 + ret = null; 214 + envid = null; 187 215 _ = arena_state.reset(.retain_capacity); 188 216 try s.reply(250, s.options.hostname); 189 217 }, ··· 194 222 declared_size = null; 195 223 body = .unspecified; 196 224 smtputf8 = false; 225 + ret = null; 226 + envid = null; 197 227 _ = arena_state.reset(.retain_capacity); 198 228 // Every reply carries an enhanced status code (RFC 3463), so 199 229 // the ENHANCEDSTATUSCODES extension (RFC 2034) is advertised. 200 - try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n", .{s.options.hostname}); 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}); 201 231 if (s.options.tls) |config| { 202 232 if (config.mode == .starttls and !s.secured) 203 233 try s.writer.writeAll("250-STARTTLS\r\n"); ··· 223 253 var mail_declared_size: ?u64 = null; 224 254 var mail_body: Envelope.Body = .unspecified; 225 255 var mail_smtputf8 = false; 256 + var mail_ret: ?protocol.Ret = null; 257 + var mail_envid: ?[]const u8 = null; 226 258 var params_ok = true; 227 259 var params = args.paramIterator(); 228 260 while (params.next()) |param| { ··· 255 287 break; 256 288 } 257 289 mail_smtputf8 = true; 290 + } else if (std.ascii.eqlIgnoreCase(param.keyword, "RET")) { 291 + mail_ret = protocol.Ret.parse(param.value) catch { 292 + try s.reply(501, "5.5.4 Invalid RET parameter"); 293 + params_ok = false; 294 + break; 295 + }; 296 + } else if (std.ascii.eqlIgnoreCase(param.keyword, "ENVID")) { 297 + // The cap is on the encoded form, which is what 298 + // arrived, so it is checked before decoding. 299 + if (param.value.len == 0 or param.value.len > protocol.max_envid_len) { 300 + try s.reply(501, "5.5.4 Invalid ENVID parameter"); 301 + params_ok = false; 302 + break; 303 + } 304 + const decoded = arena.alloc(u8, param.value.len) catch return error.OutOfMemory; 305 + mail_envid = protocol.xtextDecode(decoded, param.value) catch { 306 + try s.reply(501, "5.5.4 Invalid ENVID parameter"); 307 + params_ok = false; 308 + break; 309 + }; 258 310 } else { 259 311 try s.reply(555, "5.5.4 Unrecognized parameter"); 260 312 params_ok = false; ··· 276 328 declared_size = mail_declared_size; 277 329 body = mail_body; 278 330 smtputf8 = mail_smtputf8; 331 + ret = mail_ret; 332 + envid = mail_envid; 279 333 try s.reply(250, "2.1.0 Ok"); 280 334 }, 281 335 .rcpt => |args| { ··· 283 337 try s.reply(503, "5.5.1 Need MAIL command first"); 284 338 continue; 285 339 } 286 - if (args.params.len != 0) { 287 - try s.reply(555, "5.5.4 Unrecognized parameter"); 288 - continue; 340 + var recipient: Recipient = .{ .address = args.path }; 341 + var params_ok = true; 342 + var params = args.paramIterator(); 343 + while (params.next()) |param| { 344 + if (std.ascii.eqlIgnoreCase(param.keyword, "NOTIFY")) { 345 + recipient.notify = protocol.Notify.parse(param.value) catch { 346 + try s.reply(501, "5.5.4 Invalid NOTIFY parameter"); 347 + params_ok = false; 348 + break; 349 + }; 350 + } else if (std.ascii.eqlIgnoreCase(param.keyword, "ORCPT")) { 351 + if (param.value.len == 0 or param.value.len > protocol.Orcpt.max_len) { 352 + try s.reply(501, "5.5.4 Invalid ORCPT parameter"); 353 + params_ok = false; 354 + break; 355 + } 356 + const decoded = arena.alloc(u8, param.value.len) catch return error.OutOfMemory; 357 + recipient.orcpt = protocol.Orcpt.parse(decoded, param.value) catch { 358 + try s.reply(501, "5.5.4 Invalid ORCPT parameter"); 359 + params_ok = false; 360 + break; 361 + }; 362 + } else { 363 + try s.reply(555, "5.5.4 Unrecognized parameter"); 364 + params_ok = false; 365 + break; 366 + } 289 367 } 368 + if (!params_ok) continue; 290 369 if (!try s.validateAddress(args.path, smtputf8)) continue; 291 370 if (recipients.items.len >= s.options.max_recipients) { 292 371 try s.reply(452, "4.5.3 Too many recipients"); 293 372 continue; 294 373 } 295 374 if (s.handler.vtable.rcptTo) |callback| { 296 - switch (callback(s.handler.context, args.path)) { 375 + switch (callback(s.handler.context, recipient)) { 297 376 .accept => {}, 298 377 .reject => |r| { 299 378 try s.reply(r.code, r.text); ··· 301 380 }, 302 381 } 303 382 } 304 - try recipients.append(arena, try arena.dupe(u8, args.path)); 383 + recipient.address = try arena.dupe(u8, args.path); 384 + if (recipient.orcpt) |*orcpt| orcpt.addr_type = try arena.dupe(u8, orcpt.addr_type); 385 + try recipients.append(arena, recipient); 305 386 try s.reply(250, "2.1.5 Ok"); 306 387 }, 307 388 .data => { ··· 315 396 .declared_size = declared_size, 316 397 .body = body, 317 398 .smtputf8 = smtputf8, 399 + .ret = ret, 400 + .envid = envid, 318 401 }); 319 402 from = null; 320 403 recipients = .empty; 321 404 declared_size = null; 322 405 body = .unspecified; 323 406 smtputf8 = false; 407 + ret = null; 408 + envid = null; 324 409 _ = arena_state.reset(.retain_capacity); 325 410 }, 326 411 .bdat => |args| { ··· 340 425 .declared_size = declared_size, 341 426 .body = body, 342 427 .smtputf8 = smtputf8, 428 + .ret = ret, 429 + .envid = envid, 343 430 }, args); 344 431 from = null; 345 432 recipients = .empty; 346 433 declared_size = null; 347 434 body = .unspecified; 348 435 smtputf8 = false; 436 + ret = null; 437 + envid = null; 349 438 _ = arena_state.reset(.retain_capacity); 350 439 switch (outcome) { 351 440 .done => {}, ··· 358 447 declared_size = null; 359 448 body = .unspecified; 360 449 smtputf8 = false; 450 + ret = null; 451 + envid = null; 361 452 _ = arena_state.reset(.retain_capacity); 362 453 try s.reply(250, "2.0.0 Ok"); 363 454 }, ··· 388 479 declared_size = null; 389 480 body = .unspecified; 390 481 smtputf8 = false; 482 + ret = null; 483 + envid = null; 391 484 _ = arena_state.reset(.retain_capacity); 392 485 }, 393 486 .quit => { ··· 909 1002 declared_size: ?u64 = null, 910 1003 body: Envelope.Body = .unspecified, 911 1004 smtputf8: bool = false, 1005 + /// DSN parameters, kept from the last RCPT and the last message. The 1006 + /// strings are copied because everything a callback is handed lives 1007 + /// only for the duration of the call. 1008 + last_notify: ?protocol.Notify = null, 1009 + last_orcpt: bool = false, 1010 + last_orcpt_type: std.ArrayList(u8) = .empty, 1011 + last_orcpt_address: std.ArrayList(u8) = .empty, 1012 + ret: ?protocol.Ret = null, 1013 + envid: std.ArrayList(u8) = .empty, 912 1014 /// When set, enables the authenticate callback accepting user "alice" 913 1015 /// with this password. 914 1016 password: ?[]const u8 = null, ··· 917 1019 h.from.deinit(std.testing.allocator); 918 1020 h.recipients.deinit(std.testing.allocator); 919 1021 h.data.deinit(std.testing.allocator); 1022 + h.envid.deinit(std.testing.allocator); 1023 + h.last_orcpt_type.deinit(std.testing.allocator); 1024 + h.last_orcpt_address.deinit(std.testing.allocator); 920 1025 } 921 1026 922 1027 fn handler(h: *TestHandler) Handler { ··· 936 1041 std.mem.eql(u8, password, h.password.?); 937 1042 } 938 1043 939 - fn onRcptTo(context: ?*anyopaque, to: []const u8) Decision { 1044 + fn onRcptTo(context: ?*anyopaque, recipient: Recipient) Decision { 940 1045 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1046 + h.last_notify = recipient.notify; 1047 + if (recipient.orcpt) |orcpt| { 1048 + const gpa = std.testing.allocator; 1049 + h.last_orcpt = true; 1050 + h.last_orcpt_type.appendSlice(gpa, orcpt.addr_type) catch return .{ .reject = .{} }; 1051 + h.last_orcpt_address.appendSlice(gpa, orcpt.address) catch return .{ .reject = .{} }; 1052 + } 941 1053 if (h.reject_recipient) |rejected| { 942 - if (std.mem.eql(u8, to, rejected)) return .{ .reject = .{ 1054 + if (std.mem.eql(u8, recipient.address, rejected)) return .{ .reject = .{ 943 1055 .code = 550, 944 1056 .text = "5.1.1 No such user", 945 1057 } }; ··· 952 1064 const gpa = std.testing.allocator; 953 1065 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} }; 954 1066 for (envelope.recipients) |recipient| { 955 - h.recipients.appendSlice(gpa, recipient) catch return .{ .reject = .{} }; 1067 + h.recipients.appendSlice(gpa, recipient.address) catch return .{ .reject = .{} }; 956 1068 h.recipients.append(gpa, ';') catch return .{ .reject = .{} }; 957 1069 } 958 1070 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} }; ··· 960 1072 h.declared_size = envelope.declared_size; 961 1073 h.body = envelope.body; 962 1074 h.smtputf8 = envelope.smtputf8; 1075 + h.ret = envelope.ret; 1076 + if (envelope.envid) |envid| h.envid.appendSlice(gpa, envid) catch return .{ .reject = .{} }; 963 1077 return .accept; 964 1078 } 965 1079 }; ··· 970 1084 var session: Server = .init(&reader, &writer, handler, options); 971 1085 try session.run(std.testing.allocator); 972 1086 return writer.buffered(); 1087 + } 1088 + 1089 + test "DSN parameters reach the handler" { 1090 + var h: TestHandler = .{}; 1091 + defer h.deinit(); 1092 + 1093 + var out_buf: [2048]u8 = undefined; 1094 + const out = try runScript( 1095 + "EHLO client.example.org\r\n" ++ 1096 + "MAIL FROM:<alice@example.com> RET=HDRS ENVID=batch+207\r\n" ++ 1097 + "RCPT TO:<bob@example.net> NOTIFY=SUCCESS,FAILURE ORCPT=rfc822;team@example.net\r\n" ++ 1098 + "DATA\r\nhi\r\n.\r\nQUIT\r\n", 1099 + &out_buf, 1100 + h.handler(), 1101 + .{ .hostname = "mx.test" }, 1102 + ); 1103 + 1104 + // Nothing in the session was refused. 1105 + try std.testing.expect(std.mem.indexOf(u8, out, "\r\n5") == null); 1106 + try std.testing.expectEqual(protocol.Ret.hdrs, h.ret.?); 1107 + // The ENVID arrives xtext-decoded: "batch+207" carried a space. 1108 + try std.testing.expectEqualStrings("batch 7", h.envid.items); 1109 + const notify = h.last_notify.?; 1110 + try std.testing.expect(notify.on.success and notify.on.failure and !notify.on.delay); 1111 + try std.testing.expect(h.last_orcpt); 1112 + try std.testing.expectEqualStrings("rfc822", h.last_orcpt_type.items); 1113 + try std.testing.expectEqualStrings("team@example.net", h.last_orcpt_address.items); 1114 + } 1115 + 1116 + test "the DSN extension is advertised and its parameters are validated" { 1117 + var h: TestHandler = .{}; 1118 + defer h.deinit(); 1119 + 1120 + var out_buf: [2048]u8 = undefined; 1121 + const out = try runScript( 1122 + "EHLO client.example.org\r\n" ++ 1123 + "MAIL FROM:<a@example.com> RET=PARTIAL\r\n" ++ // 501: not FULL or HDRS 1124 + "MAIL FROM:<a@example.com> ENVID=bad+ZZ\r\n" ++ // 501: not xtext 1125 + "MAIL FROM:<a@example.com> ENVID=" ++ ("x" ** 101) ++ "\r\n" ++ // 501: too long 1126 + "MAIL FROM:<a@example.com>\r\n" ++ 1127 + "RCPT TO:<b@example.net> NOTIFY=NEVER,SUCCESS\r\n" ++ // 501: NEVER stands alone 1128 + "RCPT TO:<b@example.net> NOTIFY=SOMETIMES\r\n" ++ // 501: not a keyword 1129 + "RCPT TO:<b@example.net> ORCPT=team@example.net\r\n" ++ // 501: no addr-type 1130 + "RCPT TO:<b@example.net> FROB=1\r\n" ++ // 555: still unrecognized 1131 + "QUIT\r\n", 1132 + &out_buf, 1133 + h.handler(), 1134 + .{ .hostname = "mx.test" }, 1135 + ); 1136 + 1137 + try std.testing.expect(std.mem.indexOf(u8, out, "250-DSN\r\n") != null); 1138 + var replies = std.mem.splitSequence(u8, out, "\r\n"); 1139 + var codes: std.ArrayList([]const u8) = .empty; 1140 + defer codes.deinit(std.testing.allocator); 1141 + while (replies.next()) |line| { 1142 + if (line.len >= 4 and line[3] == ' ') try codes.append(std.testing.allocator, line[0..3]); 1143 + } 1144 + // 220 greeting, 250 EHLO, then the parameter verdicts, then 221. 1145 + try std.testing.expectEqualStrings("220", codes.items[0]); 1146 + try std.testing.expectEqualStrings("250", codes.items[1]); 1147 + try std.testing.expectEqualStrings("501", codes.items[2]); 1148 + try std.testing.expectEqualStrings("501", codes.items[3]); 1149 + try std.testing.expectEqualStrings("501", codes.items[4]); 1150 + try std.testing.expectEqualStrings("250", codes.items[5]); 1151 + try std.testing.expectEqualStrings("501", codes.items[6]); 1152 + try std.testing.expectEqualStrings("501", codes.items[7]); 1153 + try std.testing.expectEqualStrings("501", codes.items[8]); 1154 + try std.testing.expectEqualStrings("555", codes.items[9]); 1155 + try std.testing.expectEqualStrings("221", codes.items[10]); 973 1156 } 974 1157 975 1158 test run { ··· 1001 1184 1002 1185 try std.testing.expectEqualStrings( 1003 1186 "220 mx.test ESMTP ready\r\n" ++ 1004 - "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++ 1187 + "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n250 SIZE 16777216\r\n" ++ 1005 1188 "250 2.1.0 Ok\r\n" ++ 1006 1189 "250 2.1.5 Ok\r\n" ++ 1007 1190 "250 2.1.5 Ok\r\n" ++ ··· 1385 1568 } 1386 1569 1387 1570 test Envelope { 1388 - const envelope: Envelope = .{ .from = "", .recipients = &.{"a@example.com"} }; 1571 + const envelope: Envelope = .{ .from = "", .recipients = &.{.{ .address = "a@example.com" }} }; 1389 1572 try std.testing.expectEqual(@as(usize, 1), envelope.recipients.len); 1390 1573 try std.testing.expectEqual(@as(?u64, null), envelope.declared_size); 1391 1574 try std.testing.expectEqual(Envelope.Body.unspecified, envelope.body); ··· 1476 1659 "503 5.5.1 Send EHLO first\r\n" ++ 1477 1660 "503 5.5.1 Need MAIL command first\r\n" ++ 1478 1661 "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n" ++ 1479 - "250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++ 1662 + "250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n250 SIZE 16777216\r\n" ++ 1480 1663 "501 5.5.4 Syntax error in parameters\r\n" ++ 1481 1664 "501 5.5.4 Syntax error in parameters\r\n" ++ 1482 1665 "250 2.1.0 Ok\r\n" ++
+59 -10
src/main.zig
··· 5 5 //! 6 6 //! zsmtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth] 7 7 //! [--chunking] [--smtputf8] [--user <u> --password <p>] 8 - //! [--auth-method plain|login|cram-md5] <host> <port> <from> <to>... 8 + //! [--auth-method plain|login|cram-md5] 9 + //! [--ret full|hdrs] [--envid <id>] 10 + //! [--notify never|success,failure,delay] [--orcpt <address>] 11 + //! <host> <port> <from> <to>... 9 12 //! send a message read from stdin; --tls speaks TLS from the first 10 13 //! byte (port 465 style), --starttls upgrades after EHLO (port 587 11 14 //! style), --insecure skips certificate verification, --user/--password 12 15 //! authenticate with the best advertised mechanism (or the one forced 13 16 //! by --auth-method), and --allow-cleartext-auth permits a mechanism 14 - //! that sends the password over an unencrypted connection 17 + //! that sends the password over an unencrypted connection; the DSN 18 + //! options (RFC 3461) are --ret and --envid on the message and 19 + //! --notify and --orcpt on every recipient 15 20 //! zsmtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] 16 21 //! [--auth <user>:<pass>] <port> 17 22 //! run a debug server on 127.0.0.1 that prints received messages; ··· 44 49 config.chunking = true; 45 50 } else if (std.mem.eql(u8, rest[0], "--smtputf8")) { 46 51 config.smtputf8 = true; 52 + } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--ret")) { 53 + config.ret = zsmtp.protocol.Ret.parse(rest[1]) catch return usage(); 54 + rest = rest[1..]; 55 + } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--envid")) { 56 + config.envid = rest[1]; 57 + rest = rest[1..]; 58 + } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--notify")) { 59 + config.notify = zsmtp.protocol.Notify.parse(rest[1]) catch return usage(); 60 + rest = rest[1..]; 61 + } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--orcpt")) { 62 + // Applied to every recipient, which is all a one-shot 63 + // sender can sensibly do with it. 64 + config.orcpt = rest[1]; 65 + rest = rest[1..]; 47 66 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--user")) { 48 67 config.username = rest[1]; 49 68 rest = rest[1..]; ··· 109 128 allow_cleartext_auth: bool = false, 110 129 chunking: bool = false, 111 130 smtputf8: bool = false, 131 + ret: ?zsmtp.protocol.Ret = null, 132 + envid: ?[]const u8 = null, 133 + notify: ?zsmtp.protocol.Notify = null, 134 + orcpt: ?[]const u8 = null, 112 135 username: ?[]const u8 = null, 113 136 password: ?[]const u8 = null, 114 137 auth_method: enum { auto, plain, login, cram_md5 } = .auto, ··· 119 142 \\usage: 120 143 \\ zsmtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth] 121 144 \\ [--user <u> --password <p>] 122 - \\ [--auth-method plain|login|cram-md5] <host> <port> <from> <to>... 145 + \\ [--auth-method plain|login|cram-md5] 146 + \\ [--ret full|hdrs] [--envid <id>] 147 + \\ [--notify never|success,failure,delay] [--orcpt <address>] 148 + \\ <host> <port> <from> <to>... 123 149 \\ (message is read from stdin) 124 150 \\ zsmtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] 125 151 \\ [--auth <user>:<pass>] <port> ··· 215 241 std.log.err("server does not advertise SMTPUTF8", .{}); 216 242 return error.SmtpUtf8NotAdvertised; 217 243 } 218 - transact(&client, from, recipients, &stdin.interface, config.chunking, config.smtputf8) catch |err| { 244 + const wants_dsn = config.ret != null or config.envid != null or 245 + config.notify != null or config.orcpt != null; 246 + if (wants_dsn and !extensions.dsn) { 247 + // A conforming server answers an unrecognized parameter with 555, 248 + // so this is only a clearer way to say the same thing. 249 + std.log.err("server does not advertise DSN", .{}); 250 + return error.DsnNotAdvertised; 251 + } 252 + transact(&client, config, from, recipients, &stdin.interface) catch |err| { 219 253 if (err == error.UnexpectedReply) { 220 254 const reply = client.last_reply.?; 221 255 std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text }); ··· 230 264 /// arbitrarily large input never has to fit in memory. 231 265 fn transact( 232 266 client: *zsmtp.Client, 267 + config: SendConfig, 233 268 from: []const u8, 234 269 recipients: []const []const u8, 235 270 message: *Io.Reader, 236 - chunking: bool, 237 - smtputf8: bool, 238 271 ) (zsmtp.Client.Error || zsmtp.Client.ArgumentError)!void { 239 - if (smtputf8) try client.mailFromUtf8(from) else try client.mailFrom(from); 240 - for (recipients) |recipient| try client.rcptTo(recipient); 241 - if (chunking) { 272 + try client.mail(from, .{ 273 + .smtputf8 = config.smtputf8, 274 + .ret = config.ret, 275 + .envid = config.envid, 276 + }); 277 + for (recipients) |recipient| try client.rcpt(recipient, .{ 278 + .notify = config.notify, 279 + .orcpt = if (config.orcpt) |address| 280 + .{ .addr_type = "rfc822", .address = address } 281 + else 282 + null, 283 + }); 284 + if (config.chunking) { 242 285 // BDAT sends the input verbatim (no line-ending normalization). 243 286 while (true) { 244 287 const chunk = message.peekGreedy(1) catch |err| switch (err) { ··· 337 380 fn print(printer: *MessagePrinter, envelope: zsmtp.Server.Envelope, data: []const u8) !void { 338 381 try printer.out.print("--- message from <{s}> to", .{envelope.from}); 339 382 for (envelope.recipients) |recipient| { 340 - try printer.out.print(" <{s}>", .{recipient}); 383 + try printer.out.print(" <{s}>", .{recipient.address}); 384 + // DSN parameters, printed so that a session can be checked from 385 + // the outside (which is what the interop test does). 386 + if (recipient.notify) |notify| try printer.out.print(" NOTIFY={f}", .{notify}); 387 + if (recipient.orcpt) |orcpt| try printer.out.print(" ORCPT={f}", .{orcpt}); 341 388 } 389 + if (envelope.ret) |ret| try printer.out.print(" RET={f}", .{ret}); 390 + if (envelope.envid) |envid| try printer.out.print(" ENVID={s}", .{envid}); 342 391 try printer.out.print(" ({d} bytes)\n{s}---\n", .{ data.len, data }); 343 392 try printer.out.flush(); 344 393 }
+274
src/protocol.zig
··· 326 326 } 327 327 }; 328 328 329 + /// The `RET` parameter of an extended MAIL command 330 + /// ([RFC 3461 §4.3](https://datatracker.ietf.org/doc/html/rfc3461#section-4.3)): 331 + /// how much of the message a failed DSN should carry back. Absent, the 332 + /// choice is the reporting MTA's. 333 + /// RFC 3461 §4.4 caps the `ENVID` parameter value at 100 characters, which 334 + /// is a limit on the xtext-encoded form and not on what went into it. 335 + pub const max_envid_len = 100; 336 + 337 + pub const Ret = enum { 338 + /// Return the entire message. 339 + full, 340 + /// Return the headers only. 341 + hdrs, 342 + 343 + pub const ParseError = error{Syntax}; 344 + 345 + pub fn parse(value: []const u8) ParseError!Ret { 346 + if (std.ascii.eqlIgnoreCase(value, "FULL")) return .full; 347 + if (std.ascii.eqlIgnoreCase(value, "HDRS")) return .hdrs; 348 + return error.Syntax; 349 + } 350 + 351 + /// Writes the value as it appears on the wire. 352 + pub fn format(r: Ret, writer: *Io.Writer) Io.Writer.Error!void { 353 + try writer.writeAll(switch (r) { 354 + .full => "FULL", 355 + .hdrs => "HDRS", 356 + }); 357 + } 358 + 359 + test parse { 360 + try std.testing.expectEqual(Ret.hdrs, try parse("hdrs")); 361 + try std.testing.expectError(error.Syntax, parse("PARTIAL")); 362 + } 363 + }; 364 + 365 + /// The `NOTIFY` parameter of an extended RCPT command 366 + /// ([RFC 3461 §4.1](https://datatracker.ietf.org/doc/html/rfc3461#section-4.1)): 367 + /// the conditions under which the sender wants to hear about this 368 + /// recipient. Absent, RFC 3461 lets a server read it as either 369 + /// `FAILURE` or `FAILURE,DELAY` — which is why "not specified" is an 370 + /// absent `?Notify` here and not a value of it. 371 + pub const Notify = union(enum) { 372 + /// `NOTIFY=NEVER`: no DSN for this recipient under any circumstance. 373 + /// RFC 3461 requires the keyword to appear on its own, and parsing 374 + /// rejects it in a list. 375 + never, 376 + /// One or more of `SUCCESS`, `FAILURE` and `DELAY`. 377 + on: Conditions, 378 + 379 + pub const Conditions = struct { 380 + success: bool = false, 381 + failure: bool = false, 382 + delay: bool = false, 383 + }; 384 + 385 + pub const ParseError = error{Syntax}; 386 + 387 + pub fn parse(value: []const u8) ParseError!Notify { 388 + if (std.ascii.eqlIgnoreCase(value, "NEVER")) return .never; 389 + var conditions: Conditions = .{}; 390 + var it = std.mem.splitScalar(u8, value, ','); 391 + var any = false; 392 + while (it.next()) |keyword| { 393 + if (std.ascii.eqlIgnoreCase(keyword, "SUCCESS")) { 394 + conditions.success = true; 395 + } else if (std.ascii.eqlIgnoreCase(keyword, "FAILURE")) { 396 + conditions.failure = true; 397 + } else if (std.ascii.eqlIgnoreCase(keyword, "DELAY")) { 398 + conditions.delay = true; 399 + } else return error.Syntax; // Including NEVER: it may not be listed. 400 + any = true; 401 + } 402 + if (!any) return error.Syntax; 403 + return .{ .on = conditions }; 404 + } 405 + 406 + /// Writes the value as it appears on the wire. 407 + pub fn format(n: Notify, writer: *Io.Writer) Io.Writer.Error!void { 408 + switch (n) { 409 + .never => try writer.writeAll("NEVER"), 410 + .on => |conditions| { 411 + var written = false; 412 + inline for (.{ 413 + .{ conditions.success, "SUCCESS" }, 414 + .{ conditions.failure, "FAILURE" }, 415 + .{ conditions.delay, "DELAY" }, 416 + }) |pair| { 417 + if (pair[0]) { 418 + if (written) try writer.writeByte(','); 419 + try writer.writeAll(pair[1]); 420 + written = true; 421 + } 422 + } 423 + // An empty condition set has no legal spelling; NEVER is 424 + // what "tell me nothing" is written as. 425 + if (!written) try writer.writeAll("NEVER"); 426 + }, 427 + } 428 + } 429 + 430 + test parse { 431 + try std.testing.expectEqual(Notify.never, try parse("NEVER")); 432 + const both = try parse("SUCCESS,delay"); 433 + try std.testing.expect(both.on.success and both.on.delay and !both.on.failure); 434 + try std.testing.expectError(error.Syntax, parse("NEVER,SUCCESS")); 435 + try std.testing.expectError(error.Syntax, parse("")); 436 + try std.testing.expectError(error.Syntax, parse("SUCCESS,MAYBE")); 437 + } 438 + }; 439 + 440 + /// The `ORCPT` parameter of an extended RCPT command 441 + /// ([RFC 3461 §4.2](https://datatracker.ietf.org/doc/html/rfc3461#section-4.2)): 442 + /// the address the message was originally addressed to, carried unchanged 443 + /// through aliasing and forwarding so that a DSN can name what the sender 444 + /// actually wrote. 445 + pub const Orcpt = struct { 446 + /// The address type, an atom — `rfc822` in all but the unusual cases. 447 + addr_type: []const u8, 448 + /// The original recipient, xtext-decoded. 449 + address: []const u8, 450 + 451 + /// RFC 3461 §4.2 caps the whole parameter value at 500 characters. 452 + pub const max_len = 500; 453 + 454 + pub const ParseError = error{Syntax}; 455 + 456 + /// Parses `addr-type ";" xtext`, decoding the address into `buffer`. 457 + /// The returned `addr_type` points into `value` and `address` points 458 + /// into `buffer`, so the two have different lifetimes; a caller keeping 459 + /// the result past either one copies both. 460 + pub fn parse(buffer: []u8, value: []const u8) ParseError!Orcpt { 461 + const semicolon = std.mem.findScalar(u8, value, ';') orelse return error.Syntax; 462 + const addr_type = value[0..semicolon]; 463 + if (addr_type.len == 0) return error.Syntax; 464 + for (addr_type) |byte| if (!isAtomByte(byte)) return error.Syntax; 465 + return .{ 466 + .addr_type = addr_type, 467 + .address = xtextDecode(buffer, value[semicolon + 1 ..]) catch return error.Syntax, 468 + }; 469 + } 470 + 471 + /// Writes the parameter value as it appears on the wire, xtext-encoding 472 + /// the address. 473 + pub fn format(o: Orcpt, writer: *Io.Writer) Io.Writer.Error!void { 474 + try writer.writeAll(o.addr_type); 475 + try writer.writeByte(';'); 476 + try writeXtext(writer, o.address); 477 + } 478 + 479 + /// RFC 5321 `atom` less the specials, which is what an addr-type may be. 480 + fn isAtomByte(byte: u8) bool { 481 + return switch (byte) { 482 + 'A'...'Z', 'a'...'z', '0'...'9' => true, 483 + '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?' => true, 484 + '^', '_', '`', '{', '|', '}', '~' => true, 485 + else => false, 486 + }; 487 + } 488 + 489 + test parse { 490 + var buffer: [64]u8 = undefined; 491 + const orcpt = try parse(&buffer, "rfc822;bob+2Bx@example.net"); 492 + try std.testing.expectEqualStrings("rfc822", orcpt.addr_type); 493 + try std.testing.expectEqualStrings("bob+x@example.net", orcpt.address); 494 + try std.testing.expectError(error.Syntax, parse(&buffer, "bob@example.net")); 495 + try std.testing.expectError(error.Syntax, parse(&buffer, ";bob@example.net")); 496 + } 497 + }; 498 + 499 + /// Whether `byte` may appear in an xtext unencoded 500 + /// ([RFC 3461 §4](https://datatracker.ietf.org/doc/html/rfc3461#section-4)): 501 + /// printable US-ASCII other than `+`, which introduces an escape, and `=`, 502 + /// which separates an ESMTP keyword from its value. 503 + pub fn isXchar(byte: u8) bool { 504 + return byte >= '!' and byte <= '~' and byte != '+' and byte != '='; 505 + } 506 + 507 + /// Writes `text` xtext-encoded: anything that is not an `xchar` becomes 508 + /// `+` and two upper-case hex digits. Every byte therefore survives, 509 + /// including the ones that would otherwise end the command line, so an 510 + /// xtext-encoded parameter is safe to write from untrusted input. 511 + /// 512 + /// RFC 3461 asks that the value before encoding be printable US-ASCII. 513 + /// That is the caller's to observe; encoding anything else here produces 514 + /// valid xtext regardless rather than a corrupt command. 515 + pub fn writeXtext(writer: *Io.Writer, text: []const u8) Io.Writer.Error!void { 516 + for (text) |byte| { 517 + if (isXchar(byte)) { 518 + try writer.writeByte(byte); 519 + } else { 520 + try writer.print("+{X:0>2}", .{byte}); 521 + } 522 + } 523 + } 524 + 525 + /// The length `writeXtext` will produce for `text`, for checking a value 526 + /// against the length limits RFC 3461 puts on the encoded form. 527 + pub fn xtextEncodedLen(text: []const u8) usize { 528 + var len: usize = 0; 529 + for (text) |byte| len += if (isXchar(byte)) 1 else 3; 530 + return len; 531 + } 532 + 533 + pub const XtextError = error{ 534 + /// Not valid xtext: a `+` not followed by two hex digits, or a raw byte 535 + /// that the encoder was required to escape. 536 + BadXtext, 537 + NoSpaceLeft, 538 + }; 539 + 540 + /// Decodes xtext into `buffer`, returning the decoded bytes. Decoding is 541 + /// strict: a byte an encoder was obliged to escape is rejected rather than 542 + /// passed through, since accepting it would let two different encodings 543 + /// mean the same thing. 544 + pub fn xtextDecode(buffer: []u8, text: []const u8) XtextError![]u8 { 545 + var out: usize = 0; 546 + var i: usize = 0; 547 + while (i < text.len) { 548 + const byte = text[i]; 549 + if (byte == '+') { 550 + if (i + 2 >= text.len) return error.BadXtext; 551 + const hex = text[i + 1 ..][0..2]; 552 + // Checked before parsing because `parseInt` also accepts a sign 553 + // and underscore separators, which hex digits are not. Lower 554 + // case is accepted on the way in even though RFC 3461 requires 555 + // upper case on the way out. 556 + for (hex) |digit| if (!std.ascii.isHex(digit)) return error.BadXtext; 557 + const value = std.fmt.parseInt(u8, hex, 16) catch return error.BadXtext; 558 + if (out >= buffer.len) return error.NoSpaceLeft; 559 + buffer[out] = value; 560 + out += 1; 561 + i += 3; 562 + } else { 563 + if (!isXchar(byte)) return error.BadXtext; 564 + if (out >= buffer.len) return error.NoSpaceLeft; 565 + buffer[out] = byte; 566 + out += 1; 567 + i += 1; 568 + } 569 + } 570 + return buffer[0..out]; 571 + } 572 + 573 + test xtextDecode { 574 + var buffer: [64]u8 = undefined; 575 + try std.testing.expectEqualStrings( 576 + "a+b=c", 577 + try xtextDecode(&buffer, "a+2Bb+3Dc"), 578 + ); 579 + try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+2")); 580 + try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+ZZb")); 581 + // A raw '=' or ' ' is what the encoder had to escape. 582 + try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a=b")); 583 + try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a b")); 584 + } 585 + 586 + test writeXtext { 587 + var out_buf: [64]u8 = undefined; 588 + var writer: Io.Writer = .fixed(&out_buf); 589 + try writeXtext(&writer, "id+1=2 \r\n"); 590 + try std.testing.expectEqualStrings("id+2B1+3D2+20+0D+0A", writer.buffered()); 591 + try std.testing.expectEqual(writer.buffered().len, xtextEncodedLen("id+1=2 \r\n")); 592 + 593 + // Every byte survives the round trip. 594 + var raw: [256]u8 = undefined; 595 + for (&raw, 0..) |*byte, i| byte.* = @intCast(i); 596 + var round_buf: [1024]u8 = undefined; 597 + var round: Io.Writer = .fixed(&round_buf); 598 + try writeXtext(&round, &raw); 599 + var decoded_buf: [256]u8 = undefined; 600 + try std.testing.expectEqualSlices(u8, &raw, try xtextDecode(&decoded_buf, round.buffered())); 601 + } 602 + 329 603 /// Iterates the ESMTP parameters of a MAIL or RCPT command 330 604 /// ([RFC 5321 §4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2)), 331 605 /// e.g. "SIZE=1024 BODY=8BITMIME".
+1
test/protocol-torture.script
··· 14 14 ??? 250-CHUNKING 15 15 ??? 250-SMTPUTF8 16 16 ??? 250-ENHANCEDSTATUSCODES 17 + ??? 250-DSN 17 18 ??? 250 SIZE 18 19 mail 19 20 ??? 501