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.

Fetch the is_email corpus as a lazy dependency instead of vendoring it

The RFC 5321 address round-trip test now embeds tests.xml and
tests-original.xml straight from Dominic Sayers' canonical isemail
repository (pinned, .lazy = true), gated by -Disemail-corpus so plain
builds and the network-less Nix sandbox never fetch it. The test parses
the XML at test time (element extraction plus full entity unescaping),
filters both files to the RFC 5321-valid categories, round-trips all
125 addresses through Command.parse, and asserts a minimum count so
the extraction cannot silently rot; without the option it skips.

The canonical repo supersedes the copy bundled with the
email-addresses npm package: same current tests.xml plus
tests-original.xml with 92 additional RFC 5321-valid cases (richer
quoted-string escapes and address literals). Since no corpus text is
distributed in this repository anymore, the BSD-3-Clause snippet,
license file, and README note are removed.

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

+124
+14
README.md
··· 260 260 ./zig-out/bin/exim-client 127.0.0.1 2525 < test/protocol-torture.script 261 261 ``` 262 262 263 + ### Address corpus testing with the is_email suite 264 + 265 + Dominic Sayers' [is_email](https://github.com/dominicsayers/isemail) test 266 + suite (BSD-3-Clause) is declared as a *lazy* Zig dependency; nothing from 267 + it is copied into this repository. On demand, the corpus test embeds its 268 + XML test files, extracts the 125 addresses valid at the RFC 5321 layer, 269 + and checks that each passes through the path parser byte-for-byte: 270 + 271 + ```sh 272 + zig build test -Disemail-corpus # fetches the suite and runs the corpus test 273 + ``` 274 + 275 + Without the option the corpus test is skipped. 276 + 263 277 `test/protocol-torture.script` is a 28-reply dialogue distilled from 264 278 exim's own test suite (syntax errors, sequencing violations, parameter 265 279 validation, dot-stuffing); the same dialogue is asserted byte-for-byte
+20
build.zig
··· 126 126 run_cmd.addArgs(args); 127 127 } 128 128 129 + // The is_email address corpus test embeds its XML test files from the 130 + // lazy isemail dependency, fetched only on demand: 131 + // zig build test -Disemail-corpus 132 + const isemail_corpus = b.option(bool, "isemail-corpus", "Fetch Dominic Sayers' is_email suite and run the address corpus test") orelse false; 133 + var isemail_available = false; 134 + if (isemail_corpus) { 135 + if (b.lazyDependency("isemail", .{})) |isemail_dep| { 136 + mod.addAnonymousImport("isemail_tests_xml", .{ 137 + .root_source_file = isemail_dep.path("test/tests.xml"), 138 + }); 139 + mod.addAnonymousImport("isemail_tests_original_xml", .{ 140 + .root_source_file = isemail_dep.path("test/tests-original.xml"), 141 + }); 142 + isemail_available = true; 143 + } 144 + } 145 + const test_options = b.addOptions(); 146 + test_options.addOption(bool, "isemail_corpus", isemail_available); 147 + mod.addOptions("build_options", test_options); 148 + 129 149 // Exim's scriptable SMTP test client, handy for driving the zsmtp 130 150 // server through raw protocol dialogues with reply expectations (see 131 151 // test/protocol-torture.script). Guarded by an option so the lazy exim
+7
build.zig.zon
··· 46 46 .hash = "N-V-__8AAFtMiwHRrbE2kejFyc1FTFciBTZJlJOPGt-eIVGJ", 47 47 .lazy = true, 48 48 }, 49 + // Dominic Sayers' is_email test suite, used only for the address 50 + // corpus test. Lazy: fetched only by `zig build test -Disemail-corpus`. 51 + .isemail = .{ 52 + .url = "https://github.com/dominicsayers/isemail/archive/cfeefc3f2f88cb195053f6a309fa4f640cd369b5.tar.gz", 53 + .hash = "N-V-__8AAI5RBAAIsFSqnDYObnOsBo3t9cQMXnvu0vQQRSAf", 54 + .lazy = true, 55 + }, 49 56 }, 50 57 .paths = .{ 51 58 "build.zig",
+83
src/protocol.zig
··· 576 576 test crlf { 577 577 try std.testing.expectEqualStrings("\r\n", crlf); 578 578 } 579 + 580 + test "RFC 5321 mailbox forms from the is_email corpus round-trip" { 581 + // Parses Dominic Sayers' is_email test suite (tests.xml and 582 + // tests-original.xml, embedded from the lazy `isemail` dependency by 583 + // `zig build test -Disemail-corpus`) and checks that every address 584 + // valid at the RFC 5321 layer passes through the lenient path parser 585 + // byte-for-byte, params intact. 586 + if (comptime @import("build_options").isemail_corpus) { 587 + const corpus = @embedFile("isemail_tests_xml") ++ "\n" ++ 588 + @embedFile("isemail_tests_original_xml"); 589 + var checked: usize = 0; 590 + var rest: []const u8 = corpus; 591 + while (std.mem.indexOf(u8, rest, "<test ")) |start_index| { 592 + const end_index = std.mem.indexOfPos(u8, rest, start_index, "</test>") orelse break; 593 + const block = rest[start_index..end_index]; 594 + rest = rest[end_index + "</test>".len ..]; 595 + 596 + const category = xmlElementText(block, "category") orelse continue; 597 + if (!std.mem.eql(u8, category, "ISEMAIL_VALID_CATEGORY") and 598 + !std.mem.eql(u8, category, "ISEMAIL_RFC5321")) continue; 599 + const raw = xmlElementText(block, "address") orelse continue; 600 + var address_buf: [256]u8 = undefined; 601 + const address = try xmlUnescape(&address_buf, raw); 602 + 603 + var line_buf: [300]u8 = undefined; 604 + const line = try std.fmt.bufPrint(&line_buf, "MAIL FROM:<{s}> SIZE=1", .{address}); 605 + const command = try Command.parse(line); 606 + try std.testing.expectEqualStrings(address, command.mail.path); 607 + try std.testing.expectEqualStrings("SIZE=1", command.mail.params); 608 + checked += 1; 609 + } 610 + // The two files carry 125 RFC 5321-valid cases between them; fail 611 + // loudly if the extraction ever silently rots. 612 + try std.testing.expect(checked >= 120); 613 + } else return error.SkipZigTest; 614 + } 615 + 616 + fn xmlElementText(block: []const u8, comptime tag: []const u8) ?[]const u8 { 617 + const open = "<" ++ tag ++ ">"; 618 + const close = "</" ++ tag ++ ">"; 619 + const start = (std.mem.indexOf(u8, block, open) orelse return null) + open.len; 620 + const end = std.mem.indexOfPos(u8, block, start, close) orelse return null; 621 + return block[start..end]; 622 + } 623 + 624 + fn xmlUnescape(buffer: []u8, input: []const u8) ![]const u8 { 625 + var out: usize = 0; 626 + var i: usize = 0; 627 + while (i < input.len) { 628 + if (input[i] != '&') { 629 + buffer[out] = input[i]; 630 + out += 1; 631 + i += 1; 632 + continue; 633 + } 634 + const semi = std.mem.indexOfScalarPos(u8, input, i, ';') orelse return error.BadEntity; 635 + const entity = input[i + 1 .. semi]; 636 + i = semi + 1; 637 + if (std.mem.eql(u8, entity, "amp")) { 638 + buffer[out] = '&'; 639 + out += 1; 640 + } else if (std.mem.eql(u8, entity, "lt")) { 641 + buffer[out] = '<'; 642 + out += 1; 643 + } else if (std.mem.eql(u8, entity, "gt")) { 644 + buffer[out] = '>'; 645 + out += 1; 646 + } else if (std.mem.eql(u8, entity, "quot")) { 647 + buffer[out] = '"'; 648 + out += 1; 649 + } else if (std.mem.eql(u8, entity, "apos")) { 650 + buffer[out] = '\''; 651 + out += 1; 652 + } else if (std.mem.startsWith(u8, entity, "#x") or std.mem.startsWith(u8, entity, "#X")) { 653 + const codepoint = try std.fmt.parseInt(u21, entity[2..], 16); 654 + out += try std.unicode.utf8Encode(codepoint, buffer[out..]); 655 + } else if (std.mem.startsWith(u8, entity, "#")) { 656 + const codepoint = try std.fmt.parseInt(u21, entity[1..], 10); 657 + out += try std.unicode.utf8Encode(codepoint, buffer[out..]); 658 + } else return error.BadEntity; 659 + } 660 + return buffer[0..out]; 661 + }