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.

Add the CHUNKING extension (RFC 3030)

Command.bdat parses "BDAT <size> [LAST]" strictly. The server
advertises CHUNKING and receives chunked messages on both handler
paths: the collecting path reassembles raw chunks (no dot-stuffing,
max_message_size enforced with 552), and BdatReader adapts the chunk
sequence into the streaming handler's reader, replying 250 between
chunks and handling RSET/QUIT/protocol violations mid-stream, with
unread remainder drained. Framing is strictly length-based: a BDAT
without a transaction still consumes its payload octets, and chunk
payloads that look like commands are data.

The client gains Extensions.chunking, bdat(chunk, last) (verbatim
transmission, one flush per chunk), and sendMessageChunked; the CLI
gains send --chunking, streaming stdin as BDAT chunks.

The exim-client torture script and the byte-for-byte gauntlet unit
test gain a BDAT section, and the VM interop suite delivers via
CHUNKING to real Postfix and Exim (18 subtests passing). BINARYMIME
remains deliberately unimplemented (BODY=BINARYMIME is rejected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx

+544 -12
+11
README.md
··· 42 42 try data_writer.end(); // terminates the message, reads the verdict 43 43 ``` 44 44 45 + When the server advertises CHUNKING (`extensions.chunking`), `bdat` and 46 + `sendMessageChunked` transmit the message with length-framed BDAT chunks 47 + instead of DATA — verbatim, with no dot-stuffing, so content must already 48 + use CRLF line endings. 49 + 45 50 ### Authentication 46 51 47 52 `hello` reports the server's advertised mechanisms in `extensions.auth`; ··· 119 124 120 125 `run` serves one connection until QUIT or disconnect, enforcing command 121 126 sequencing, recipient and message-size limits, and un-stuffing message data. 127 + Messages may also arrive via BDAT chunks (CHUNKING is advertised); both 128 + the collecting and streaming handler paths receive the reassembled content. 122 129 MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552 123 130 when it exceeds `max_message_size`, `BODY=7BIT`/`BODY=8BITMIME` (RFC 6152) 124 131 are accepted, and unrecognized parameters get 555; the declared size and ··· 194 201 with 552 before DATA); parsed from EHLO by the client. 195 202 - [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152) — 8BITMIME: 196 203 advertised by the server and `BODY=` validated; parsed by the client. 204 + - [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030) — CHUNKING 205 + (BDAT): client and server, with length-based framing and no dot-stuffing; 206 + the companion BINARYMIME extension is not implemented (`BODY=BINARYMIME` 207 + is rejected). 197 208 - [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING: 198 209 advertised by the server, whose strictly sequential command loop handles 199 210 pipelined clients naturally; parsed by the client.
+4
nix/interop-test.nix
··· 202 202 with subtest(f"zsmtp client to {name}, implicit TLS"): 203 203 deliver("--tls --insecure", tls_port, f"zsmtp to {name} smtps", mailbox) 204 204 205 + for name, (port, tls_port, mailbox) in servers.items(): 206 + with subtest(f"zsmtp client to {name}, CHUNKING"): 207 + deliver("--chunking", port, f"zsmtp to {name} chunked", mailbox) 208 + 205 209 with subtest("zsmtp client to exim, AUTH PLAIN"): 206 210 deliver( 207 211 "--user alice --password secret --auth-method plain",
+53
src/Client.zig
··· 47 47 eight_bit_mime: bool = false, 48 48 starttls: bool = false, 49 49 smtputf8: bool = false, 50 + chunking: bool = false, 50 51 enhanced_status_codes: bool = false, 51 52 /// AUTH mechanisms advertised by the server. 52 53 auth: Auth = .{}, ··· 99 100 ext.starttls = true; 100 101 } else if (ieql(kw, "SMTPUTF8")) { 101 102 ext.smtputf8 = true; 103 + } else if (ieql(kw, "CHUNKING")) { 104 + ext.chunking = true; 102 105 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) { 103 106 ext.enhanced_status_codes = true; 104 107 } else if (ieql(kw, "AUTH")) { ··· 410 413 } 411 414 } 412 415 }; 416 + 417 + /// Sends one BDAT chunk (the CHUNKING extension, 418 + /// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the 419 + /// server's reply. Use only when `Extensions.chunking` was advertised. The 420 + /// chunk is transmitted verbatim — no dot-stuffing and no line-ending 421 + /// normalization — so message content must already use CRLF line endings. 422 + /// Set `last` on the final chunk; `bdat("", true)` is a valid terminator. 423 + pub fn bdat(c: *Client, chunk: []const u8, last: bool) Error!void { 424 + if (last) { 425 + try c.writer.print("BDAT {d} LAST\r\n", .{chunk.len}); 426 + } else { 427 + try c.writer.print("BDAT {d}\r\n", .{chunk.len}); 428 + } 429 + try c.writer.writeAll(chunk); 430 + try c.writer.flush(); 431 + _ = try c.expectClass(2); 432 + } 433 + 434 + /// Sends the message content for the current transaction as a single BDAT 435 + /// chunk. See `bdat` for the transmission caveats. 436 + pub fn sendMessageChunked(c: *Client, message_data: []const u8) Error!void { 437 + try c.bdat(message_data, true); 438 + } 413 439 414 440 /// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient, 415 441 /// then DATA. Call after `greet` and `hello`. ··· 879 905 try std.testing.expect(!extensions.starttls); 880 906 try std.testing.expect(!extensions.auth.any()); 881 907 try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size); 908 + } 909 + 910 + test bdat { 911 + var reader: Io.Reader = .fixed("250 2.0.0 Chunk received\r\n250 2.0.0 Ok\r\n"); 912 + var out_buf: [128]u8 = undefined; 913 + var writer: Io.Writer = .fixed(&out_buf); 914 + var reply_buf: [64]u8 = undefined; 915 + var client: Client = .init(&reader, &writer, &reply_buf); 916 + 917 + try client.bdat("Subject: hi\r\n\r\n", false); 918 + try client.bdat("body\r\n", true); 919 + try std.testing.expectEqualStrings( 920 + "BDAT 15\r\nSubject: hi\r\n\r\nBDAT 6 LAST\r\nbody\r\n", 921 + writer.buffered(), 922 + ); 923 + } 924 + 925 + test sendMessageChunked { 926 + var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 927 + var out_buf: [128]u8 = undefined; 928 + var writer: Io.Writer = .fixed(&out_buf); 929 + var reply_buf: [64]u8 = undefined; 930 + var client: Client = .init(&reader, &writer, &reply_buf); 931 + 932 + // Raw transmission: the leading dot is not stuffed. 933 + try client.sendMessageChunked(".raw\r\n"); 934 + try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered()); 882 935 }
+400 -8
src/Server.zig
··· 190 190 _ = arena_state.reset(.retain_capacity); 191 191 // Every reply carries an enhanced status code (RFC 3463), so 192 192 // the ENHANCEDSTATUSCODES extension (RFC 2034) is advertised. 193 - try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-ENHANCEDSTATUSCODES\r\n", .{s.options.hostname}); 193 + try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-ENHANCEDSTATUSCODES\r\n", .{s.options.hostname}); 194 194 if (s.options.tls) |config| { 195 195 if (config.mode == .starttls and !s.secured) 196 196 try s.writer.writeAll("250-STARTTLS\r\n"); ··· 302 302 declared_size = null; 303 303 body = .unspecified; 304 304 _ = arena_state.reset(.retain_capacity); 305 + }, 306 + .bdat => |args| { 307 + if (recipients.items.len == 0) { 308 + // The chunk's octets follow regardless; consume them to 309 + // keep the length-framed stream in sync. 310 + s.reader.discardAll64(args.size) catch |err| switch (err) { 311 + error.EndOfStream => return, 312 + error.ReadFailed => return error.ReadFailed, 313 + }; 314 + try s.reply(503, "5.5.1 Need RCPT command first"); 315 + continue; 316 + } 317 + const outcome = try s.receiveChunked(arena, .{ 318 + .from = from.?, 319 + .recipients = recipients.items, 320 + .declared_size = declared_size, 321 + .body = body, 322 + }, args); 323 + from = null; 324 + recipients = .empty; 325 + declared_size = null; 326 + body = .unspecified; 327 + _ = arena_state.reset(.retain_capacity); 328 + switch (outcome) { 329 + .done => {}, 330 + .end_session => return, 331 + } 305 332 }, 306 333 .rset => { 307 334 from = null; ··· 507 534 std.base64.standard.Decoder.decode(out[0..len], encoded) catch return null; 508 535 return out[0..len]; 509 536 } 537 + 538 + const ChunkOutcome = enum { done, end_session }; 539 + 540 + /// Receives a message sent with BDAT chunks (RFC 3030 CHUNKING), starting 541 + /// from the already-parsed first chunk header. Chunk data is raw: no 542 + /// dot-stuffing and no line-ending normalization. 543 + fn receiveChunked( 544 + s: *Server, 545 + arena: std.mem.Allocator, 546 + envelope: Envelope, 547 + first: protocol.Command.BdatArgs, 548 + ) RunError!ChunkOutcome { 549 + if (s.handler.vtable.messageReader) |callback| { 550 + var buffer: [1024]u8 = undefined; 551 + var bdat_reader: BdatReader = .{ 552 + .server = s, 553 + .remaining = first.size, 554 + .last = first.last, 555 + .interface = .{ 556 + .buffer = &buffer, 557 + .vtable = &.{ .stream = BdatReader.stream }, 558 + .seek = 0, 559 + .end = 0, 560 + }, 561 + }; 562 + const decision = callback(s.handler.context, envelope, &bdat_reader.interface); 563 + if (bdat_reader.abort == null and !bdat_reader.finished) { 564 + // Consume whatever the callback left unread, through LAST. 565 + var discard_buf: [256]u8 = undefined; 566 + var discarding: Io.Writer.Discarding = .init(&discard_buf); 567 + _ = bdat_reader.interface.streamRemaining(&discarding.writer) catch {}; 568 + } 569 + if (bdat_reader.abort) |abort| switch (abort) { 570 + .rset, .protocol => return .done, // Replies already sent. 571 + .quit, .disconnected => return .end_session, 572 + .transport_failure => return error.ReadFailed, 573 + }; 574 + switch (decision) { 575 + .accept => try s.reply(250, "2.0.0 Ok, message accepted"), 576 + .reject => |r| try s.reply(r.code, r.text), 577 + } 578 + return .done; 579 + } 580 + 581 + var data: std.ArrayList(u8) = .empty; 582 + var oversize = false; 583 + var size = first.size; 584 + var last = first.last; 585 + while (true) { 586 + var left = size; 587 + while (left > 0) { 588 + const available = s.reader.peekGreedy(1) catch |err| switch (err) { 589 + error.EndOfStream => return .end_session, 590 + error.ReadFailed => return error.ReadFailed, 591 + }; 592 + const n: usize = @intCast(@min(@as(u64, available.len), left)); 593 + if (!oversize) { 594 + if (data.items.len + n > s.options.max_message_size) { 595 + oversize = true; 596 + } else { 597 + try data.appendSlice(arena, available[0..n]); 598 + } 599 + } 600 + s.reader.toss(n); 601 + left -= n; 602 + } 603 + if (last) break; 604 + try s.reply(250, "2.0.0 Chunk received"); 605 + const line = protocol.readLine(s.reader) catch |err| switch (err) { 606 + error.EndOfStream => return .end_session, 607 + error.ReadFailed => return error.ReadFailed, 608 + error.LineTooLong => { 609 + try s.discardLine(); 610 + try s.reply(500, "5.5.2 Line too long"); 611 + return .done; // Transaction aborted. 612 + }, 613 + }; 614 + const command = protocol.Command.parse(line) catch { 615 + try s.reply(501, "5.5.4 Syntax error in parameters"); 616 + return .done; 617 + }; 618 + switch (command) { 619 + .bdat => |b| { 620 + size = b.size; 621 + last = b.last; 622 + }, 623 + .rset => { 624 + try s.reply(250, "2.0.0 Ok"); 625 + return .done; 626 + }, 627 + .quit => { 628 + try s.reply(221, "2.0.0 Bye"); 629 + if (s.secured) s.tls_connection.close() catch {}; 630 + return .end_session; 631 + }, 632 + else => { 633 + try s.reply(503, "5.5.1 BDAT expected"); 634 + return .done; 635 + }, 636 + } 637 + } 638 + if (oversize) { 639 + try s.reply(552, "5.3.4 Message exceeds maximum size"); 640 + return .done; 641 + } 642 + switch (s.handler.vtable.message.?(s.handler.context, envelope, data.items)) { 643 + .accept => try s.reply(250, "2.0.0 Ok, message accepted"), 644 + .reject => |r| try s.reply(r.code, r.text), 645 + } 646 + return .done; 647 + } 648 + 649 + /// Adapts a BDAT chunk sequence into an `Io.Reader` of the raw message 650 + /// content for `Handler.VTable.messageReader`, replying 250 between chunks 651 + /// and following the chunk headers as they arrive. 652 + const BdatReader = struct { 653 + server: *Server, 654 + interface: Io.Reader, 655 + remaining: u64, 656 + last: bool, 657 + finished: bool = false, 658 + abort: ?Abort = null, 659 + 660 + const Abort = enum { rset, quit, protocol, disconnected, transport_failure }; 661 + 662 + fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize { 663 + const br: *BdatReader = @alignCast(@fieldParentPtr("interface", io_r)); 664 + const s = br.server; 665 + while (br.remaining == 0) { 666 + if (br.last) { 667 + br.finished = true; 668 + return error.EndOfStream; 669 + } 670 + s.reply(250, "2.0.0 Chunk received") catch { 671 + br.abort = .transport_failure; 672 + return error.ReadFailed; 673 + }; 674 + const line = protocol.readLine(s.reader) catch |err| { 675 + switch (err) { 676 + error.EndOfStream => br.abort = .disconnected, 677 + error.ReadFailed => br.abort = .transport_failure, 678 + error.LineTooLong => { 679 + s.discardLine() catch {}; 680 + s.reply(500, "5.5.2 Line too long") catch {}; 681 + br.abort = .protocol; 682 + }, 683 + } 684 + return error.ReadFailed; 685 + }; 686 + const command = protocol.Command.parse(line) catch { 687 + s.reply(501, "5.5.4 Syntax error in parameters") catch {}; 688 + br.abort = .protocol; 689 + return error.ReadFailed; 690 + }; 691 + switch (command) { 692 + .bdat => |b| { 693 + br.remaining = b.size; 694 + br.last = b.last; 695 + }, 696 + .rset => { 697 + s.reply(250, "2.0.0 Ok") catch {}; 698 + br.abort = .rset; 699 + return error.ReadFailed; 700 + }, 701 + .quit => { 702 + s.reply(221, "2.0.0 Bye") catch {}; 703 + if (s.secured) s.tls_connection.close() catch {}; 704 + br.abort = .quit; 705 + return error.ReadFailed; 706 + }, 707 + else => { 708 + s.reply(503, "5.5.1 BDAT expected") catch {}; 709 + br.abort = .protocol; 710 + return error.ReadFailed; 711 + }, 712 + } 713 + } 714 + const available = s.reader.peekGreedy(1) catch |err| switch (err) { 715 + error.EndOfStream => { 716 + br.abort = .disconnected; 717 + return error.ReadFailed; 718 + }, 719 + error.ReadFailed => { 720 + br.abort = .transport_failure; 721 + return error.ReadFailed; 722 + }, 723 + }; 724 + const dest = limit.slice(try w.writableSliceGreedy(1)); 725 + const n: usize = @intCast(@min(@min(@as(u64, available.len), @as(u64, dest.len)), br.remaining)); 726 + @memcpy(dest[0..n], available[0..n]); 727 + s.reader.toss(n); 728 + br.remaining -= n; 729 + w.advance(n); 730 + return n; 731 + } 732 + }; 510 733 511 734 /// Reads message content after DATA up to the terminating ".\r\n", 512 735 /// un-stuffing dots, then asks the handler to accept or reject. ··· 732 955 733 956 try std.testing.expectEqualStrings( 734 957 "220 mx.test ESMTP ready\r\n" ++ 735 - "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++ 958 + "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++ 736 959 "250 2.1.0 Ok\r\n" ++ 737 960 "250 2.1.5 Ok\r\n" ++ 738 961 "250 2.1.5 Ok\r\n" ++ ··· 1182 1405 "mail from:<a@b> FOO=bar\r\n" ++ 1183 1406 "mail from:<a@b> SIZE=nan\r\n" ++ 1184 1407 "starttls\r\n" ++ 1408 + "BDAT 5\r\n" ++ 1409 + "abc\r\n" ++ 1410 + "mail from:<chunky@test.ex>\r\n" ++ 1411 + "rcpt to:<userx@test.ex>\r\n" ++ 1412 + "BDAT 7\r\n" ++ 1413 + "hello\r\n" ++ 1414 + "BDAT 23 LAST\r\n" ++ 1415 + "world of chunked mail\r\n" ++ 1185 1416 "quit\r\n", 1186 1417 &out_buf, 1187 1418 h.handler(), ··· 1194 1425 "500 5.5.2 Command not recognized\r\n" ++ 1195 1426 "503 5.5.1 Send EHLO first\r\n" ++ 1196 1427 "503 5.5.1 Need MAIL command first\r\n" ++ 1197 - "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n" ++ 1428 + "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n" ++ 1198 1429 "250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++ 1199 1430 "501 5.5.4 Syntax error in parameters\r\n" ++ 1200 1431 "501 5.5.4 Syntax error in parameters\r\n" ++ ··· 1216 1447 "555 5.5.4 Unrecognized parameter\r\n" ++ 1217 1448 "501 5.5.2 Invalid SIZE parameter\r\n" ++ 1218 1449 "502 5.5.1 STARTTLS not supported\r\n" ++ 1450 + "503 5.5.1 Need RCPT command first\r\n" ++ 1451 + "250 2.1.0 Ok\r\n" ++ 1452 + "250 2.1.5 Ok\r\n" ++ 1453 + "250 2.0.0 Chunk received\r\n" ++ 1454 + "250 2.0.0 Ok, message accepted\r\n" ++ 1219 1455 "221 2.0.0 Bye\r\n", 1220 1456 output, 1221 1457 ); 1222 - try std.testing.expectEqualStrings("ok@test1", h.from.items); 1223 - try std.testing.expectEqualStrings("userx@test.ex;route@test.ex;", h.recipients.items); 1458 + try std.testing.expectEqual(@as(usize, 2), h.messages_accepted); 1459 + try std.testing.expectEqualStrings("ok@test1chunky@test.ex", h.from.items); 1224 1460 try std.testing.expectEqualStrings( 1225 - ".that line started with a dot\r\n. and one starting with two dots\r\nMessage body\r\n", 1461 + "userx@test.ex;route@test.ex;userx@test.ex;", 1462 + h.recipients.items, 1463 + ); 1464 + try std.testing.expectEqualStrings( 1465 + ".that line started with a dot\r\n. and one starting with two dots\r\nMessage body\r\n" ++ 1466 + "hello\r\nworld of chunked mail\r\n", 1226 1467 h.data.items, 1227 1468 ); 1228 - try std.testing.expectEqual(@as(?u64, 100), h.declared_size); 1229 - try std.testing.expectEqual(Envelope.Body.eight_bit_mime, h.body); 1230 1469 } 1231 1470 // SPDX-SnippetEnd 1471 + 1472 + test "BDAT chunks are reassembled without unstuffing" { 1473 + var h: TestHandler = .{}; 1474 + defer h.deinit(); 1475 + 1476 + var out_buf: [1024]u8 = undefined; 1477 + const output = try runScript( 1478 + "EHLO client.example.org\r\n" ++ 1479 + "MAIL FROM:<alice@example.com>\r\n" ++ 1480 + "RCPT TO:<bob@example.net>\r\n" ++ 1481 + "BDAT 20\r\n" ++ 1482 + "Subject: chunked\r\n\r\n" ++ // exactly 20 raw octets 1483 + "BDAT 18\r\n" ++ 1484 + ".dots stay\nas-is\r\n" ++ // 18 raw octets, no unstuffing 1485 + "BDAT 0 LAST\r\n" ++ 1486 + "QUIT\r\n", 1487 + &out_buf, 1488 + h.handler(), 1489 + .{}, 1490 + ); 1491 + 1492 + try std.testing.expectEqualStrings( 1493 + "Subject: chunked\r\n\r\n.dots stay\nas-is\r\n", 1494 + h.data.items, 1495 + ); 1496 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1497 + try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Chunk received") != null); 1498 + try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 1499 + } 1500 + 1501 + test "BDAT framing is length-based, not content-based" { 1502 + var h: TestHandler = .{}; 1503 + defer h.deinit(); 1504 + 1505 + var out_buf: [1024]u8 = undefined; 1506 + const output = try runScript( 1507 + "EHLO client.example.org\r\n" ++ 1508 + // Without a transaction the chunk must still be consumed, or the 1509 + // embedded commands would be executed. 1510 + "BDAT 12\r\n" ++ 1511 + "QUIT\r\nRSET\r\n" ++ 1512 + "MAIL FROM:<alice@example.com>\r\n" ++ 1513 + "RCPT TO:<bob@example.net>\r\n" ++ 1514 + // A chunk whose payload looks like commands is still just data. 1515 + "BDAT 23 LAST\r\n" ++ 1516 + "QUIT\r\nMAIL FROM:<x@y>\r\n" ++ 1517 + "QUIT\r\n", 1518 + &out_buf, 1519 + h.handler(), 1520 + .{}, 1521 + ); 1522 + 1523 + try std.testing.expectEqualStrings("QUIT\r\nMAIL FROM:<x@y>\r\n", h.data.items); 1524 + try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null); 1525 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1526 + try std.testing.expect(std.mem.indexOf(u8, output, "221 2.0.0 Bye") != null); 1527 + } 1528 + 1529 + test "RSET between BDAT chunks aborts the message" { 1530 + var h: TestHandler = .{}; 1531 + defer h.deinit(); 1532 + 1533 + var out_buf: [1024]u8 = undefined; 1534 + const output = try runScript( 1535 + "EHLO client.example.org\r\n" ++ 1536 + "MAIL FROM:<alice@example.com>\r\n" ++ 1537 + "RCPT TO:<bob@example.net>\r\n" ++ 1538 + "BDAT 5\r\n" ++ 1539 + "abc\r\n" ++ 1540 + "RSET\r\n" ++ 1541 + "NOOP\r\n" ++ 1542 + "QUIT\r\n", 1543 + &out_buf, 1544 + h.handler(), 1545 + .{}, 1546 + ); 1547 + 1548 + try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 1549 + try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Chunk received") != null); 1550 + try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n250 2.0.0 Ok\r\n221") != null); 1551 + } 1552 + 1553 + test "oversize BDAT message is rejected" { 1554 + var h: TestHandler = .{}; 1555 + defer h.deinit(); 1556 + 1557 + var out_buf: [1024]u8 = undefined; 1558 + const output = try runScript( 1559 + "EHLO client.example.org\r\n" ++ 1560 + "MAIL FROM:<alice@example.com>\r\n" ++ 1561 + "RCPT TO:<bob@example.net>\r\n" ++ 1562 + "BDAT 40 LAST\r\n" ++ 1563 + "0123456789012345678901234567890123456789" ++ 1564 + "NOOP\r\n" ++ 1565 + "QUIT\r\n", 1566 + &out_buf, 1567 + h.handler(), 1568 + .{ .max_message_size = 16 }, 1569 + ); 1570 + 1571 + try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 1572 + try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 1573 + try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 1574 + } 1575 + 1576 + test "streaming handler receives BDAT chunks" { 1577 + var h: StreamTestHandler = .{}; 1578 + defer h.collected.deinit(std.testing.allocator); 1579 + 1580 + var out_buf: [1024]u8 = undefined; 1581 + const output = try runScript( 1582 + "EHLO client.example.org\r\n" ++ 1583 + "MAIL FROM:<alice@example.com>\r\n" ++ 1584 + "RCPT TO:<bob@example.net>\r\n" ++ 1585 + "BDAT 6\r\n" ++ 1586 + "part1\n" ++ 1587 + "BDAT 8 LAST\r\n" ++ 1588 + ".part2\r\n" ++ 1589 + "QUIT\r\n", 1590 + &out_buf, 1591 + h.handler(), 1592 + .{}, 1593 + ); 1594 + 1595 + try std.testing.expectEqualStrings("part1\n.part2\r\n", h.collected.items); 1596 + try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 1597 + } 1598 + 1599 + test "session drains BDAT chunks a streaming handler leaves unread" { 1600 + var h: StreamTestHandler = .{ .take_only = 4 }; 1601 + defer h.collected.deinit(std.testing.allocator); 1602 + 1603 + var out_buf: [1024]u8 = undefined; 1604 + const output = try runScript( 1605 + "EHLO client.example.org\r\n" ++ 1606 + "MAIL FROM:<alice@example.com>\r\n" ++ 1607 + "RCPT TO:<bob@example.net>\r\n" ++ 1608 + "BDAT 10\r\n" ++ 1609 + "0123456789" ++ 1610 + "BDAT 10 LAST\r\n" ++ 1611 + "abcdefghij" ++ 1612 + "NOOP\r\n" ++ 1613 + "QUIT\r\n", 1614 + &out_buf, 1615 + h.handler(), 1616 + .{}, 1617 + ); 1618 + 1619 + try std.testing.expectEqualStrings("0123", h.collected.items); 1620 + try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 1621 + // The NOOP after the final chunk proves the stream stayed in sync. 1622 + try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 1623 + }
+25 -3
src/main.zig
··· 3 3 4 4 //! Demo CLI for the zsmtp library. 5 5 //! 6 - //! zsmtp send [--tls|--starttls] [--insecure] [--user <u> --password <p>] 6 + //! zsmtp send [--tls|--starttls] [--insecure] [--chunking] 7 + //! [--user <u> --password <p>] 7 8 //! [--auth-method plain|login|cram-md5] <host> <port> <from> <to>... 8 9 //! send a message read from stdin; --tls speaks TLS from the first 9 10 //! byte (port 465 style), --starttls upgrades after EHLO (port 587 ··· 36 37 config.mode = .starttls; 37 38 } else if (std.mem.eql(u8, rest[0], "--insecure")) { 38 39 config.insecure = true; 40 + } else if (std.mem.eql(u8, rest[0], "--chunking")) { 41 + config.chunking = true; 39 42 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--user")) { 40 43 config.username = rest[1]; 41 44 rest = rest[1..]; ··· 98 101 const SendConfig = struct { 99 102 mode: enum { plain, tls, starttls } = .plain, 100 103 insecure: bool = false, 104 + chunking: bool = false, 101 105 username: ?[]const u8 = null, 102 106 password: ?[]const u8 = null, 103 107 auth_method: enum { auto, plain, login, cram_md5 } = .auto, ··· 186 190 }; 187 191 } 188 192 189 - transact(&client, from, recipients, &stdin.interface) catch |err| { 193 + if (config.chunking and !extensions.chunking) { 194 + std.log.err("server does not advertise CHUNKING", .{}); 195 + return error.ChunkingNotAdvertised; 196 + } 197 + transact(&client, from, recipients, &stdin.interface, config.chunking) catch |err| { 190 198 if (err == error.UnexpectedReply) { 191 199 const reply = client.last_reply.?; 192 200 std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text }); ··· 204 212 from: []const u8, 205 213 recipients: []const []const u8, 206 214 message: *Io.Reader, 215 + chunking: bool, 207 216 ) zsmtp.Client.Error!void { 208 217 try client.mailFrom(from); 209 218 for (recipients) |recipient| try client.rcptTo(recipient); 210 - try client.sendMessageReader(message); 219 + if (chunking) { 220 + // BDAT sends the input verbatim (no line-ending normalization). 221 + while (true) { 222 + const chunk = message.peekGreedy(1) catch |err| switch (err) { 223 + error.EndOfStream => break, 224 + error.ReadFailed => return error.ReadFailed, 225 + }; 226 + try client.bdat(chunk, false); 227 + message.toss(chunk.len); 228 + } 229 + try client.bdat("", true); 230 + } else { 231 + try client.sendMessageReader(message); 232 + } 211 233 } 212 234 213 235 fn serve(io: Io, gpa: std.mem.Allocator, config: ServeConfig, port_arg: []const u8) !void {
+37 -1
src/protocol.zig
··· 150 150 starttls, 151 151 /// AUTH ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 152 152 auth: AuthArgs, 153 + /// BDAT, the CHUNKING extension 154 + /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). The 155 + /// command line is followed by exactly `size` raw octets. 156 + bdat: BdatArgs, 153 157 /// Unrecognized command verb; the payload is the full line. 154 158 unknown: []const u8, 159 + 160 + pub const BdatArgs = struct { 161 + size: u64, 162 + /// True for the final chunk of the message ("BDAT n LAST"). 163 + last: bool = false, 164 + }; 155 165 156 166 pub const AuthArgs = struct { 157 167 mechanism: []const u8, ··· 205 215 if (ieql(verb, "VRFY")) return .{ .vrfy = rest }; 206 216 if (ieql(verb, "HELP")) return .help; 207 217 if (ieql(verb, "STARTTLS")) return .starttls; 218 + if (ieql(verb, "BDAT")) { 219 + var it = std.mem.tokenizeAny(u8, rest, " \t"); 220 + const size_token = it.next() orelse return error.Syntax; 221 + const size = std.fmt.parseInt(u64, size_token, 10) catch return error.Syntax; 222 + var last = false; 223 + if (it.next()) |token| { 224 + if (!ieql(token, "LAST")) return error.Syntax; 225 + last = true; 226 + } 227 + if (it.next() != null) return error.Syntax; 228 + return .{ .bdat = .{ .size = size, .last = last } }; 229 + } 208 230 if (ieql(verb, "AUTH")) { 209 231 const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len; 210 232 if (mech_end == 0) return error.Syntax; ··· 404 426 const cmd = try Command.parse("MADE UP"); 405 427 try std.testing.expectEqualStrings("MADE UP", cmd.unknown); 406 428 } 429 + { 430 + const cmd = try Command.parse("BDAT 1024"); 431 + try std.testing.expectEqual(@as(u64, 1024), cmd.bdat.size); 432 + try std.testing.expect(!cmd.bdat.last); 433 + } 434 + { 435 + const cmd = try Command.parse("bdat 0 last"); 436 + try std.testing.expectEqual(@as(u64, 0), cmd.bdat.size); 437 + try std.testing.expect(cmd.bdat.last); 438 + } 439 + try std.testing.expectError(error.Syntax, Command.parse("BDAT")); 440 + try std.testing.expectError(error.Syntax, Command.parse("BDAT nan")); 441 + try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 FIRST")); 442 + try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 LAST extra")); 407 443 try std.testing.expectError(error.Syntax, Command.parse("AUTH")); 408 444 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>")); 409 445 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:")); ··· 458 494 try std.testing.expect(args.mechanism.len <= line.len); 459 495 try std.testing.expect(args.initial.len <= line.len); 460 496 }, 461 - .data, .rset, .noop, .quit, .help, .starttls => {}, 497 + .data, .rset, .noop, .quit, .help, .starttls, .bdat => {}, 462 498 } 463 499 } 464 500
+14
test/protocol-torture.script
··· 11 11 ??? 250- 12 12 ??? 250-PIPELINING 13 13 ??? 250-8BITMIME 14 + ??? 250-CHUNKING 14 15 ??? 250-ENHANCEDSTATUSCODES 15 16 ??? 250 SIZE 16 17 mail ··· 56 57 ??? 501 57 58 starttls 58 59 ??? 502 60 + BDAT 5 61 + abc 62 + ??? 503 63 + mail from:<chunky@test.ex> 64 + ??? 250 65 + rcpt to:<userx@test.ex> 66 + ??? 250 67 + BDAT 7 68 + hello 69 + ??? 250 70 + BDAT 23 LAST 71 + world of chunked mail 72 + ??? 250 59 73 quit 60 74 ??? 221