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.

Carry the submitter with AUTH= on MAIL FROM

RFC 4954 §5, which is how a relay says who originally submitted the
message it is carrying. `protocol.Submitter` is the parameter: a mailbox,
or the two characters `<>` that mean "I do not know". Both sides handle
it, and the xtext codec DSN already needed is what encodes the mailbox --
the `=` in an address like e=mc2@example.com would otherwise end the
parameter.

The interesting rule is §5's, and it is the opposite of what one would
guess: a server advertising AUTH **must accept the parameter even from a
client that has not authenticated**, and must then behave as though `<>`
had been sent. Taking it and disbelieving it, rather than refusing it, is
what keeps a relay from having to know in advance whether it will be
trusted. So the server records the claim as `.unknown` when the session
is unauthenticated, and a `.mailbox` reaching a handler always means an
authenticated peer asserted it -- `Envelope.authenticated_as` says which
peer, which is the other half of deciding whether to believe it.

A server that advertises no mechanisms at all is in a different position:
it never offered the extension, so the parameter is simply unrecognized
and gets 555.

`<>` is a claim rather than an absence, which is why the client spells it
`.unknown` rather than leaving the parameter off: a server that receives
nothing learns nothing, where one that receives `<>` learns the peer
considered the question.

Verified against exim, which decoded the xtext, believed an authenticated
peer, and kept the value -- the interop test reads it back out of the
delivered message, exim having no log selector that shows it. And against
postfix, which advertises no AUTH on that port, where the client refuses
to send the parameter at all.

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

+338 -8
+20 -8
README.md
··· 103 103 DATA keeps that choice with the caller, and costs one round trip out of the 104 104 *n*+1 saved. 105 105 106 + A relay carrying somebody else's mail names the original submitter with 107 + `AUTH=` ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): 108 + 109 + ```zig 110 + try client.mail(from, .{ .auth = .{ .mailbox = "alice@example.com" } }); 111 + try client.mail(from, .{ .auth = .unknown }); // sends AUTH=<> 112 + ``` 113 + 114 + `<>` is a claim of its own — "I considered the question and cannot vouch for 115 + anybody" — and RFC 4954 asks a relay to send it rather than leave the 116 + parameter off. On the receiving side it arrives as `Envelope.submitter`, and 117 + a server that advertises AUTH must accept the parameter *even from a client 118 + that has not authenticated*, then behave as though `<>` had been sent. So a 119 + `.mailbox` in an envelope always means an authenticated peer asserted it, and 120 + `Envelope.authenticated_as` says which peer, which is what a handler needs to 121 + decide whether to believe it. 122 + 106 123 `mail` and `rcpt` are the parameterized forms of `mailFrom` and `rcptTo`, 107 124 carrying the ESMTP parameters the server advertised — today SMTPUTF8 and the 108 125 DSN set of [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461): ··· 475 492 476 493 ### Protocol 477 494 478 - - **`AUTH=` on MAIL FROM** ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)), 479 - which a trusted relay uses to forward the identity that originally 480 - authenticated. The client-side *mechanisms* are no longer a gap — PLAIN, 481 - LOGIN, CRAM-MD5, EXTERNAL, XOAUTH2, OAUTHBEARER and SCRAM all come from 482 - zig-sasl, and the server offers whichever of their server halves it is 483 - given. 484 495 - **Client certificates** — neither side can present or verify one. 485 496 - **No enhanced status code accessor** — the server emits `x.y.z` on every 486 497 reply, but `Reply` exposes only `code` and the raw text. ··· 573 584 (SMTPS): client (`Tls` before any SMTP traffic) and server 574 585 (`.mode = .implicit`). 575 586 - [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client 576 - and server, including initial responses, empty challenges and `*` 577 - cancellation. The client drives any mechanism from 587 + and server, including initial responses, empty challenges, `*` 588 + cancellation, and §5's `AUTH=` parameter to MAIL FROM — which the server 589 + takes from an unauthenticated client and disregards, as §5 requires. The client drives any mechanism from 578 590 [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl), and the server offers 579 591 whichever of their server halves it is handed — PLAIN 580 592 ([RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616)), the de-facto
+32
nix/interop-test.nix
··· 165 165 delivery_date_add 166 166 envelope_to_add 167 167 return_path_add 168 + # Exim keeps the MAIL AUTH= value in $authenticated_sender but 169 + # does not log it, so the delivered message is where a test can 170 + # see that it arrived and was kept. 171 + headers_add = X-Authenticated-Sender: $authenticated_sender 168 172 169 173 begin authenticators 170 174 ··· 399 403 "journalctl -u zig-smtp-server | grep -F 'to <alice@example.net> (266 bytes)'", 400 404 timeout=60, 401 405 ) 406 + 407 + # RFC 4954 section 5: a relay carrying somebody else's mail names the 408 + # original submitter, and the receiving server takes it only from a peer 409 + # it has authenticated. Exim is the one here that offers AUTH. 410 + with subtest("zig-smtp client to exim, AUTH= on MAIL FROM"): 411 + deliver( 412 + "--allow-cleartext-auth --user alice --password secret" 413 + " --submitter 'e=mc2@example.com'", 414 + 2625, 415 + "zig-smtp to exim auth= parameter", 416 + "/var/spool/exim-mail/alice", 417 + ) 418 + # Exim kept the submitter this client asserted, which means it both 419 + # decoded the xtext and believed an authenticated peer. 420 + machine.wait_until_succeeds( 421 + "grep -r 'X-Authenticated-Sender: e=mc2@example.com'" 422 + " /var/spool/exim-mail/alice", 423 + timeout=60, 424 + ) 425 + 426 + with subtest("zig-smtp refuses AUTH= to a server that does not offer AUTH"): 427 + status, output = machine.execute( 428 + "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 429 + " | zig-smtp send --submitter alice@example.com 127.0.0.1 25" 430 + " bob@example.com alice@localhost 2>&1" 431 + ) 432 + assert status != 0, f"postfix was sent AUTH= anyway: {output}" 433 + assert "does not advertise AUTH" in output, f"unexpected failure: {output}" 402 434 403 435 with subtest("zig-smtp client to postfix, SMTPUTF8"): 404 436 machine.succeed(
+70
src/Client.zig
··· 434 434 /// transaction to BDAT — `data` will refuse to open a DATA phase for 435 435 /// it, as RFC 3030 §3 requires. 436 436 body: ?protocol.Body = null, 437 + /// `AUTH=` 438 + /// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): 439 + /// who originally submitted this message, for a relay carrying it on 440 + /// behalf of somebody else. Needs the server to advertise AUTH. 441 + /// 442 + /// `.unknown` sends `<>`, which is what a relay should send when it 443 + /// cannot vouch for the submitter — RFC 4954 asks for that rather than 444 + /// leaving the parameter off, because a server receiving no parameter 445 + /// learns nothing while one receiving `<>` learns that the peer 446 + /// considered the question. The mailbox form is xtext-encoded, so any 447 + /// bytes are safe to pass, and is rejected with `error.ArgumentTooLong` 448 + /// past the 500 characters RFC 4954 makes room for. 449 + /// 450 + /// Being believed is another matter: the receiving server disregards 451 + /// this unless this client has authenticated to it. 452 + auth: ?protocol.Submitter = null, 437 453 /// DSN `RET=`: how much of the message a failure report should carry 438 454 /// back. Needs `Extensions.dsn`. 439 455 ret: ?protocol.Ret = null, ··· 487 503 if (protocol.xtextEncodedLen(envid) > protocol.max_envid_len) 488 504 return error.ArgumentTooLong; 489 505 } 506 + if (options.auth) |auth| switch (auth) { 507 + .unknown => {}, 508 + .mailbox => |mailbox| { 509 + if (mailbox.len == 0) return error.UnsafeArgument; 510 + if (protocol.xtextEncodedLen(mailbox) > protocol.Submitter.max_len) 511 + return error.ArgumentTooLong; 512 + }, 513 + }; 490 514 } 491 515 492 516 /// Writes MAIL without flushing or reading its reply. 493 517 fn writeMail(c: *Client, from: []const u8, options: MailOptions) Error!void { 494 518 try c.writer.print("MAIL FROM:<{s}>", .{from}); 519 + if (options.auth) |auth| try c.writer.print(" AUTH={f}", .{auth}); 495 520 if (options.body) |body| try c.writer.print(" BODY={f}", .{body}); 496 521 if (options.smtputf8) try c.writer.writeAll(" SMTPUTF8"); 497 522 if (options.ret) |ret| try c.writer.print(" RET={f}", .{ret}); ··· 1342 1367 "RCPT TO:<bob@example.net> NOTIFY=FAILURE,DELAY ORCPT=rfc822;team@example.net\r\n", 1343 1368 writer.buffered(), 1344 1369 ); 1370 + } 1371 + 1372 + test "mail carries AUTH= for a relay speaking for somebody else" { 1373 + var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n250 2.1.0 Ok\r\n"); 1374 + var out_buf: [512]u8 = undefined; 1375 + var writer: Io.Writer = .fixed(&out_buf); 1376 + var reply_buf: [64]u8 = undefined; 1377 + var client: Client = .init(&reader, &writer, &reply_buf); 1378 + 1379 + try client.mail("relay@example.com", .{ 1380 + .auth = .{ .mailbox = "e=mc2@example.com" }, 1381 + }); 1382 + // The '=' is escaped, because an unescaped one would end the parameter. 1383 + try std.testing.expectEqualStrings( 1384 + "MAIL FROM:<relay@example.com> AUTH=e+3Dmc2@example.com\r\n", 1385 + writer.buffered(), 1386 + ); 1387 + 1388 + // `<>` is what a relay sends when it cannot vouch for anybody, and RFC 1389 + // 4954 asks for that rather than leaving the parameter off. 1390 + var second_buf: [256]u8 = undefined; 1391 + var second: Io.Writer = .fixed(&second_buf); 1392 + client.setTransport(&reader, &second, .plaintext); 1393 + try client.mail("relay@example.com", .{ .auth = .unknown }); 1394 + try std.testing.expectEqualStrings( 1395 + "MAIL FROM:<relay@example.com> AUTH=<>\r\n", 1396 + second.buffered(), 1397 + ); 1398 + } 1399 + 1400 + test "an AUTH= mailbox that will not fit is refused before it is sent" { 1401 + var reader: Io.Reader = .fixed(""); 1402 + var out_buf: [1024]u8 = undefined; 1403 + var writer: Io.Writer = .fixed(&out_buf); 1404 + var reply_buf: [64]u8 = undefined; 1405 + var client: Client = .init(&reader, &writer, &reply_buf); 1406 + 1407 + try std.testing.expectError(error.ArgumentTooLong, client.mail("a@b", .{ 1408 + .auth = .{ .mailbox = "x" ** (protocol.Submitter.max_len + 1) }, 1409 + })); 1410 + // An empty mailbox is not `<>`; the caller meant one or the other. 1411 + try std.testing.expectError(error.UnsafeArgument, client.mail("a@b", .{ 1412 + .auth = .{ .mailbox = "" }, 1413 + })); 1414 + try std.testing.expectEqualStrings("", writer.buffered()); 1345 1415 } 1346 1416 1347 1417 test "NOTIFY=NEVER is written on its own" {
+138
src/Server.zig
··· 168 168 /// Value of the MAIL `ENVID=` parameter, xtext-decoded: an identifier 169 169 /// the sender wants quoted back in any DSN for this message. 170 170 envid: ?[]const u8 = null, 171 + /// Value of the MAIL `AUTH=` parameter 172 + /// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): 173 + /// who the client says originally submitted this message, for a relay 174 + /// carrying it on behalf of somebody else. 175 + /// 176 + /// Null when the parameter was absent. `.unknown` when it said `<>` — 177 + /// and also when it named a mailbox that this session has no business 178 + /// asserting, because RFC 4954 requires a server to behave as though 179 + /// `<>` had been sent whenever the client has not authenticated. A 180 + /// `.mailbox` here therefore means an authenticated peer asserted it; 181 + /// whether *that* peer is entitled to is the handler's to judge, and 182 + /// `authenticated_as` says who is doing the asserting. 183 + submitter: ?protocol.Submitter = null, 171 184 /// The identity the client authenticated as, or null if it did not. 172 185 /// 173 186 /// This is what the mechanism reported, which is not always the username ··· 189 202 smtputf8: bool = false, 190 203 ret: ?protocol.Ret = null, 191 204 envid: ?[]const u8 = null, 205 + submitter: ?protocol.Submitter = null, 192 206 193 207 /// The memory all of this points into is the session arena, which the 194 208 /// caller resets alongside. ··· 200 214 return .{ 201 215 .from = t.from.?, 202 216 .authenticated_as = authenticated_as, 217 + .submitter = t.submitter, 203 218 .recipients = t.recipients.items, 204 219 .declared_size = t.declared_size, 205 220 .body = t.body, ··· 344 359 var mail_smtputf8 = false; 345 360 var mail_ret: ?protocol.Ret = null; 346 361 var mail_envid: ?[]const u8 = null; 362 + var mail_submitter: ?protocol.Submitter = null; 347 363 var params_ok = true; 348 364 var params = args.paramIterator(); 349 365 while (params.next()) |param| { ··· 372 388 break; 373 389 } 374 390 mail_smtputf8 = true; 391 + } else if (std.ascii.eqlIgnoreCase(param.keyword, "AUTH")) { 392 + // RFC 4954 §5 is explicit that a server advertising 393 + // AUTH must take this parameter even from a client 394 + // that has not authenticated — and then disregard 395 + // what it says, which is what the check below does. 396 + if (s.options.auth_mechanisms.len == 0) { 397 + try s.reply(555, "5.5.4 Unrecognized parameter"); 398 + params_ok = false; 399 + break; 400 + } 401 + const decoded = arena.alloc(u8, param.value.len) catch 402 + return error.OutOfMemory; 403 + const asserted = protocol.Submitter.parse(decoded, param.value) catch { 404 + try s.reply(501, "5.5.4 Invalid AUTH parameter"); 405 + params_ok = false; 406 + break; 407 + }; 408 + // "MUST behave as if the AUTH=<> parameter was 409 + // supplied" when the client has not authenticated. 410 + // The claim is still recorded as having been made, 411 + // just not as having been believed. 412 + mail_submitter = if (authenticated) asserted else .unknown; 375 413 } else if (std.ascii.eqlIgnoreCase(param.keyword, "RET")) { 376 414 mail_ret = protocol.Ret.parse(param.value) catch { 377 415 try s.reply(501, "5.5.4 Invalid RET parameter"); ··· 415 453 transaction.smtputf8 = mail_smtputf8; 416 454 transaction.ret = mail_ret; 417 455 transaction.envid = mail_envid; 456 + transaction.submitter = mail_submitter; 418 457 try s.replyGrouped(250, "2.1.0 Ok"); 419 458 }, 420 459 .rcpt => |args| { ··· 1163 1202 last_orcpt_type: std.ArrayList(u8) = .empty, 1164 1203 last_orcpt_address: std.ArrayList(u8) = .empty, 1165 1204 ret: ?protocol.Ret = null, 1205 + submitter: ?protocol.Submitter = null, 1206 + submitter_mailbox: std.ArrayList(u8) = .empty, 1166 1207 identity: std.ArrayList(u8) = .empty, 1167 1208 envid: std.ArrayList(u8) = .empty, 1168 1209 /// When set, enables the authenticate callback accepting user "alice" ··· 1175 1216 h.data.deinit(std.testing.allocator); 1176 1217 h.envid.deinit(std.testing.allocator); 1177 1218 h.identity.deinit(std.testing.allocator); 1219 + h.submitter_mailbox.deinit(std.testing.allocator); 1178 1220 h.last_orcpt_type.deinit(std.testing.allocator); 1179 1221 h.last_orcpt_address.deinit(std.testing.allocator); 1180 1222 } ··· 1250 1292 h.body = envelope.body; 1251 1293 h.smtputf8 = envelope.smtputf8; 1252 1294 h.ret = envelope.ret; 1295 + h.submitter = envelope.submitter; 1296 + if (envelope.submitter) |who| switch (who) { 1297 + // Copied: it points into the session arena, which is reset the 1298 + // moment this transaction ends. 1299 + .mailbox => |mailbox| h.submitter_mailbox.appendSlice(gpa, mailbox) catch 1300 + return .{ .reject = .{} }, 1301 + .unknown => {}, 1302 + }; 1253 1303 if (envelope.authenticated_as) |who| 1254 1304 h.identity.appendSlice(gpa, who) catch return .{ .reject = .{} }; 1255 1305 if (envelope.envid) |envid| h.envid.appendSlice(gpa, envid) catch return .{ .reject = .{} }; ··· 1774 1824 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null); 1775 1825 try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null); 1776 1826 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 1827 + } 1828 + 1829 + test "an authenticated client's AUTH= assertion reaches the handler" { 1830 + var h: TestHandler = .{ .password = "secret" }; 1831 + defer h.deinit(); 1832 + var mechanisms: TestMechanisms = .init(&h); 1833 + 1834 + var out_buf: [2048]u8 = undefined; 1835 + _ = try runScript( 1836 + "EHLO client.example.org\r\n" ++ 1837 + "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ 1838 + // xtext: "e=mc2@example.com", the '=' escaped as +3D. 1839 + "MAIL FROM:<relay@example.com> AUTH=e+3Dmc2@example.com\r\n" ++ 1840 + "RCPT TO:<bob@example.net>\r\n" ++ 1841 + "DATA\r\nrelayed\r\n.\r\nQUIT\r\n", 1842 + &out_buf, 1843 + h.handler(), 1844 + .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 1845 + ); 1846 + 1847 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1848 + try std.testing.expectEqualStrings("e=mc2@example.com", h.submitter_mailbox.items); 1849 + // And who did the asserting, which is the other half of judging it. 1850 + try std.testing.expectEqualStrings("alice", h.identity.items); 1851 + } 1852 + 1853 + test "an unauthenticated client's AUTH= is taken and disbelieved" { 1854 + var h: TestHandler = .{ .password = "secret" }; 1855 + defer h.deinit(); 1856 + var mechanisms: TestMechanisms = .init(&h); 1857 + 1858 + var out_buf: [2048]u8 = undefined; 1859 + const output = try runScript( 1860 + "EHLO client.example.org\r\n" ++ 1861 + "MAIL FROM:<relay@example.com> AUTH=alice@example.com\r\n" ++ 1862 + "RCPT TO:<bob@example.net>\r\n" ++ 1863 + "DATA\r\nrelayed\r\n.\r\nQUIT\r\n", 1864 + &out_buf, 1865 + h.handler(), 1866 + .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 1867 + ); 1868 + 1869 + // RFC 4954 §5: a server advertising AUTH must accept the parameter even 1870 + // from a client that has not authenticated -- so this is not a 501 -- 1871 + // and must then behave as though `<>` had been sent. 1872 + try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null); 1873 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1874 + try std.testing.expectEqual(protocol.Submitter.unknown, h.submitter.?); 1875 + try std.testing.expectEqualStrings("", h.submitter_mailbox.items); 1876 + } 1877 + 1878 + test "AUTH= is rejected outright by a server that offers no AUTH at all" { 1879 + var h: TestHandler = .{}; 1880 + defer h.deinit(); 1881 + 1882 + var out_buf: [2048]u8 = undefined; 1883 + const output = try runScript( 1884 + "EHLO client.example.org\r\n" ++ 1885 + "MAIL FROM:<relay@example.com> AUTH=alice@example.com\r\n" ++ 1886 + "QUIT\r\n", 1887 + &out_buf, 1888 + h.handler(), 1889 + .{}, 1890 + ); 1891 + // The obligation to take it belongs to a server that advertises the 1892 + // extension; one that does not is seeing a parameter it never offered. 1893 + try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); 1894 + } 1895 + 1896 + test "AUTH=<> says the peer considered the question and does not know" { 1897 + var h: TestHandler = .{ .password = "secret" }; 1898 + defer h.deinit(); 1899 + var mechanisms: TestMechanisms = .init(&h); 1900 + 1901 + var out_buf: [2048]u8 = undefined; 1902 + const output = try runScript( 1903 + "EHLO client.example.org\r\n" ++ 1904 + "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ 1905 + "MAIL FROM:<relay@example.com> AUTH=<>\r\n" ++ 1906 + "RSET\r\n" ++ 1907 + // `+` must introduce two hex digits; "ZZ" are not. 1908 + "MAIL FROM:<relay@example.com> AUTH=bad+ZZ\r\n" ++ // 501 1909 + "QUIT\r\n", 1910 + &out_buf, 1911 + h.handler(), 1912 + .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 1913 + ); 1914 + try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.4 Invalid AUTH parameter") != null); 1777 1915 } 1778 1916 1779 1917 test "the server can now offer CRAM-MD5, which it never could before" {
+25
src/main.zig
··· 8 8 //! [--auth-method plain|login|cram-md5] 9 9 //! [--ret full|hdrs] [--envid <id>] 10 10 //! [--notify never|success,failure,delay] [--orcpt <address>] 11 + //! [--submitter <mailbox>|<>] 11 12 //! [--lmtp] [--binarymime] <host> <port> <from> <to>... 12 13 //! send a message read from stdin; --tls speaks TLS from the first 13 14 //! byte (port 465 style), --starttls upgrades after EHLO (port 587 ··· 60 61 // chunking plus a declaration of what the chunks hold. 61 62 config.body = .binary_mime; 62 63 config.chunking = true; 64 + } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--submitter")) { 65 + // RFC 4954 §5. "<>" is the two characters that say "I do not 66 + // know", which is a different claim from not asking. 67 + config.submitter = if (std.mem.eql(u8, rest[1], "<>")) 68 + .unknown 69 + else 70 + .{ .mailbox = rest[1] }; 71 + rest = rest[1..]; 63 72 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--ret")) { 64 73 config.ret = smtp.protocol.Ret.parse(rest[1]) catch return usage(); 65 74 rest = rest[1..]; ··· 151 160 smtputf8: bool = false, 152 161 protocol: smtp.Client.Protocol = .smtp, 153 162 body: ?smtp.protocol.Body = null, 163 + submitter: ?smtp.protocol.Submitter = null, 154 164 ret: ?smtp.protocol.Ret = null, 155 165 envid: ?[]const u8 = null, 156 166 notify: ?smtp.protocol.Notify = null, ··· 168 178 \\ [--auth-method plain|login|cram-md5] 169 179 \\ [--ret full|hdrs] [--envid <id>] 170 180 \\ [--notify never|success,failure,delay] [--orcpt <address>] 181 + \\ [--submitter <mailbox>|<>] 171 182 \\ [--lmtp] [--binarymime] <host> <port> <from> <to>... 172 183 \\ (message is read from stdin) 173 184 \\ zig-smtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] ··· 310 321 std.log.err("server does not advertise BINARYMIME", .{}); 311 322 return error.BinaryMimeNotAdvertised; 312 323 } 324 + if (config.submitter != null and extensions.auth.len == 0) { 325 + // RFC 4954 §5 obliges a server to take the parameter only if it 326 + // advertised AUTH; one that did not will answer 555. 327 + std.log.err("server does not advertise AUTH, so it will not take AUTH=", .{}); 328 + return error.AuthNotAdvertised; 329 + } 313 330 const wants_dsn = config.ret != null or config.envid != null or 314 331 config.notify != null or config.orcpt != null; 315 332 if (wants_dsn and !extensions.dsn) { ··· 340 357 ) (smtp.Client.Error || smtp.Client.ArgumentError)!void { 341 358 try client.mail(from, .{ 342 359 .smtputf8 = config.smtputf8, 360 + .auth = config.submitter, 343 361 .body = config.body, 344 362 .ret = config.ret, 345 363 .envid = config.envid, ··· 557 575 } 558 576 if (envelope.ret) |ret| try printer.out.print(" RET={f}", .{ret}); 559 577 if (envelope.envid) |envid| try printer.out.print(" ENVID={s}", .{envid}); 578 + // The decoded mailbox rather than `{f}`, which would print the xtext 579 + // that went over the wire. 580 + if (envelope.submitter) |who| switch (who) { 581 + .unknown => try printer.out.writeAll(" AUTH=<>"), 582 + .mailbox => |mailbox| try printer.out.print(" AUTH={s}", .{mailbox}), 583 + }; 584 + if (envelope.authenticated_as) |who| try printer.out.print(" (authenticated as {s})", .{who}); 560 585 try printer.out.print(" ({d} bytes)\n{s}---\n", .{ data.len, data }); 561 586 try printer.out.flush(); 562 587 }
+53
src/protocol.zig
··· 379 379 } 380 380 }; 381 381 382 + /// The `AUTH` parameter of an extended MAIL command 383 + /// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): 384 + /// who originally submitted this message, carried forward by a relay that 385 + /// authenticated them. 386 + /// 387 + /// It is an assertion, not a proof — the peer is claiming this on its own 388 + /// authority — which is why RFC 4954 requires a server to disregard it and 389 + /// behave as though `<>` had been sent whenever the client is unauthenticated 390 + /// or insufficiently trusted. 391 + pub const Submitter = union(enum) { 392 + /// Sent as `<>`: the two characters that mean "I do not know", which a 393 + /// client should send rather than omitting the parameter when it is 394 + /// relaying something it cannot vouch for. 395 + unknown, 396 + /// The mailbox asserted, xtext-decoded. A bare address with no angle 397 + /// brackets, which is what RFC 5321's `Mailbox` production is. 398 + mailbox: []const u8, 399 + 400 + /// RFC 4954 §5 extends the MAIL command line by 500 characters to make 401 + /// room for this, which is the only ceiling it gives. 402 + pub const max_len = 500; 403 + 404 + pub const ParseError = error{Syntax}; 405 + 406 + /// Parses the parameter value, decoding the mailbox into `buffer`. 407 + pub fn parse(buffer: []u8, value: []const u8) ParseError!Submitter { 408 + if (value.len == 0 or value.len > max_len) return error.Syntax; 409 + const decoded = xtextDecode(buffer, value) catch return error.Syntax; 410 + if (std.mem.eql(u8, decoded, "<>")) return .unknown; 411 + // Anything else must be a mailbox, and an empty one is not. 412 + if (decoded.len == 0) return error.Syntax; 413 + return .{ .mailbox = decoded }; 414 + } 415 + 416 + /// Writes the parameter value as it appears on the wire, xtext-encoding 417 + /// the mailbox. 418 + pub fn format(s: Submitter, writer: *Io.Writer) Io.Writer.Error!void { 419 + switch (s) { 420 + .unknown => try writer.writeAll("<>"), 421 + .mailbox => |mailbox| try writeXtext(writer, mailbox), 422 + } 423 + } 424 + 425 + test parse { 426 + var buffer: [64]u8 = undefined; 427 + try std.testing.expectEqual(Submitter.unknown, try parse(&buffer, "<>")); 428 + const who = try parse(&buffer, "e+3Dmc2@example.com"); 429 + try std.testing.expectEqualStrings("e=mc2@example.com", who.mailbox); 430 + try std.testing.expectError(error.Syntax, parse(&buffer, "")); 431 + try std.testing.expectError(error.Syntax, parse(&buffer, "not xtext!")); 432 + } 433 + }; 434 + 382 435 /// RFC 3461 §4.4 caps the `ENVID` parameter value at 100 characters, which 383 436 /// is a limit on the xtext-encoded form and not on what went into it. 384 437 pub const max_envid_len = 100;