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.

Implement BINARYMIME, the other half of RFC 3030

CHUNKING was here and BINARYMIME was refused with 555, which left the
framing without the thing it exists to frame. BDAT carries a length, so
it can carry content that holds what would otherwise be a terminator;
that is the whole reason RFC 3030 defines the two together and says
BINARYMIME can only be used with CHUNKING.

The server advertises it beside CHUNKING, takes `BODY=BINARYMIME`, and
answers DATA for such a message with 503 as §3 requires -- binary has no
line structure, so a line holding a single dot cannot mean the end of it.
Nothing had to change in the receive path: BDAT already copied octets
without touching line endings, which is what "preserve all bits in each
octet" asks for, and there is now a test that walks all 256 byte values
through it to keep that true.

The client gained `MailOptions.body`, so `BODY=` is sayable at all --
7BIT and 8BITMIME as well, which the client could parse off EHLO and
never send. Declaring `.binary_mime` commits the transaction to BDAT, and
`data` refuses it with `error.BinaryRequiresChunking` rather than making
the round trip to be told 503. The demo CLI exposes it as --binarymime,
which implies --chunking because there is no other way to send it.

`Envelope.Body` moved to `protocol.Body` and lost its `unspecified`
variant in favour of `?protocol.Body`, since the client needs the same
enum and the envelope's other optional parameters are already spelled
that way. That is the breaking part.

The torture dialogue asserted 555 for BODY=BINARYMIME, which was exim's
answer and is no longer ours; those cases now use BODY=BINARY, which is
still not a body-value, so they go on testing what they were written to
test.

Verified against postfix and exim, neither of which offers BINARYMIME:
the client refuses to send binary to them at all, which is what RFC 3030
demands without qualification. Bit-exactness is checked end to end
through the CLI, all 256 octets plus a bare CR and a lone dot line.

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

+273 -38
+29 -11
README.md
··· 144 144 145 145 When the server advertises CHUNKING (`extensions.chunking`), `bdat` and 146 146 `sendMessageChunked` transmit the message with length-framed BDAT chunks 147 - instead of DATA — verbatim, with no dot-stuffing, so content must already 148 - use CRLF line endings. 147 + instead of DATA — verbatim, with no dot-stuffing, so text content must 148 + already use CRLF line endings. 149 + 150 + That framing is also what makes binary content possible. 151 + `mail(from, .{ .body = .binary_mime })` declares it 152 + ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030), needs 153 + `extensions.binary_mime`), after which the message may hold any octets at 154 + all — NULs, bare CR, a line that is nothing but a dot — and `data` refuses 155 + to open a DATA phase for it with `error.BinaryRequiresChunking`, which is 156 + the 503 the server would have sent, made one round trip earlier. RFC 3030 157 + is absolute that binary must not be sent to a server that did not advertise 158 + it, so check the capability first. 149 159 150 160 ### Authentication 151 161 ··· 274 284 is meant for the hop between a queueing MTA and whatever writes to mailboxes; 275 285 RFC 2033 §5 forbids it on TCP port 25 and advises against wide-area use. 276 286 287 + BINARYMIME ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) is 288 + advertised alongside CHUNKING, which the RFC requires of anything offering 289 + it. `BODY=BINARYMIME` arrives as `Envelope.body`, DATA for such a message is 290 + refused with 503, and the content reaches the handler exactly as it was 291 + sent — the BDAT path copies octets and has no line structure to normalize. 292 + 277 293 DSN ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)) is 278 294 advertised. `RET=` and `ENVID=` on MAIL arrive as `Envelope.ret` and 279 295 `Envelope.envid`, and `NOTIFY=` and `ORCPT=` on RCPT arrive as ··· 331 347 zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 332 348 zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 333 349 350 + # Send arbitrary binary content (RFC 3030), framed by BDAT rather than DATA: 351 + ./zig-out/bin/zsmtp send --binarymime 127.0.0.1 2525 me@example.com you@example.net \ 352 + < some-binary-file 353 + 334 354 # Speak LMTP (RFC 2033) instead of SMTP. The server reports one verdict per 335 355 # recipient, and --fail-delivery makes one of them fail to show it: 336 356 ./zig-out/bin/zsmtp serve --lmtp --fail-delivery bad@example.net 2529 ··· 387 407 388 408 ### Protocol 389 409 390 - - **BINARYMIME** — CHUNKING is implemented but `BODY=BINARYMIME` is refused, 391 - which is the other half of 392 - [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030). 393 410 - **Modern SASL** — no XOAUTH2 or OAUTHBEARER 394 411 ([RFC 7628](https://datatracker.ietf.org/doc/html/rfc7628)), which is what 395 412 Gmail and Microsoft 365 now require; no SCRAM-SHA-256 ··· 430 447 the transaction, where `smtplib.sendmail` delivers to the rest and reports 431 448 the refusals. `envelope` gives a caller the per-recipient codes to decide 432 449 for itself, but no higher-level call does that decision for it. 433 - - **No `SIZE=` or `BODY=` on MAIL**, though the client parses both 434 - capabilities off EHLO; `max_size` in particular is read and never used, so 435 - nothing checks that a message fits before transmitting it. 450 + - **No `SIZE=` on MAIL**, though the client parses the capability off EHLO: 451 + `max_size` is read and never used, so nothing checks that a message fits 452 + before transmitting it. 436 453 - No MX resolution or connect helper, no 4xx retry or backoff, no connection 437 454 reuse helper. 438 455 ··· 448 465 - [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152) — 8BITMIME: 449 466 advertised by the server and `BODY=` validated; parsed by the client. 450 467 - [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030) — CHUNKING 451 - (BDAT): client and server, with length-based framing and no dot-stuffing; 452 - the companion BINARYMIME extension is not implemented (`BODY=BINARYMIME` 453 - is rejected). 468 + (BDAT) and BINARYMIME: client and server, with length-based framing and no 469 + dot-stuffing. `BODY=BINARYMIME` is advertised, accepted and delivered bit 470 + for bit, and DATA is refused with 503 for a message that declared it, 471 + since binary content cannot be framed by a line holding a single dot. 454 472 - [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — DSN: 455 473 advertised by the server, which parses and validates `RET=`/`ENVID=` on 456 474 MAIL and `NOTIFY=`/`ORCPT=` on RCPT and hands them to the handler; the
+30
nix/interop-test.nix
··· 44 44 zsmtp 45 45 pkgs.swaks 46 46 pkgs.netcat 47 + pkgs.python3 47 48 ]; 48 49 49 50 users.users.alice.isNormalUser = true; ··· 369 370 "printf 'EHLO x\\r\\nQUIT\\r\\n' | timeout 5 nc 127.0.0.1 2529" 370 371 ) 371 372 assert "500" in output, f"expected a 500 for EHLO, got: {output}" 373 + 374 + # RFC 3030 BINARYMIME. Neither postfix nor exim offers it, which is 375 + # what makes them the test of the refusal: RFC 3030 is absolute that 376 + # binary must not be sent to a server that did not advertise it. 377 + with subtest("zsmtp refuses to send binary to a server that lacks BINARYMIME"): 378 + for name, (port, _tls, _mailbox) in servers.items(): 379 + status, output = machine.execute( 380 + f"printf 'x' | zsmtp send --binarymime 127.0.0.1 {port}" 381 + " bob@example.com alice@localhost 2>&1" 382 + ) 383 + assert status != 0, f"{name} was sent binary anyway: {output}" 384 + assert "BINARYMIME" in output, f"{name}: unexpected failure: {output}" 385 + 386 + with subtest("zsmtp to zsmtp, every octet survives BINARYMIME"): 387 + # All 256 byte values plus a lone dot line and a bare CR: content 388 + # that DATA could not carry at all, and that must arrive unchanged. 389 + machine.succeed( 390 + "python3 -c \"import sys;" 391 + "sys.stdout.buffer.write(bytes(range(256))+b'\\r\\n.\\r\\n\\rtail')\"" 392 + " > /tmp/binary.in" 393 + ) 394 + machine.succeed( 395 + "zsmtp send --binarymime 127.0.0.1 2525 bob@example.com alice@example.net" 396 + " < /tmp/binary.in" 397 + ) 398 + machine.wait_until_succeeds( 399 + "journalctl -u zsmtp-server | grep -F 'to <alice@example.net> (266 bytes)'", 400 + timeout=60, 401 + ) 372 402 373 403 with subtest("zsmtp client to postfix, SMTPUTF8"): 374 404 machine.succeed(
+77 -1
src/Client.zig
··· 52 52 /// Recipients the server has accepted since the last MAIL, which in LMTP 53 53 /// is how many replies the end of the message will draw. 54 54 accepted_recipients: usize = 0, 55 + /// Whether the current transaction was opened with `BODY=BINARYMIME`, in 56 + /// which case its content can only go out by BDAT. 57 + binary: bool = false, 55 58 /// Permits `authenticate`, `authPlain` and `authLogin` to send credentials 56 59 /// over a `.plaintext` transport, which they otherwise refuse with 57 60 /// `error.InsecureTransport`. ··· 81 84 ReplyTooLong, 82 85 /// The server answered with an unexpected code; see `last_reply`. 83 86 UnexpectedReply, 87 + /// The transaction was opened with `BODY=BINARYMIME`, whose content 88 + /// can only be sent with `bdat`. A server would answer DATA with 503 89 + /// (RFC 3030 §3); this is the same refusal, made before the round trip. 90 + BinaryRequiresChunking, 84 91 /// LMTP only: at least one recipient's verdict at the end of the 85 92 /// message was not a 2xx. 86 93 /// ··· 109 116 starttls: bool = false, 110 117 smtputf8: bool = false, 111 118 chunking: bool = false, 119 + /// The server takes `BODY=BINARYMIME` 120 + /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). Always 121 + /// accompanied by `chunking`, since binary content can only be sent 122 + /// with BDAT — a server advertising one without the other is broken, 123 + /// and sending binary to it anyway is what RFC 3030 forbids outright. 124 + binary_mime: bool = false, 112 125 enhanced_status_codes: bool = false, 113 126 /// The server accepts the DSN parameters of 114 127 /// [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — `RET` and ··· 167 180 ext.smtputf8 = true; 168 181 } else if (ieql(kw, "CHUNKING")) { 169 182 ext.chunking = true; 183 + } else if (ieql(kw, "BINARYMIME")) { 184 + ext.binary_mime = true; 170 185 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) { 171 186 ext.enhanced_status_codes = true; 172 187 } else if (ieql(kw, "DSN")) { ··· 208 223 pub fn hello(c: *Client, client_name: []const u8) (Error || ArgumentError)!Extensions { 209 224 if (!protocol.isSafeArgument(client_name)) return error.UnsafeArgument; 210 225 c.accepted_recipients = 0; 226 + c.binary = false; 211 227 c.pipelining = false; 212 228 if (c.mode == .lmtp) { 213 229 // LHLO has EHLO's semantics, and there is no older greeting to fall ··· 385 401 /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)), which 386 402 /// lets the envelope and headers carry UTF-8. Needs `Extensions.smtputf8`. 387 403 smtputf8: bool = false, 404 + /// `BODY=`: what kind of content the message carries. 405 + /// `.eight_bit_mime` needs `Extensions.eight_bit_mime`; 406 + /// `.binary_mime` needs `Extensions.binary_mime`, and commits the 407 + /// transaction to BDAT — `data` will refuse to open a DATA phase for 408 + /// it, as RFC 3030 §3 requires. 409 + body: ?protocol.Body = null, 388 410 /// DSN `RET=`: how much of the message a failure report should carry 389 411 /// back. Needs `Extensions.dsn`. 390 412 ret: ?protocol.Ret = null, ··· 425 447 try c.writer.flush(); 426 448 _ = try c.expectClass(2); 427 449 c.accepted_recipients = 0; 450 + c.binary = options.body == .binary_mime; 428 451 } 429 452 430 453 /// Everything about a MAIL command that can be refused before it is ··· 442 465 /// Writes MAIL without flushing or reading its reply. 443 466 fn writeMail(c: *Client, from: []const u8, options: MailOptions) Error!void { 444 467 try c.writer.print("MAIL FROM:<{s}>", .{from}); 468 + if (options.body) |body| try c.writer.print(" BODY={f}", .{body}); 445 469 if (options.smtputf8) try c.writer.writeAll(" SMTPUTF8"); 446 470 if (options.ret) |ret| try c.writer.print(" RET={f}", .{ret}); 447 471 if (options.envid) |envid| { ··· 565 589 // queued would be mistaken for the answers to whatever comes next. 566 590 const mail_reply = try c.readReply(); 567 591 const mail_ok = mail_reply.isPositiveCompletion(); 568 - if (mail_ok) c.accepted_recipients = 0; 592 + if (mail_ok) { 593 + c.accepted_recipients = 0; 594 + c.binary = options.mail.body == .binary_mime; 595 + } 569 596 570 597 var accepted: usize = 0; 571 598 for (0..recipients.len) |index| { ··· 616 643 /// through the returned writer's `interface`, then call `end`. Line endings 617 644 /// are normalized to CRLF and leading dots stuffed as the data flows. 618 645 pub fn data(c: *Client) Error!DataWriter { 646 + if (c.binary) return error.BinaryRequiresChunking; 619 647 try c.send("DATA", .{}); 620 648 _ = try c.expect(354); 621 649 return .{ ··· 854 882 try c.send("RSET", .{}); 855 883 _ = try c.expectClass(2); 856 884 c.accepted_recipients = 0; 885 + c.binary = false; 857 886 } 858 887 859 888 pub fn noop(c: *Client) Error!void { ··· 1087 1116 client.authenticate(.{}, "u", "p"), 1088 1117 ); 1089 1118 } 1119 + } 1120 + 1121 + test "BODY=BINARYMIME commits the transaction to BDAT" { 1122 + const responses = "250-mx.example.com\r\n250-CHUNKING\r\n250 BINARYMIME\r\n" ++ 1123 + "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.0.0 Ok\r\n"; 1124 + var reader: Io.Reader = .fixed(responses); 1125 + var out_buf: [512]u8 = undefined; 1126 + var writer: Io.Writer = .fixed(&out_buf); 1127 + var reply_buf: [256]u8 = undefined; 1128 + var client: Client = .init(&reader, &writer, &reply_buf); 1129 + 1130 + const ext = try client.hello("client.example.org"); 1131 + try std.testing.expect(ext.binary_mime and ext.chunking); 1132 + 1133 + try client.mail("alice@example.com", .{ .body = .binary_mime }); 1134 + try client.rcptTo("bob@example.net"); 1135 + // The server would answer DATA with 503; the client will not get that 1136 + // far, because the round trip has nothing to discover. 1137 + try std.testing.expectError(error.BinaryRequiresChunking, client.data()); 1138 + 1139 + // BDAT is the way, and it sends the octets untouched: a bare CR, a NUL 1140 + // and a lone dot all cross unchanged. 1141 + try client.bdat("\x00\r.\r\n", true); 1142 + try std.testing.expect(std.mem.endsWith( 1143 + u8, 1144 + writer.buffered(), 1145 + "MAIL FROM:<alice@example.com> BODY=BINARYMIME\r\n" ++ 1146 + "RCPT TO:<bob@example.net>\r\n" ++ 1147 + "BDAT 5 LAST\r\n\x00\r.\r\n", 1148 + )); 1149 + } 1150 + 1151 + test "the binary commitment is lifted by RSET and by the next transaction" { 1152 + const responses = "250 2.1.0 Ok\r\n250 2.0.0 Ok\r\n250 2.1.0 Ok\r\n354 go\r\n250 ok\r\n"; 1153 + var reader: Io.Reader = .fixed(responses); 1154 + var out_buf: [512]u8 = undefined; 1155 + var writer: Io.Writer = .fixed(&out_buf); 1156 + var reply_buf: [256]u8 = undefined; 1157 + var client: Client = .init(&reader, &writer, &reply_buf); 1158 + 1159 + try client.mail("alice@example.com", .{ .body = .binary_mime }); 1160 + try std.testing.expect(client.binary); 1161 + try client.rset(); 1162 + try std.testing.expect(!client.binary); 1163 + // And a plain MAIL leaves DATA available again. 1164 + try client.mail("alice@example.com", .{}); 1165 + try client.sendMessage("hi\r\n"); 1090 1166 } 1091 1167 1092 1168 test "a pipelined envelope writes the whole group before reading a reply" {
+78 -22
src/Server.zig
··· 122 122 /// ([RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870)), if the client 123 123 /// declared one. Already validated against `Options.max_message_size`. 124 124 declared_size: ?u64 = null, 125 - /// Value of the MAIL BODY= parameter 126 - /// ([RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152)). 127 - body: Body = .unspecified, 125 + /// Value of the MAIL `BODY=` parameter, if the client declared one. 126 + /// `.binary_mime` ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) 127 + /// means the content is arbitrary octets and arrived by BDAT, so the 128 + /// handler must keep every bit of it: there is no line structure to 129 + /// normalize and nothing was unstuffed. 130 + body: ?protocol.Body = null, 128 131 /// True when the client requested the SMTPUTF8 extension 129 132 /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)); the 130 133 /// envelope addresses and message headers may then contain UTF-8. ··· 137 140 /// Value of the MAIL `ENVID=` parameter, xtext-decoded: an identifier 138 141 /// the sender wants quoted back in any DSN for this message. 139 142 envid: ?[]const u8 = null, 140 - 141 - pub const Body = enum { unspecified, seven_bit, eight_bit_mime }; 142 143 }; 143 144 144 145 /// What a mail transaction accumulates between MAIL and the end of the ··· 148 149 from: ?[]const u8 = null, 149 150 recipients: std.ArrayList(Recipient) = .empty, 150 151 declared_size: ?u64 = null, 151 - body: Envelope.Body = .unspecified, 152 + body: ?protocol.Body = null, 152 153 smtputf8: bool = false, 153 154 ret: ?protocol.Ret = null, 154 155 envid: ?[]const u8 = null, ··· 302 303 continue; 303 304 } 304 305 var mail_declared_size: ?u64 = null; 305 - var mail_body: Envelope.Body = .unspecified; 306 + var mail_body: ?protocol.Body = null; 306 307 var mail_smtputf8 = false; 307 308 var mail_ret: ?protocol.Ret = null; 308 309 var mail_envid: ?[]const u8 = null; ··· 322 323 } 323 324 mail_declared_size = size; 324 325 } else if (std.ascii.eqlIgnoreCase(param.keyword, "BODY")) { 325 - if (std.ascii.eqlIgnoreCase(param.value, "7BIT")) { 326 - mail_body = .seven_bit; 327 - } else if (std.ascii.eqlIgnoreCase(param.value, "8BITMIME")) { 328 - mail_body = .eight_bit_mime; 329 - } else { 326 + mail_body = protocol.Body.parse(param.value) catch { 330 327 try s.reply(555, "5.5.4 Unsupported BODY value"); 331 328 params_ok = false; 332 329 break; 333 - } 330 + }; 334 331 } else if (std.ascii.eqlIgnoreCase(param.keyword, "SMTPUTF8")) { 335 332 if (param.value.len != 0) { 336 333 try s.reply(501, "5.5.4 SMTPUTF8 takes no value"); ··· 441 438 try s.reply(503, "5.5.1 Need RCPT command first"); 442 439 continue; 443 440 } 441 + // RFC 3030 §3: binary content has no line structure, so it 442 + // cannot be framed by a line holding a single dot. BDAT, 443 + // which carries its length, is the only way to send it. 444 + if (transaction.body == .binary_mime) { 445 + try s.reply(503, "5.5.1 BINARYMIME requires BDAT"); 446 + continue; 447 + } 444 448 try s.receiveData(arena, transaction.envelope()); 445 449 transaction.clear(); 446 450 _ = arena_state.reset(.retain_capacity); ··· 534 538 fn greetExtended(s: *Server, authenticated: bool) error{WriteFailed}!void { 535 539 // Every reply carries an enhanced status code (RFC 3463), so the 536 540 // 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}); 541 + try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-BINARYMIME\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n", .{s.options.hostname}); 538 542 if (s.options.tls) |config| { 539 543 if (config.mode == .starttls and !s.secured) 540 544 try s.writer.writeAll("250-STARTTLS\r\n"); ··· 1080 1084 /// verdict is asked for. 1081 1085 reject_message: ?Decision.Rejection = null, 1082 1086 declared_size: ?u64 = null, 1083 - body: Envelope.Body = .unspecified, 1087 + body: ?protocol.Body = null, 1084 1088 smtputf8: bool = false, 1085 1089 /// DSN parameters, kept from the last RCPT and the last message. The 1086 1090 /// strings are copied because everything a callback is handed lives ··· 1276 1280 var session: Server = .init(&reader, &writer, handler, options); 1277 1281 try session.run(std.testing.allocator); 1278 1282 return writer.buffered(); 1283 + } 1284 + 1285 + test "BINARYMIME is advertised, accepted, and refused on DATA" { 1286 + var h: TestHandler = .{}; 1287 + defer h.deinit(); 1288 + 1289 + var out_buf: [4096]u8 = undefined; 1290 + const out = try runScript( 1291 + "EHLO client.example.org\r\n" ++ 1292 + "MAIL FROM:<alice@example.com> BODY=BINARYMIME\r\n" ++ 1293 + "RCPT TO:<bob@example.net>\r\n" ++ 1294 + "DATA\r\n" ++ // 503: binary content cannot be framed by a dot 1295 + "BDAT 5 LAST\r\n\x00\r\n.\r\nQUIT\r\n", 1296 + &out_buf, 1297 + h.handler(), 1298 + .{ .hostname = "mx.test" }, 1299 + ); 1300 + 1301 + // RFC 3030: BINARYMIME may only be offered alongside CHUNKING. 1302 + try std.testing.expect(std.mem.indexOf(u8, out, "250-BINARYMIME\r\n") != null); 1303 + try std.testing.expect(std.mem.indexOf(u8, out, "250-CHUNKING\r\n") != null); 1304 + try std.testing.expect(std.mem.indexOf(u8, out, "503 5.5.1 BINARYMIME requires BDAT") != null); 1305 + try std.testing.expectEqual(protocol.Body.binary_mime, h.body.?); 1306 + // Five octets, delivered as they were sent: a NUL, and a lone dot on a 1307 + // line of its own, which over DATA would have ended the message. 1308 + try std.testing.expectEqualStrings("\x00\r\n.\r", h.data.items); 1309 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1310 + } 1311 + 1312 + test "every octet survives a binary chunk" { 1313 + var h: TestHandler = .{}; 1314 + defer h.deinit(); 1315 + 1316 + // All 256 byte values, which is the "preserve all bits in each octet" 1317 + // requirement of RFC 3030 §5 stated as a test. 1318 + const octets = comptime blk: { 1319 + var all: [256]u8 = undefined; 1320 + for (&all, 0..) |*byte, i| byte.* = @intCast(i); 1321 + break :blk all; 1322 + }; 1323 + 1324 + var out_buf: [4096]u8 = undefined; 1325 + _ = try runScript( 1326 + "EHLO client.example.org\r\n" ++ 1327 + "MAIL FROM:<alice@example.com> BODY=BINARYMIME\r\n" ++ 1328 + "RCPT TO:<bob@example.net>\r\n" ++ 1329 + "BDAT 256 LAST\r\n" ++ octets ++ "QUIT\r\n", 1330 + &out_buf, 1331 + h.handler(), 1332 + .{ .hostname = "mx.test" }, 1333 + ); 1334 + try std.testing.expectEqualSlices(u8, &octets, h.data.items); 1279 1335 } 1280 1336 1281 1337 test "LMTP answers once per accepted recipient" { ··· 1510 1566 1511 1567 try std.testing.expectEqualStrings( 1512 1568 "220 mx.test ESMTP ready\r\n" ++ 1513 - "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" ++ 1569 + "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-BINARYMIME\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n250 SIZE 16777216\r\n" ++ 1514 1570 "250 2.1.0 Ok\r\n" ++ 1515 1571 "250 2.1.5 Ok\r\n" ++ 1516 1572 "250 2.1.5 Ok\r\n" ++ ··· 1833 1889 1834 1890 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1835 1891 try std.testing.expectEqual(@as(?u64, 42), h.declared_size); 1836 - try std.testing.expectEqual(Envelope.Body.eight_bit_mime, h.body); 1892 + try std.testing.expectEqual(protocol.Body.eight_bit_mime, h.body.?); 1837 1893 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null); 1838 1894 } 1839 1895 ··· 1847 1903 "MAIL FROM:<a@example.com> SIZE=9999\r\n" ++ // over the maximum: 552 1848 1904 "RCPT TO:<b@example.net>\r\n" ++ // that MAIL never started: 503 1849 1905 "MAIL FROM:<a@example.com> SIZE=banana\r\n" ++ // 501 1850 - "MAIL FROM:<a@example.com> BODY=BINARYMIME\r\n" ++ // 555 1906 + "MAIL FROM:<a@example.com> BODY=BINARY\r\n" ++ // 555: not a body-value 1851 1907 "MAIL FROM:<a@example.com> FUTURE=yes\r\n" ++ // 555 1852 1908 "MAIL FROM:<a@example.com> BODY=7bit\r\n" ++ // ok 1853 1909 "RCPT TO:<b@example.net> NOTIFY=SUCCESS\r\n" ++ // no RCPT params: 555 ··· 1864 1920 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unsupported BODY value") != null); 1865 1921 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); 1866 1922 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1867 - try std.testing.expectEqual(Envelope.Body.seven_bit, h.body); 1923 + try std.testing.expectEqual(protocol.Body.seven_bit, h.body.?); 1868 1924 try std.testing.expectEqual(@as(?u64, null), h.declared_size); 1869 1925 } 1870 1926 ··· 1897 1953 const envelope: Envelope = .{ .from = "", .recipients = &.{.{ .address = "a@example.com" }} }; 1898 1954 try std.testing.expectEqual(@as(usize, 1), envelope.recipients.len); 1899 1955 try std.testing.expectEqual(@as(?u64, null), envelope.declared_size); 1900 - try std.testing.expectEqual(Envelope.Body.unspecified, envelope.body); 1956 + try std.testing.expectEqual(@as(?protocol.Body, null), envelope.body); 1901 1957 } 1902 1958 1903 1959 test Handler { ··· 1956 2012 "Message body\r\n" ++ 1957 2013 ".\r\n" ++ 1958 2014 "mail from:<a@b> SIZE=99999999\r\n" ++ 1959 - "mail from:<a@b> BODY=BINARYMIME\r\n" ++ 2015 + "mail from:<a@b> BODY=BINARY\r\n" ++ 1960 2016 "mail from:<a@b> FOO=bar\r\n" ++ 1961 2017 "mail from:<a@b> SIZE=nan\r\n" ++ 1962 2018 "starttls\r\n" ++ ··· 1984 2040 "500 5.5.2 Command not recognized\r\n" ++ 1985 2041 "503 5.5.1 Send EHLO first\r\n" ++ 1986 2042 "503 5.5.1 Need MAIL command first\r\n" ++ 1987 - "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n" ++ 2043 + "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-BINARYMIME\r\n" ++ 1988 2044 "250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n250 SIZE 16777216\r\n" ++ 1989 2045 "501 5.5.4 Syntax error in parameters\r\n" ++ 1990 2046 "501 5.5.4 Syntax error in parameters\r\n" ++
+17 -3
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 - //! [--lmtp] <host> <port> <from> <to>... 11 + //! [--lmtp] [--binarymime] <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 ··· 16 16 //! by --auth-method), and --allow-cleartext-auth permits a mechanism 17 17 //! that sends the password over an unencrypted connection; the DSN 18 18 //! options (RFC 3461) are --ret and --envid on the message and 19 - //! --notify and --orcpt on every recipient 19 + //! --notify and --orcpt on every recipient; --binarymime sends the 20 + //! input as BODY=BINARYMIME over BDAT, byte for byte 20 21 //! zsmtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] 21 22 //! [--auth <user>:<pass>] [--lmtp [--fail-delivery <address>]] 22 23 //! <port> ··· 54 55 config.smtputf8 = true; 55 56 } else if (std.mem.eql(u8, rest[0], "--lmtp")) { 56 57 config.protocol = .lmtp; 58 + } else if (std.mem.eql(u8, rest[0], "--binarymime")) { 59 + // Binary content can only be framed by BDAT, so this is 60 + // chunking plus a declaration of what the chunks hold. 61 + config.body = .binary_mime; 62 + config.chunking = true; 57 63 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--ret")) { 58 64 config.ret = zsmtp.protocol.Ret.parse(rest[1]) catch return usage(); 59 65 rest = rest[1..]; ··· 144 150 chunking: bool = false, 145 151 smtputf8: bool = false, 146 152 protocol: zsmtp.Client.Protocol = .smtp, 153 + body: ?zsmtp.protocol.Body = null, 147 154 ret: ?zsmtp.protocol.Ret = null, 148 155 envid: ?[]const u8 = null, 149 156 notify: ?zsmtp.protocol.Notify = null, ··· 161 168 \\ [--auth-method plain|login|cram-md5] 162 169 \\ [--ret full|hdrs] [--envid <id>] 163 170 \\ [--notify never|success,failure,delay] [--orcpt <address>] 164 - \\ [--lmtp] <host> <port> <from> <to>... 171 + \\ [--lmtp] [--binarymime] <host> <port> <from> <to>... 165 172 \\ (message is read from stdin) 166 173 \\ zsmtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] 167 174 \\ [--auth <user>:<pass>] [--lmtp [--fail-delivery <address>]] ··· 259 266 std.log.err("server does not advertise SMTPUTF8", .{}); 260 267 return error.SmtpUtf8NotAdvertised; 261 268 } 269 + if (config.body == .binary_mime and !extensions.binary_mime) { 270 + // RFC 3030 is absolute about this one: without the advertisement, 271 + // binary must not be sent under any circumstances. 272 + std.log.err("server does not advertise BINARYMIME", .{}); 273 + return error.BinaryMimeNotAdvertised; 274 + } 262 275 const wants_dsn = config.ret != null or config.envid != null or 263 276 config.notify != null or config.orcpt != null; 264 277 if (wants_dsn and !extensions.dsn) { ··· 289 302 ) (zsmtp.Client.Error || zsmtp.Client.ArgumentError)!void { 290 303 try client.mail(from, .{ 291 304 .smtputf8 = config.smtputf8, 305 + .body = config.body, 292 306 .ret = config.ret, 293 307 .envid = config.envid, 294 308 });
+40
src/protocol.zig
··· 339 339 /// ([RFC 3461 §4.3](https://datatracker.ietf.org/doc/html/rfc3461#section-4.3)): 340 340 /// how much of the message a failed DSN should carry back. Absent, the 341 341 /// choice is the reporting MTA's. 342 + /// The `BODY` parameter of an extended MAIL command: what kind of content 343 + /// the message carries, and so what the receiver has to be able to take. 344 + pub const Body = enum { 345 + /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). Lines of 346 + /// at most 998 characters from the ASCII repertoire. 347 + seven_bit, 348 + /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). The same 349 + /// line structure, with the high bit allowed. 350 + eight_bit_mime, 351 + /// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030). Arbitrary 352 + /// octets with no line structure at all, which is why it can only be 353 + /// carried by BDAT: DATA has no way to frame content that may hold the 354 + /// terminator itself. 355 + binary_mime, 356 + 357 + pub const ParseError = error{Syntax}; 358 + 359 + pub fn parse(value: []const u8) ParseError!Body { 360 + if (std.ascii.eqlIgnoreCase(value, "7BIT")) return .seven_bit; 361 + if (std.ascii.eqlIgnoreCase(value, "8BITMIME")) return .eight_bit_mime; 362 + if (std.ascii.eqlIgnoreCase(value, "BINARYMIME")) return .binary_mime; 363 + return error.Syntax; 364 + } 365 + 366 + /// Writes the value as it appears on the wire. 367 + pub fn format(b: Body, writer: *Io.Writer) Io.Writer.Error!void { 368 + try writer.writeAll(switch (b) { 369 + .seven_bit => "7BIT", 370 + .eight_bit_mime => "8BITMIME", 371 + .binary_mime => "BINARYMIME", 372 + }); 373 + } 374 + 375 + test parse { 376 + try std.testing.expectEqual(Body.binary_mime, try parse("binarymime")); 377 + try std.testing.expectEqual(Body.seven_bit, try parse("7BIT")); 378 + try std.testing.expectError(error.Syntax, parse("BINARY")); 379 + } 380 + }; 381 + 342 382 /// RFC 3461 §4.4 caps the `ENVID` parameter value at 100 characters, which 343 383 /// is a limit on the xtext-encoded form and not on what went into it. 344 384 pub const max_envid_len = 100;
+2 -1
test/protocol-torture.script
··· 12 12 ??? 250-PIPELINING 13 13 ??? 250-8BITMIME 14 14 ??? 250-CHUNKING 15 + ??? 250-BINARYMIME 15 16 ??? 250-SMTPUTF8 16 17 ??? 250-ENHANCEDSTATUSCODES 17 18 ??? 250-DSN ··· 51 52 ??? 250 52 53 mail from:<a@b> SIZE=99999999 53 54 ??? 552 54 - mail from:<a@b> BODY=BINARYMIME 55 + mail from:<a@b> BODY=BINARY 55 56 ??? 555 56 57 mail from:<a@b> FOO=bar 57 58 ??? 555