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 SMTPUTF8 extension (RFC 6531)

The server advertises SMTPUTF8 and accepts the valueless SMTPUTF8 MAIL
parameter (a value gets 501). Non-ASCII envelope addresses on MAIL and
RCPT are rejected with 553 5.6.7 (RFC 6533) unless the transaction
requested SMTPUTF8, and must be well-formed UTF-8 even then. The flag
rides the transaction state and reaches handlers via Envelope.smtputf8.

The client gains mailFromUtf8 and the CLI gains send --smtputf8, which
errors cleanly when the server does not advertise the extension.

The torture script and the byte-for-byte gauntlet gain the SMTPUTF8
cases, and the VM interop suite delivers with a UTF-8 sender to real
Postfix (ICU-enabled in nixpkgs); 19 subtests pass. Exim interop is
skipped since nixpkgs exim is built without SUPPORT_I18N.

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

+147 -7
+4 -1
README.md
··· 226 226 status codes: carried in every server reply and advertised via 227 227 ENHANCEDSTATUSCODES; detected by the client. 228 228 - [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) — SMTPUTF8: 229 - detected by the client in EHLO; not implemented by the server. 229 + client (`mailFromUtf8`) and server (advertised; non-ASCII addresses 230 + require the parameter and must be valid UTF-8, rejected with 553 5.6.7 231 + per [RFC 6533](https://datatracker.ietf.org/doc/html/rfc6533) otherwise; 232 + the flag reaches handlers via `Envelope.smtputf8`). 230 233 231 234 TLS itself (TLS 1.3, [RFC 8446](https://datatracker.ietf.org/doc/html/rfc8446)) 232 235 is provided by [ianic/tls.zig](https://github.com/ianic/tls.zig).
+10
nix/interop-test.nix
··· 206 206 with subtest(f"zsmtp client to {name}, CHUNKING"): 207 207 deliver("--chunking", port, f"zsmtp to {name} chunked", mailbox) 208 208 209 + with subtest("zsmtp client to postfix, SMTPUTF8"): 210 + machine.succeed( 211 + "printf 'Subject: interop\\r\\n\\r\\nzsmtp to postfix utf8\\r\\n'" 212 + " | zsmtp send --smtputf8 127.0.0.1 25" 213 + " 'böb@example.com' alice@localhost" 214 + ) 215 + machine.wait_until_succeeds( 216 + "grep -r 'zsmtp to postfix utf8' /var/spool/mail/alice/", timeout=60 217 + ) 218 + 209 219 with subtest("zsmtp client to exim, AUTH PLAIN"): 210 220 deliver( 211 221 "--user alice --password secret --auth-method plain",
+20
src/Client.zig
··· 414 414 } 415 415 }; 416 416 417 + /// Like `mailFrom`, but requests the SMTPUTF8 extension 418 + /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)) so the 419 + /// envelope addresses and message headers may contain UTF-8. Use only when 420 + /// `Extensions.smtputf8` was advertised. 421 + pub fn mailFromUtf8(c: *Client, from: []const u8) Error!void { 422 + try c.send("MAIL FROM:<{s}> SMTPUTF8", .{from}); 423 + _ = try c.expectClass(2); 424 + } 425 + 417 426 /// Sends one BDAT chunk (the CHUNKING extension, 418 427 /// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the 419 428 /// server's reply. Use only when `Extensions.chunking` was advertised. The ··· 932 941 // Raw transmission: the leading dot is not stuffed. 933 942 try client.sendMessageChunked(".raw\r\n"); 934 943 try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered()); 944 + } 945 + 946 + test mailFromUtf8 { 947 + var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 948 + var out_buf: [64]u8 = undefined; 949 + var writer: Io.Writer = .fixed(&out_buf); 950 + var reply_buf: [64]u8 = undefined; 951 + var client: Client = .init(&reader, &writer, &reply_buf); 952 + 953 + try client.mailFromUtf8("böb@example.com"); 954 + try std.testing.expectEqualStrings("MAIL FROM:<böb@example.com> SMTPUTF8\r\n", writer.buffered()); 935 955 }
+93 -3
src/Server.zig
··· 92 92 /// Value of the MAIL BODY= parameter 93 93 /// ([RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152)). 94 94 body: Body = .unspecified, 95 + /// True when the client requested the SMTPUTF8 extension 96 + /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)); the 97 + /// envelope addresses and message headers may then contain UTF-8. 98 + smtputf8: bool = false, 95 99 96 100 pub const Body = enum { unspecified, seven_bit, eight_bit_mime }; 97 101 }; ··· 153 157 var recipients: std.ArrayList([]const u8) = .empty; 154 158 var declared_size: ?u64 = null; 155 159 var body: Envelope.Body = .unspecified; 160 + var smtputf8 = false; 156 161 157 162 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname}); 158 163 try s.writer.flush(); ··· 178 183 recipients = .empty; 179 184 declared_size = null; 180 185 body = .unspecified; 186 + smtputf8 = false; 181 187 _ = arena_state.reset(.retain_capacity); 182 188 try s.reply(250, s.options.hostname); 183 189 }, ··· 187 193 recipients = .empty; 188 194 declared_size = null; 189 195 body = .unspecified; 196 + smtputf8 = false; 190 197 _ = arena_state.reset(.retain_capacity); 191 198 // Every reply carries an enhanced status code (RFC 3463), so 192 199 // the ENHANCEDSTATUSCODES extension (RFC 2034) is advertised. 193 - try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-ENHANCEDSTATUSCODES\r\n", .{s.options.hostname}); 200 + try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n", .{s.options.hostname}); 194 201 if (s.options.tls) |config| { 195 202 if (config.mode == .starttls and !s.secured) 196 203 try s.writer.writeAll("250-STARTTLS\r\n"); ··· 215 222 } 216 223 var mail_declared_size: ?u64 = null; 217 224 var mail_body: Envelope.Body = .unspecified; 225 + var mail_smtputf8 = false; 218 226 var params_ok = true; 219 227 var params = args.paramIterator(); 220 228 while (params.next()) |param| { ··· 240 248 params_ok = false; 241 249 break; 242 250 } 251 + } else if (std.ascii.eqlIgnoreCase(param.keyword, "SMTPUTF8")) { 252 + if (param.value.len != 0) { 253 + try s.reply(501, "5.5.4 SMTPUTF8 takes no value"); 254 + params_ok = false; 255 + break; 256 + } 257 + mail_smtputf8 = true; 243 258 } else { 244 259 try s.reply(555, "5.5.4 Unrecognized parameter"); 245 260 params_ok = false; ··· 247 262 } 248 263 } 249 264 if (!params_ok) continue; 265 + if (!try s.validateAddress(args.path, mail_smtputf8)) continue; 250 266 if (s.handler.vtable.mailFrom) |callback| { 251 267 switch (callback(s.handler.context, args.path)) { 252 268 .accept => {}, ··· 259 275 from = try arena.dupe(u8, args.path); 260 276 declared_size = mail_declared_size; 261 277 body = mail_body; 278 + smtputf8 = mail_smtputf8; 262 279 try s.reply(250, "2.1.0 Ok"); 263 280 }, 264 281 .rcpt => |args| { ··· 270 287 try s.reply(555, "5.5.4 Unrecognized parameter"); 271 288 continue; 272 289 } 290 + if (!try s.validateAddress(args.path, smtputf8)) continue; 273 291 if (recipients.items.len >= s.options.max_recipients) { 274 292 try s.reply(452, "4.5.3 Too many recipients"); 275 293 continue; ··· 296 314 .recipients = recipients.items, 297 315 .declared_size = declared_size, 298 316 .body = body, 317 + .smtputf8 = smtputf8, 299 318 }); 300 319 from = null; 301 320 recipients = .empty; 302 321 declared_size = null; 303 322 body = .unspecified; 323 + smtputf8 = false; 304 324 _ = arena_state.reset(.retain_capacity); 305 325 }, 306 326 .bdat => |args| { ··· 319 339 .recipients = recipients.items, 320 340 .declared_size = declared_size, 321 341 .body = body, 342 + .smtputf8 = smtputf8, 322 343 }, args); 323 344 from = null; 324 345 recipients = .empty; 325 346 declared_size = null; 326 347 body = .unspecified; 348 + smtputf8 = false; 327 349 _ = arena_state.reset(.retain_capacity); 328 350 switch (outcome) { 329 351 .done => {}, ··· 335 357 recipients = .empty; 336 358 declared_size = null; 337 359 body = .unspecified; 360 + smtputf8 = false; 338 361 _ = arena_state.reset(.retain_capacity); 339 362 try s.reply(250, "2.0.0 Ok"); 340 363 }, ··· 364 387 recipients = .empty; 365 388 declared_size = null; 366 389 body = .unspecified; 390 + smtputf8 = false; 367 391 _ = arena_state.reset(.retain_capacity); 368 392 }, 369 393 .quit => { ··· 842 866 } 843 867 }; 844 868 869 + /// Enforces RFC 6531: a non-ASCII envelope address is only allowed when 870 + /// the transaction requested SMTPUTF8, and must be well-formed UTF-8. 871 + /// Replies and returns false on rejection. 872 + fn validateAddress(s: *Server, path: []const u8, smtputf8: bool) error{WriteFailed}!bool { 873 + for (path) |byte| { 874 + if (byte >= 0x80) { 875 + if (!smtputf8) { 876 + try s.reply(553, "5.6.7 Non-ASCII address requires SMTPUTF8"); 877 + return false; 878 + } 879 + if (!std.unicode.utf8ValidateSlice(path)) { 880 + try s.reply(553, "5.6.7 Address is not valid UTF-8"); 881 + return false; 882 + } 883 + return true; 884 + } 885 + } 886 + return true; 887 + } 888 + 845 889 fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 846 890 try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text }); 847 891 try s.writer.flush(); ··· 864 908 reject_recipient: ?[]const u8 = null, 865 909 declared_size: ?u64 = null, 866 910 body: Envelope.Body = .unspecified, 911 + smtputf8: bool = false, 867 912 /// When set, enables the authenticate callback accepting user "alice" 868 913 /// with this password. 869 914 password: ?[]const u8 = null, ··· 914 959 h.messages_accepted += 1; 915 960 h.declared_size = envelope.declared_size; 916 961 h.body = envelope.body; 962 + h.smtputf8 = envelope.smtputf8; 917 963 return .accept; 918 964 } 919 965 }; ··· 955 1001 956 1002 try std.testing.expectEqualStrings( 957 1003 "220 mx.test ESMTP ready\r\n" ++ 958 - "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++ 1004 + "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++ 959 1005 "250 2.1.0 Ok\r\n" ++ 960 1006 "250 2.1.5 Ok\r\n" ++ 961 1007 "250 2.1.5 Ok\r\n" ++ ··· 1405 1451 "mail from:<a@b> FOO=bar\r\n" ++ 1406 1452 "mail from:<a@b> SIZE=nan\r\n" ++ 1407 1453 "starttls\r\n" ++ 1454 + "mail from:<böb@test.ex>\r\n" ++ 1455 + "mail from:<a@b> SMTPUTF8=YES\r\n" ++ 1456 + "mail from:<böb@test.ex> SMTPUTF8\r\n" ++ 1457 + "rset\r\n" ++ 1408 1458 "BDAT 5\r\n" ++ 1409 1459 "abc\r\n" ++ 1410 1460 "mail from:<chunky@test.ex>\r\n" ++ ··· 1426 1476 "503 5.5.1 Send EHLO first\r\n" ++ 1427 1477 "503 5.5.1 Need MAIL command first\r\n" ++ 1428 1478 "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n" ++ 1429 - "250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++ 1479 + "250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++ 1430 1480 "501 5.5.4 Syntax error in parameters\r\n" ++ 1431 1481 "501 5.5.4 Syntax error in parameters\r\n" ++ 1432 1482 "250 2.1.0 Ok\r\n" ++ ··· 1447 1497 "555 5.5.4 Unrecognized parameter\r\n" ++ 1448 1498 "501 5.5.2 Invalid SIZE parameter\r\n" ++ 1449 1499 "502 5.5.1 STARTTLS not supported\r\n" ++ 1500 + "553 5.6.7 Non-ASCII address requires SMTPUTF8\r\n" ++ 1501 + "501 5.5.4 SMTPUTF8 takes no value\r\n" ++ 1502 + "250 2.1.0 Ok\r\n" ++ 1503 + "250 2.0.0 Ok\r\n" ++ 1450 1504 "503 5.5.1 Need RCPT command first\r\n" ++ 1451 1505 "250 2.1.0 Ok\r\n" ++ 1452 1506 "250 2.1.5 Ok\r\n" ++ ··· 1620 1674 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 1621 1675 // The NOOP after the final chunk proves the stream stayed in sync. 1622 1676 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 1677 + } 1678 + 1679 + test "SMTPUTF8 transactions and non-ASCII address enforcement" { 1680 + var h: TestHandler = .{}; 1681 + defer h.deinit(); 1682 + 1683 + var out_buf: [2048]u8 = undefined; 1684 + const output = try runScript( 1685 + "EHLO client.example.org\r\n" ++ 1686 + // Non-ASCII without the parameter: rejected. 1687 + "MAIL FROM:<böb@example.com>\r\n" ++ 1688 + "MAIL FROM:<alice@example.com>\r\n" ++ 1689 + "RCPT TO:<jürgen@example.net>\r\n" ++ 1690 + "RSET\r\n" ++ 1691 + // The parameter takes no value. 1692 + "MAIL FROM:<a@example.com> SMTPUTF8=YES\r\n" ++ 1693 + // Invalid UTF-8 bytes even with the parameter: rejected. 1694 + "MAIL FROM:<b\xff\xfeb@example.com> SMTPUTF8\r\n" ++ 1695 + // Proper internationalized transaction. 1696 + "MAIL FROM:<böb@example.com> SMTPUTF8\r\n" ++ 1697 + "RCPT TO:<jürgen@example.net>\r\n" ++ 1698 + "DATA\r\nSubject: ünïcode\r\n\r\nhello\r\n.\r\n" ++ 1699 + "QUIT\r\n", 1700 + &out_buf, 1701 + h.handler(), 1702 + .{}, 1703 + ); 1704 + 1705 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1706 + try std.testing.expect(h.smtputf8); 1707 + try std.testing.expectEqualStrings("böb@example.com", h.from.items); 1708 + try std.testing.expectEqualStrings("jürgen@example.net;", h.recipients.items); 1709 + try std.testing.expect(std.mem.indexOf(u8, output, "250-SMTPUTF8\r\n") != null); 1710 + try std.testing.expect(std.mem.indexOf(u8, output, "553 5.6.7 Non-ASCII address requires SMTPUTF8") != null); 1711 + try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.4 SMTPUTF8 takes no value") != null); 1712 + try std.testing.expect(std.mem.indexOf(u8, output, "553 5.6.7 Address is not valid UTF-8") != null); 1623 1713 }
+11 -3
src/main.zig
··· 3 3 4 4 //! Demo CLI for the zsmtp library. 5 5 //! 6 - //! zsmtp send [--tls|--starttls] [--insecure] [--chunking] 6 + //! zsmtp send [--tls|--starttls] [--insecure] [--chunking] [--smtputf8] 7 7 //! [--user <u> --password <p>] 8 8 //! [--auth-method plain|login|cram-md5] <host> <port> <from> <to>... 9 9 //! send a message read from stdin; --tls speaks TLS from the first ··· 39 39 config.insecure = true; 40 40 } else if (std.mem.eql(u8, rest[0], "--chunking")) { 41 41 config.chunking = true; 42 + } else if (std.mem.eql(u8, rest[0], "--smtputf8")) { 43 + config.smtputf8 = true; 42 44 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--user")) { 43 45 config.username = rest[1]; 44 46 rest = rest[1..]; ··· 102 104 mode: enum { plain, tls, starttls } = .plain, 103 105 insecure: bool = false, 104 106 chunking: bool = false, 107 + smtputf8: bool = false, 105 108 username: ?[]const u8 = null, 106 109 password: ?[]const u8 = null, 107 110 auth_method: enum { auto, plain, login, cram_md5 } = .auto, ··· 194 197 std.log.err("server does not advertise CHUNKING", .{}); 195 198 return error.ChunkingNotAdvertised; 196 199 } 197 - transact(&client, from, recipients, &stdin.interface, config.chunking) catch |err| { 200 + if (config.smtputf8 and !extensions.smtputf8) { 201 + std.log.err("server does not advertise SMTPUTF8", .{}); 202 + return error.SmtpUtf8NotAdvertised; 203 + } 204 + transact(&client, from, recipients, &stdin.interface, config.chunking, config.smtputf8) catch |err| { 198 205 if (err == error.UnexpectedReply) { 199 206 const reply = client.last_reply.?; 200 207 std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text }); ··· 213 220 recipients: []const []const u8, 214 221 message: *Io.Reader, 215 222 chunking: bool, 223 + smtputf8: bool, 216 224 ) zsmtp.Client.Error!void { 217 - try client.mailFrom(from); 225 + if (smtputf8) try client.mailFromUtf8(from) else try client.mailFrom(from); 218 226 for (recipients) |recipient| try client.rcptTo(recipient); 219 227 if (chunking) { 220 228 // BDAT sends the input verbatim (no line-ending normalization).
+9
test/protocol-torture.script
··· 12 12 ??? 250-PIPELINING 13 13 ??? 250-8BITMIME 14 14 ??? 250-CHUNKING 15 + ??? 250-SMTPUTF8 15 16 ??? 250-ENHANCEDSTATUSCODES 16 17 ??? 250 SIZE 17 18 mail ··· 57 58 ??? 501 58 59 starttls 59 60 ??? 502 61 + mail from:<böb@test.ex> 62 + ??? 553 63 + mail from:<a@b> SMTPUTF8=YES 64 + ??? 501 65 + mail from:<böb@test.ex> SMTPUTF8 66 + ??? 250 67 + rset 68 + ??? 250 60 69 BDAT 5 61 70 abc 62 71 ??? 503