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.

Read enhanced status codes, and check that we emit them

`Reply.enhanced` parses the class.subject.detail code RFC 3463 defines
and RFC 2034 puts at the front of a reply's text; `Reply.message` gives
the text without it. 550 is "no", where 5.1.1 is "no, that mailbox does
not exist" and 5.7.1 is "no, and not because of anything about the
address" -- a difference a caller can act on and the three digits cannot
express.

The parser is strict on purpose, because the failure mode of a loose one
is misreading an ordinary message that happens to start with digits. The
class must be one of the three RFC 3463 defines, each field is one to
three digits with no leading zeros, and the code must be followed by a
space or be the whole text. "2.1 GB is too large" is not a status code
and does not parse as one.

`agrees` is there because nothing else checks it: a 250 carrying a 5.x.x
code is a server contradicting itself, RFC 3463 does not say what a
receiver should do about that, and a caller that reads only one half will
believe the wrong one.

The other half of this is a test that walks a session touching most of
the command table and checks every reply against RFC 2034's rule --
prefaced with a code whose class agrees, except the greeting, the EHLO
response and any 3xx, which must *not* carry one. It passes, and it is
not vacuous: deleting the code from one reply makes it fail naming that
line.

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

+347 -6
+24 -4
README.md
··· 52 52 try client.quit(); 53 53 ``` 54 54 55 + `Reply` carries more than three digits. `enhanced()` reads the 56 + `class.subject.detail` code RFC 3463 defines and 57 + [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) puts at the front 58 + of the text, and `message()` gives the text without it: 59 + 60 + ```zig 61 + const reply = client.last_reply.?; 62 + if (reply.enhanced()) |status| switch (status.subjectClass()) { 63 + .addressing => {}, // 5.1.x — something about the address 64 + .security => {}, // 5.7.x — policy, nothing to do with the address 65 + else => {}, 66 + } 67 + ``` 68 + 69 + `550` is "no"; `5.1.1` is "no, that mailbox does not exist" and `5.7.1` is 70 + "no, and not because of anything about the address". Check 71 + `status.agrees(reply.code)` before acting on it — a 250 carrying a 5.x.x 72 + code is a server contradicting itself. The greeting, the EHLO response and 73 + any 3xx carry no code, by RFC 2034's own exclusions, so `enhanced()` 74 + answers null there and is right to. 75 + 55 76 Line endings in the message are normalized to CRLF and leading dots are 56 77 stuffed automatically. On `error.UnexpectedReply`, `client.last_reply` holds 57 78 the server's actual code and text. `mailFrom`/`rcptTo`/`sendMessage` are also ··· 493 514 ### Protocol 494 515 495 516 - **Client certificates** — neither side can present or verify one. 496 - - **No enhanced status code accessor** — the server emits `x.y.z` on every 497 - reply, but `Reply` exposes only `code` and the raw text. 498 517 - `EXPN` is unrecognized rather than unimplemented, so it answers 500 where 499 518 [RFC 5321 §4.2.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.4) 500 519 wants 502. ··· 594 613 CRAM-MD5 and EXTERNAL among them. 595 614 - [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) / 596 615 [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced 597 - status codes: carried in every server reply and advertised via 598 - ENHANCEDSTATUSCODES; detected by the client. 616 + status codes: advertised and attached to every reply RFC 2034 asks for, 617 + with a test that walks a whole session and checks each one against that 618 + rule; read back by the client through `Reply.enhanced`. 599 619 - [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) — SMTPUTF8: 600 620 client (`mailFromUtf8`) and server (advertised; non-ASCII addresses 601 621 require the parameter and must be valid UTF-8, rejected with 553 5.6.7
+65
src/Client.zig
··· 1682 1682 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); 1683 1683 } 1684 1684 1685 + test "a rejection's enhanced status code says more than its reply code" { 1686 + // Two different refusals behind the same 550: one about the address, 1687 + // one about policy. The three-digit code cannot tell them apart and the 1688 + // enhanced one can, which is the whole reason to read it. 1689 + var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n550 5.1.1 No such user\r\n"); 1690 + var out_buf: [256]u8 = undefined; 1691 + var writer: Io.Writer = .fixed(&out_buf); 1692 + var reply_buf: [256]u8 = undefined; 1693 + var client: Client = .init(&reader, &writer, &reply_buf); 1694 + 1695 + try client.mailFrom("alice@example.com"); 1696 + try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.net")); 1697 + 1698 + const reply = client.last_reply.?; 1699 + const status = reply.enhanced().?; 1700 + try std.testing.expect(status.agrees(reply.code)); 1701 + try std.testing.expectEqual(protocol.Enhanced.Subject.addressing, status.subjectClass()); 1702 + try std.testing.expectEqual(@as(u16, 1), status.detail); 1703 + // And the part meant for a person, without the code in front of it. 1704 + try std.testing.expectEqualStrings("No such user", reply.message()); 1705 + } 1706 + 1707 + test "a server that contradicts itself is detectable" { 1708 + // 250 carrying a 5.x.x code. Nothing in RFC 3463 says what to do about 1709 + // it, but a caller can at least see it rather than trusting either half. 1710 + var reader: Io.Reader = .fixed("250 5.1.1 Ok?\r\n"); 1711 + var out_buf: [128]u8 = undefined; 1712 + var writer: Io.Writer = .fixed(&out_buf); 1713 + var reply_buf: [128]u8 = undefined; 1714 + var client: Client = .init(&reader, &writer, &reply_buf); 1715 + 1716 + try client.mailFrom("alice@example.com"); // the 2xx is what `mail` checks 1717 + const reply = client.last_reply.?; 1718 + try std.testing.expect(!reply.enhanced().?.agrees(reply.code)); 1719 + } 1720 + 1721 + test "a multiline reply repeats the code on every line" { 1722 + const responses = "250-mx.example.com\r\n250 SIZE 1000000\r\n" ++ 1723 + "452-4.5.3 Too many recipients\r\n452 4.5.3 Try fewer\r\n"; 1724 + var reader: Io.Reader = .fixed(responses); 1725 + var out_buf: [256]u8 = undefined; 1726 + var writer: Io.Writer = .fixed(&out_buf); 1727 + var reply_buf: [256]u8 = undefined; 1728 + var client: Client = .init(&reader, &writer, &reply_buf); 1729 + 1730 + _ = try client.hello("client.example.org"); 1731 + try std.testing.expectError(error.UnexpectedReply, client.rcptTo("b@example.net")); 1732 + 1733 + const reply = client.last_reply.?; 1734 + // `message` strips the first line's code; the rest are reached through 1735 + // `lines`, which is what the doc comment says to do. 1736 + try std.testing.expectEqualStrings("Too many recipients\nTry fewer", blk: { 1737 + var joined: [64]u8 = undefined; 1738 + var out: Io.Writer = .fixed(&joined); 1739 + var it = reply.lines(); 1740 + var first = true; 1741 + while (it.next()) |line| { 1742 + if (!first) try out.writeByte('\n'); 1743 + first = false; 1744 + try out.writeAll(protocol.Enhanced.strip(line)); 1745 + } 1746 + break :blk out.buffered(); 1747 + }); 1748 + } 1749 + 1685 1750 test "an address carrying CRLF cannot inject a command" { 1686 1751 // Without the check this would put a second RCPT on the wire. 1687 1752 const smuggled = "bob@example.net>\r\nRCPT TO:<victim@example.net";
+76
src/Server.zig
··· 1826 1826 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 1827 1827 } 1828 1828 1829 + test "every reply that should carry an enhanced status code does" { 1830 + // RFC 2034 §4: a server implementing the extension prefaces the text of 1831 + // every 2xx, 4xx and 5xx reply with a status code whose class agrees -- 1832 + // except the greeting, the response to HELO or EHLO, and any 3xx. This 1833 + // walks a session that touches most of the command table and checks the 1834 + // whole transcript against that rule rather than reply by reply. 1835 + var h: TestHandler = .{ .password = "secret" }; 1836 + defer h.deinit(); 1837 + var mechanisms: TestMechanisms = .init(&h); 1838 + 1839 + var out_buf: [8192]u8 = undefined; 1840 + const output = try runScript( 1841 + "EHLO client.example.org\r\n" ++ 1842 + "NOOP\r\n" ++ 1843 + "VRFY somebody\r\n" ++ 1844 + "HELP\r\n" ++ 1845 + "WHAT\r\n" ++ // 500 1846 + "MAIL FROM:<a@example.com> FROB=1\r\n" ++ // 555 1847 + "MAIL FROM:<a@example.com> SIZE=99999999\r\n" ++ // 552 1848 + "RCPT TO:<b@example.net>\r\n" ++ // 503, no MAIL yet 1849 + "AUTH GSSAPI\r\n" ++ // 504 1850 + "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // 535 1851 + "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // 235 1852 + "MAIL FROM:<a@example.com>\r\n" ++ 1853 + "RCPT TO:<b@example.net>\r\n" ++ 1854 + "DATA\r\nbody\r\n.\r\n" ++ 1855 + "RSET\r\n" ++ 1856 + "QUIT\r\n", 1857 + &out_buf, 1858 + h.handler(), 1859 + .{ 1860 + .max_message_size = 1024, 1861 + .auth_mechanisms = mechanisms.list(), 1862 + .sasl_buffer = mechanisms.scratch(), 1863 + }, 1864 + ); 1865 + 1866 + var checked: usize = 0; 1867 + var greeting = true; 1868 + var in_ehlo = false; 1869 + var lines = std.mem.splitSequence(u8, output, "\r\n"); 1870 + while (lines.next()) |line| { 1871 + if (line.len < 4) continue; 1872 + const code = std.fmt.parseInt(u16, line[0..3], 10) catch continue; 1873 + const continued = line[3] == '-'; 1874 + const text = line[4..]; 1875 + 1876 + // The exclusions, in the order a session meets them. 1877 + if (greeting) { 1878 + greeting = false; 1879 + continue; 1880 + } 1881 + if (in_ehlo or (code == 250 and continued)) { 1882 + in_ehlo = continued; 1883 + continue; 1884 + } 1885 + if (code / 100 == 3) { 1886 + // 354 and the 334 challenges, which RFC 2034 leaves out. 1887 + try std.testing.expectEqual(@as(?protocol.Enhanced, null), protocol.Enhanced.parse(text)); 1888 + continue; 1889 + } 1890 + 1891 + const status = protocol.Enhanced.parse(text) orelse { 1892 + std.debug.print("no enhanced status code: {s}\n", .{line}); 1893 + return error.TestUnexpectedResult; 1894 + }; 1895 + if (!status.agrees(code)) { 1896 + std.debug.print("class disagrees with the reply code: {s}\n", .{line}); 1897 + return error.TestUnexpectedResult; 1898 + } 1899 + checked += 1; 1900 + } 1901 + // Enough of them to mean the walk actually walked. 1902 + try std.testing.expect(checked >= 14); 1903 + } 1904 + 1829 1905 test "an authenticated client's AUTH= assertion reaches the handler" { 1830 1906 var h: TestHandler = .{ .password = "secret" }; 1831 1907 defer h.deinit();
+20 -2
src/main.zig
··· 289 289 switch (err) { 290 290 error.AuthenticationFailed => { 291 291 const reply = client.last_reply.?; 292 - std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text }); 292 + // 5.7.8 is "bad credentials" where 4.7.0 is "try again"; 293 + // the three-digit code says neither. 294 + if (reply.enhanced()) |status| { 295 + std.log.err("authentication failed: {d} {f} {s}", .{ 296 + reply.code, 297 + status, 298 + reply.message(), 299 + }); 300 + } else { 301 + std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text }); 302 + } 293 303 }, 294 304 error.ServerNotAuthenticated => std.log.err( 295 305 "the server accepted the login without proving itself; " ++ ··· 338 348 transact(&client, config, from, recipients, &stdin.interface) catch |err| { 339 349 if (err == error.UnexpectedReply) { 340 350 const reply = client.last_reply.?; 341 - std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text }); 351 + if (reply.enhanced()) |status| { 352 + std.log.err("server rejected: {d} {f} {s}", .{ 353 + reply.code, 354 + status, 355 + reply.message(), 356 + }); 357 + } else { 358 + std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text }); 359 + } 342 360 } 343 361 return err; 344 362 };
+162
src/protocol.zig
··· 60 60 return line; 61 61 } 62 62 63 + /// An enhanced status code 64 + /// ([RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463)), the 65 + /// `class.subject.detail` triple that 66 + /// [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) puts at the 67 + /// front of a reply's text. 68 + /// 69 + /// It says more than the three-digit code can. `550` is "no", where 70 + /// `5.1.1` is "no, that mailbox does not exist" and `5.7.1` is "no, and not 71 + /// because of anything about the address" — a difference worth acting on. 72 + pub const Enhanced = struct { 73 + /// 2, 4 or 5: success, persistent transient failure, permanent failure. 74 + /// RFC 3463 defines no others. 75 + class: u8, 76 + subject: u16, 77 + detail: u16, 78 + 79 + /// What the subject sub-code is about. Non-exhaustive because the 80 + /// production allows three digits and only eight are defined. 81 + pub const Subject = enum(u16) { 82 + other = 0, 83 + addressing = 1, 84 + mailbox = 2, 85 + mail_system = 3, 86 + network = 4, 87 + delivery_protocol = 5, 88 + content = 6, 89 + security = 7, 90 + _, 91 + }; 92 + 93 + pub fn subjectClass(e: Enhanced) Subject { 94 + return @enumFromInt(e.subject); 95 + } 96 + 97 + /// Whether the class agrees with the reply code it came with. 98 + /// 99 + /// They should: a 250 carrying `5.1.1` is a server contradicting itself, 100 + /// and worth not believing. RFC 3463 does not say what a receiver should 101 + /// do about it, so this only reports it. 102 + pub fn agrees(e: Enhanced, code: u16) bool { 103 + return e.class == code / 100; 104 + } 105 + 106 + /// Reads a code from the front of `text`, or null if there is not one. 107 + /// 108 + /// Strict, because the alternative is misreading an ordinary message 109 + /// that happens to begin with digits: the class must be one RFC 3463 110 + /// defines, each field is one to three digits with no leading zeros, and 111 + /// the code must be followed by a space or be the whole text. 112 + pub fn parse(text: []const u8) ?Enhanced { 113 + var rest = text; 114 + const class = switch (if (rest.len == 0) return null else rest[0]) { 115 + '2', '4', '5' => |digit| digit - '0', 116 + else => return null, 117 + }; 118 + rest = rest[1..]; 119 + if (rest.len == 0 or rest[0] != '.') return null; 120 + rest = rest[1..]; 121 + 122 + const subject = takeNumber(&rest) orelse return null; 123 + if (rest.len == 0 or rest[0] != '.') return null; 124 + rest = rest[1..]; 125 + const detail = takeNumber(&rest) orelse return null; 126 + 127 + // RFC 2034: "always followed by one or more spaces". A code with no 128 + // text after it at all is not what the RFC describes, but servers 129 + // send it and there is nothing ambiguous about it. 130 + if (rest.len != 0 and rest[0] != ' ') return null; 131 + return .{ .class = class, .subject = subject, .detail = detail }; 132 + } 133 + 134 + /// `text` with a leading enhanced code and the spaces after it removed, 135 + /// or `text` unchanged if there was none. 136 + /// 137 + /// A multiline reply repeats the code on every line, so this strips it 138 + /// from the first one only; walk `Reply.lines` and strip each. 139 + pub fn strip(text: []const u8) []const u8 { 140 + if (parse(text) == null) return text; 141 + const space = std.mem.findScalar(u8, text, ' ') orelse return text[text.len..]; 142 + return std.mem.trimStart(u8, text[space..], " "); 143 + } 144 + 145 + /// Writes the code as `class.subject.detail`. 146 + pub fn format(e: Enhanced, writer: *Io.Writer) Io.Writer.Error!void { 147 + try writer.print("{d}.{d}.{d}", .{ e.class, e.subject, e.detail }); 148 + } 149 + 150 + /// One to three digits with no leading zeros, consumed from the front. 151 + fn takeNumber(rest: *[]const u8) ?u16 { 152 + var digits: usize = 0; 153 + while (digits < rest.len and std.ascii.isDigit(rest.*[digits])) digits += 1; 154 + if (digits == 0 or digits > 3) return null; 155 + // "expressed without leading zero digits", so "0" is a number and 156 + // "01" is not one. 157 + if (digits > 1 and rest.*[0] == '0') return null; 158 + const value = std.fmt.parseInt(u16, rest.*[0..digits], 10) catch return null; 159 + rest.* = rest.*[digits..]; 160 + return value; 161 + } 162 + 163 + test parse { 164 + const ok = parse("2.1.5 Ok").?; 165 + try std.testing.expectEqual(@as(u8, 2), ok.class); 166 + try std.testing.expectEqual(Subject.addressing, ok.subjectClass()); 167 + try std.testing.expectEqual(@as(u16, 5), ok.detail); 168 + try std.testing.expect(ok.agrees(250)); 169 + try std.testing.expect(!ok.agrees(550)); 170 + try std.testing.expectEqualStrings("Ok", strip("2.1.5 Ok")); 171 + 172 + // A code with no text after it. 173 + try std.testing.expectEqual(@as(u16, 0), parse("5.0.0").?.detail); 174 + // Three digits each is the most the grammar allows. 175 + try std.testing.expectEqual(@as(u16, 999), parse("5.999.999 x").?.subject); 176 + 177 + // And the things that are not codes. 178 + try std.testing.expectEqual(@as(?Enhanced, null), parse("")); // nothing 179 + try std.testing.expectEqual(@as(?Enhanced, null), parse("3.1.1 x")); // no such class 180 + try std.testing.expectEqual(@as(?Enhanced, null), parse("2.1 x")); // two fields 181 + try std.testing.expectEqual(@as(?Enhanced, null), parse("2.1.5x")); // no space 182 + try std.testing.expectEqual(@as(?Enhanced, null), parse("2.01.5 x")); // leading zero 183 + try std.testing.expectEqual(@as(?Enhanced, null), parse("2.1234.5 x")); // too long 184 + // An ordinary message that begins with something numeric-looking. 185 + try std.testing.expectEqualStrings("2.1 GB is too large", strip("2.1 GB is too large")); 186 + } 187 + }; 188 + 63 189 /// A server reply: a 3-digit code and one or more lines of text. 64 190 pub const Reply = struct { 65 191 code: u16, ··· 125 251 pub fn isTransientFailure(r: Reply) bool { 126 252 return r.code >= 400 and r.code < 500; 127 253 } 254 + /// The enhanced status code at the front of the text, if there is one 255 + /// ([RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034)). 256 + /// 257 + /// A server implementing that extension attaches one to every 2xx, 4xx 258 + /// and 5xx reply whether or not the client said EHLO — but not to the 259 + /// greeting, not to a HELO or EHLO response, and not to a 3xx, so those 260 + /// answer null and correctly so. 261 + /// 262 + /// Worth checking `agrees` against `code` before acting on it: the two 263 + /// disagreeing means the server contradicted itself. 264 + pub fn enhanced(r: Reply) ?Enhanced { 265 + return Enhanced.parse(r.text); 266 + } 267 + 268 + /// The text with the enhanced status code removed, which is the part 269 + /// meant for a person. A multiline reply repeats the code on every line, 270 + /// so this strips it from the first only; walk `lines` and use 271 + /// `Enhanced.strip` on each for the rest. 272 + pub fn message(r: Reply) []const u8 { 273 + return Enhanced.strip(r.text); 274 + } 275 + 276 + test enhanced { 277 + const rejected: Reply = .{ .code = 550, .text = "5.1.1 No such user" }; 278 + const status = rejected.enhanced().?; 279 + try std.testing.expect(status.agrees(rejected.code)); 280 + try std.testing.expectEqual(Enhanced.Subject.addressing, status.subjectClass()); 281 + try std.testing.expectEqualStrings("No such user", rejected.message()); 282 + 283 + // A server that does not implement the extension, and one that is 284 + // excluded from it: neither has a code, and neither is an error. 285 + const plain: Reply = .{ .code = 550, .text = "No such user" }; 286 + try std.testing.expectEqual(@as(?Enhanced, null), plain.enhanced()); 287 + try std.testing.expectEqualStrings("No such user", plain.message()); 288 + } 289 + 128 290 pub fn isPermanentFailure(r: Reply) bool { 129 291 return r.code >= 500 and r.code < 600; 130 292 }