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.

Decline EXPN rather than disown it

EXPN fell through to the unknown-command arm and answered 500, which
claims never to have heard of a command this server can perfectly well
parse. RFC 5321 §4.2.4 permits either 500 or 502 for a command that is
not implemented, and 502 is the one that is true here: the verb was
recognized and the service is not offered. A client can tell the
difference and act on it -- 500 means stop asking, 502 means this server
in this configuration.

VRFY keeps its 252, which is the compliant answer for a server that will
not check an address in advance but will accept the mail, and which
§4.5.1 requires of it -- 500 or 502 there would put this out of
compliance, since VRFY is one of the commands a server must support.

Both now require their argument, which the ABNF makes mandatory: `vrfy =
"VRFY" SP String CRLF`, and EXPN the same shape. Neither has anything to
act on without one, so a bare VRFY is a syntax error rather than a
command with an empty operand.

The RFC 2034 conformance walk covers EXPN now too, so its 502 is checked
for a status code whose class agrees, along with everything else.

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

+73 -5
+6 -3
README.md
··· 354 354 355 355 `run` serves one connection until QUIT or disconnect, enforcing command 356 356 sequencing, recipient and message-size limits, and un-stuffing message data. 357 + The commands it declines it declines precisely: `EXPN` is answered 502, 358 + "known and not implemented", where a verb it has never heard of gets 500; 359 + `VRFY` is answered 252, which is the compliant reply for a server that will 360 + not check an address in advance but will take the mail, and which 361 + [RFC 5321 §4.5.1](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.1) 362 + requires of it. 357 363 Messages may also arrive via BDAT chunks (CHUNKING is advertised); both 358 364 the collecting and streaming handler paths receive the reassembled content. 359 365 MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552 ··· 514 520 ### Protocol 515 521 516 522 - **Client certificates** — neither side can present or verify one. 517 - - `EXPN` is unrecognized rather than unimplemented, so it answers 500 where 518 - [RFC 5321 §4.2.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.4) 519 - wants 502. 520 523 - Niche and absent: REQUIRETLS, MT-PRIORITY, DELIVERBY, FUTURERELEASE, ETRN. 521 524 522 525 ### Server
+45
src/Server.zig
··· 550 550 try s.replyGrouped(250, "2.0.0 Ok"); 551 551 }, 552 552 .noop => try s.reply(250, "2.0.0 Ok"), 553 + // 252 is the compliant answer for a server that will not check 554 + // an address in advance but will take the mail: RFC 5321 §3.5.3. 555 + // 500 or 502 here would put this server out of compliance, since 556 + // §4.5.1 makes VRFY one of the commands it must support. 553 557 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"), 558 + // EXPN is not required and is not implemented, and 502 says 559 + // exactly that. 500 would be the reply of a server that had 560 + // never heard of the command, which would not be true. 561 + .expn => try s.reply(502, "5.5.1 EXPN not implemented"), 554 562 .help => try s.reply(214, "2.0.0 See RFC 5321"), 555 563 .starttls => { 556 564 const config = s.options.tls orelse { ··· 1826 1834 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 1827 1835 } 1828 1836 1837 + test "EXPN is declined rather than disowned" { 1838 + var h: TestHandler = .{}; 1839 + defer h.deinit(); 1840 + 1841 + var out_buf: [2048]u8 = undefined; 1842 + const output = try runScript( 1843 + "EHLO client.example.org\r\n" ++ 1844 + "EXPN staff\r\n" ++ // 502: known, not implemented 1845 + "VRFY somebody@example.net\r\n" ++ // 252: will not check, will take 1846 + "EXPN\r\n" ++ // 501: the argument is not optional 1847 + "VRFY\r\n" ++ // 501: likewise 1848 + "FROB list\r\n" ++ // 500: genuinely never heard of 1849 + "QUIT\r\n", 1850 + &out_buf, 1851 + h.handler(), 1852 + .{}, 1853 + ); 1854 + 1855 + var replies = std.mem.splitSequence(u8, output, "\r\n"); 1856 + var codes: std.ArrayList([]const u8) = .empty; 1857 + defer codes.deinit(std.testing.allocator); 1858 + while (replies.next()) |line| { 1859 + if (line.len >= 4 and line[3] == ' ') try codes.append(std.testing.allocator, line[0..3]); 1860 + } 1861 + // 220 greeting, 250 EHLO, then the verdicts, then 221. 1862 + try std.testing.expectEqualStrings("502", codes.items[2]); 1863 + try std.testing.expectEqualStrings("252", codes.items[3]); 1864 + try std.testing.expectEqualStrings("501", codes.items[4]); 1865 + try std.testing.expectEqualStrings("501", codes.items[5]); 1866 + // The distinction that matters: 500 is "I have never heard of that", 1867 + // 502 is "I know it and will not do it", and only one of them is true 1868 + // of EXPN here. 1869 + try std.testing.expectEqualStrings("500", codes.items[6]); 1870 + try std.testing.expect(std.mem.indexOf(u8, output, "502 5.5.1 EXPN not implemented") != null); 1871 + } 1872 + 1829 1873 test "every reply that should carry an enhanced status code does" { 1830 1874 // RFC 2034 §4: a server implementing the extension prefaces the text of 1831 1875 // every 2xx, 4xx and 5xx reply with a status code whose class agrees -- ··· 1841 1885 "EHLO client.example.org\r\n" ++ 1842 1886 "NOOP\r\n" ++ 1843 1887 "VRFY somebody\r\n" ++ 1888 + "EXPN staff\r\n" ++ // 502 1844 1889 "HELP\r\n" ++ 1845 1890 "WHAT\r\n" ++ // 500 1846 1891 "MAIL FROM:<a@example.com> FROB=1\r\n" ++ // 555
+22 -2
src/protocol.zig
··· 342 342 rset, 343 343 noop, 344 344 quit, 345 + /// VRFY: is this a deliverable address? 346 + /// ([RFC 5321 §4.1.1.6](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.6)) 345 347 vrfy: []const u8, 348 + /// EXPN: what addresses does this mailing list expand to? 349 + /// ([RFC 5321 §4.1.1.7](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.7)) 350 + /// 351 + /// Recognized so that a server can decline it with 502 — "I know this 352 + /// command and do not implement it" — rather than 500, which claims not 353 + /// to have heard of it. RFC 5321 §4.2.4 allows either, and the first is 354 + /// the true one for a server that parsed the verb. 355 + expn: []const u8, 346 356 help, 347 357 starttls, 348 358 /// AUTH ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). ··· 413 423 if (ieql(verb, "RSET")) return .rset; 414 424 if (ieql(verb, "NOOP")) return .noop; 415 425 if (ieql(verb, "QUIT")) return .quit; 416 - if (ieql(verb, "VRFY")) return .{ .vrfy = rest }; 426 + // Both take a mandatory argument: `vrfy = "VRFY" SP String CRLF` 427 + // and the same shape for EXPN. Without one there is nothing to act 428 + // on, so it is a syntax error rather than a command. 429 + if (ieql(verb, "VRFY")) { 430 + if (rest.len == 0) return error.Syntax; 431 + return .{ .vrfy = rest }; 432 + } 433 + if (ieql(verb, "EXPN")) { 434 + if (rest.len == 0) return error.Syntax; 435 + return .{ .expn = rest }; 436 + } 417 437 if (ieql(verb, "HELP")) return .help; 418 438 if (ieql(verb, "STARTTLS")) return .starttls; 419 439 if (ieql(verb, "BDAT")) { ··· 1090 1110 const command = Command.parse(line) catch return; 1091 1111 // Payload slices must always lie within the parsed line. 1092 1112 switch (command) { 1093 - .helo, .ehlo, .lhlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len), 1113 + .helo, .ehlo, .lhlo, .vrfy, .expn, .unknown => |payload| try std.testing.expect(payload.len <= line.len), 1094 1114 .mail, .rcpt => |args| { 1095 1115 try std.testing.expect(args.path.len <= line.len); 1096 1116 try std.testing.expect(args.params.len <= line.len);