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.

Implement REQUIRETLS, and say why the other four stay out

The five "niche extensions" were one bullet, which hid that they are two
different kinds of thing and that four of them were already answered
correctly.

MT-PRIORITY, DELIVERBY, FUTURERELEASE and ETRN are queue features: one
orders a queue, one bounces from it on a deadline, one holds in it until a
time, one flushes it on demand. This library has no queue, so their wire
syntax without their semantics would advertise something nothing here
could honour. They are already answered right -- 555 for a parameter
never advertised, per RFC 5321 §4.1.1.11, and 500 for a command from an
extension never offered -- and the README now says that rather than
listing them as an oversight. Ignoring them is the one answer that would
be wrong.

REQUIRETLS is different in kind, which is why it is implemented: it is a
security feature, not a queue feature, and the sender's meaning is
"bounce this rather than let it travel in the clear".

Advertising it is a promise, and the code says so out loud. RFC 8689
requires a server offering the keyword to honour the requirement, and a
client that does not see it must quit and try another MX, refusing the
domain outright if none offers it -- so the keyword is load-bearing in a
way most are not. This library can keep no part of that promise itself;
it does not relay. `Options.requiretls` is therefore the caller
undertaking to honour it, defaults false, and is advertised only while
the session employs TLS. A parameter arriving where it was never offered
gets 555 rather than being quietly disregarded: accepting and ignoring it
would turn a sender's refusal to be downgraded into a downgrade.

The client refuses to send it over a session it does not believe is
encrypted. The rest of §4.1's preconditions -- a certificate validated by
a trust chain or DANE, an MX vouched for by DNSSEC or MTA-STS -- are not
visible from a library that is handed a reader and a writer and resolves
nothing, so they are documented as the caller's. The demo CLI knows more
than the library does and refuses --requiretls beside --insecure.

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

+289 -9
+51 -1
README.md
··· 141 141 `Envelope.authenticated_as` says which peer, which is what a handler needs to 142 142 decide whether to believe it. 143 143 144 + A sender that would rather have a message bounce than travel in the clear 145 + says so with REQUIRETLS 146 + ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)): 147 + 148 + ```zig 149 + try client.mail(from, .{ .require_tls = true }); 150 + ``` 151 + 152 + It is refused with `error.InsecureTransport` on a session this client does 153 + not believe is encrypted, because a guarantee about an unprotected channel 154 + guarantees nothing. That is the precondition the library can check; the rest 155 + of §4.1's are the caller's and are not visible from here — the server's 156 + certificate must have been validated by a trust chain or DANE, so not with 157 + `Tls.Options.ca = .insecure`, and the MX must have been vouched for by 158 + DNSSEC or MTA-STS, which nothing here resolves. The demo CLI refuses 159 + `--requiretls` alongside `--insecure` for exactly that reason. 160 + 144 161 `mail` and `rcpt` are the parameterized forms of `mailFrom` and `rcptTo`, 145 162 carrying the ESMTP parameters the server advertised — today SMTPUTF8 and the 146 163 DSN set of [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461): ··· 402 419 refused with 503, and the content reaches the handler exactly as it was 403 420 sent — the BDAT path copies octets and has no line structure to normalize. 404 421 422 + `Options.requiretls` offers REQUIRETLS 423 + ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)), and **setting 424 + it is a promise**. RFC 8689 requires a server advertising the keyword to 425 + honour the requirement, and a client that does not see it must quit and try 426 + another MX — refusing the domain entirely if no host offers it — so the 427 + keyword is load-bearing in a way most are not. This library cannot keep any 428 + part of that promise itself: it does not relay, so honouring the request is 429 + whatever the handler does with `Envelope.require_tls`. It is advertised only 430 + while the session is TLS-protected, and a client sending the parameter to a 431 + session that was not offered it gets 555 rather than being quietly 432 + disregarded — silently accepting it would turn a sender's refusal to be 433 + downgraded into a downgrade. 434 + 405 435 DSN ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)) is 406 436 advertised. `RET=` and `ENVID=` on MAIL arrive as `Envelope.ret` and 407 437 `Envelope.envid`, and `NOTIFY=` and `ORCPT=` on RCPT arrive as ··· 520 550 ### Protocol 521 551 522 552 - **Client certificates** — neither side can present or verify one. 523 - - Niche and absent: REQUIRETLS, MT-PRIORITY, DELIVERBY, FUTURERELEASE, ETRN. 553 + - **MT-PRIORITY** ([RFC 6710](https://datatracker.ietf.org/doc/html/rfc6710)), 554 + **DELIVERBY** ([RFC 2852](https://datatracker.ietf.org/doc/html/rfc2852)), 555 + **FUTURERELEASE** ([RFC 4865](https://datatracker.ietf.org/doc/html/rfc4865)) 556 + and **ETRN** ([RFC 1985](https://datatracker.ietf.org/doc/html/rfc1985)) 557 + are absent on purpose rather than overlooked, and they are all the same 558 + thing: queue features. One orders a queue, one bounces from it on a 559 + deadline, one holds in it until a time, and one flushes it on demand. This 560 + library has no queue — see the first section — so implementing their wire 561 + syntax would advertise a capability nothing here could honour. 562 + 563 + They are already answered correctly. The three parameters are not 564 + advertised, so a client sending one gets 555, which 565 + [RFC 5321 §4.1.1.11](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.11) 566 + defines for a parameter the server cannot implement; ETRN is a command 567 + from an extension never offered, so it gets 500. Neither is ignored, and 568 + ignoring is the one answer that would be wrong. 524 569 525 570 ### Server 526 571 ··· 586 631 dot-stuffing. `BODY=BINARYMIME` is advertised, accepted and delivered bit 587 632 for bit, and DATA is refused with 503 for a message that declared it, 588 633 since binary content cannot be framed by a line holding a single dot. 634 + - [RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689) — REQUIRETLS: 635 + offered by the server when `Options.requiretls` is set and the session is 636 + TLS-protected, and reaching the handler as `Envelope.require_tls`; sent by 637 + the client through `MailOptions.require_tls`, which is refused on a 638 + session that is not encrypted. 589 639 - [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — DSN: 590 640 advertised by the server, which parses and validates `RET=`/`ENVID=` on 591 641 MAIL and `NOTIFY=`/`ORCPT=` on RCPT and hands them to the handler; the
+67 -6
src/Client.zig
··· 114 114 }; 115 115 116 116 pub const ArgumentError = error{ 117 + /// The transport is not encrypted and what was asked for needs it — 118 + /// a mechanism that would put a reusable credential on the wire, or a 119 + /// REQUIRETLS guarantee that would mean nothing without one. 120 + /// 121 + /// Upgrade the session with `starttls`, or for the credential case set 122 + /// `allow_cleartext_auth` if the connection is protected by something 123 + /// this library cannot see. 124 + InsecureTransport, 117 125 /// An argument contained CR, LF or NUL and was not sent. See 118 126 /// `protocol.isSafeArgument` for why those three bytes and no others. 119 127 UnsafeArgument, ··· 137 145 /// and sending binary to it anyway is what RFC 3030 forbids outright. 138 146 binary_mime: bool = false, 139 147 enhanced_status_codes: bool = false, 148 + /// The server offers REQUIRETLS 149 + /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)). Only 150 + /// ever seen on a TLS-protected session, since that is the only kind it 151 + /// may be advertised on. 152 + requiretls: bool = false, 140 153 /// The server accepts the DSN parameters of 141 154 /// [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — `RET` and 142 155 /// `ENVID` on MAIL, `NOTIFY` and `ORCPT` on RCPT. ··· 175 188 ext.enhanced_status_codes = true; 176 189 } else if (ieql(kw, "DSN")) { 177 190 ext.dsn = true; 191 + } else if (ieql(kw, "REQUIRETLS")) { 192 + ext.requiretls = true; 178 193 } else if (ieql(kw, "AUTH")) { 179 194 ext.auth = arg; 180 195 } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) { ··· 262 277 } 263 278 264 279 pub const AuthError = Error || ArgumentError || sasl.Client.Error || error{ 265 - /// The transport is not encrypted and the mechanism would have put a 266 - /// reusable credential on the wire. Upgrade the session with `starttls`, 267 - /// or set `allow_cleartext_auth` if the connection is protected by 268 - /// something this library cannot see. 269 - InsecureTransport, 270 280 /// The server rejected the credentials; see `last_reply`. 271 281 AuthenticationFailed, 272 282 /// The server's challenge was not valid base64, or was longer than the ··· 434 444 /// transaction to BDAT — `data` will refuse to open a DATA phase for 435 445 /// it, as RFC 3030 §3 requires. 436 446 body: ?protocol.Body = null, 447 + /// `REQUIRETLS` 448 + /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)): do not 449 + /// let this message travel onward in the clear — bounce it instead. 450 + /// Needs `Extensions.requiretls`. 451 + /// 452 + /// Refused on a session this client does not believe is encrypted, with 453 + /// `error.InsecureTransport`, because asking for a guarantee over a 454 + /// channel that has none is asking for nothing. That is the part this 455 + /// library can check. The rest of RFC 8689 §4.1's preconditions are the 456 + /// caller's and cannot be checked from here: the server's certificate 457 + /// must have been validated by a trust chain or DANE — so not with 458 + /// `Tls.Options.ca = .insecure` — and the MX itself must have been 459 + /// vouched for by DNSSEC or MTA-STS, which this library does not resolve. 460 + require_tls: bool = false, 437 461 /// `AUTH=` 438 462 /// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): 439 463 /// who originally submitted this message, for a relay carrying it on ··· 497 521 /// written. Split out so that a pipelined group can be validated in full 498 522 /// before any of it goes on the wire. 499 523 fn checkMail(c: *Client, from: []const u8, options: MailOptions) ArgumentError!void { 500 - _ = c; 501 524 if (!protocol.isSafeArgument(from)) return error.UnsafeArgument; 525 + // A guarantee about a channel with no protection is not a guarantee. 526 + if (options.require_tls and c.security != .encrypted) return error.InsecureTransport; 502 527 if (options.envid) |envid| { 503 528 if (protocol.xtextEncodedLen(envid) > protocol.max_envid_len) 504 529 return error.ArgumentTooLong; ··· 516 541 /// Writes MAIL without flushing or reading its reply. 517 542 fn writeMail(c: *Client, from: []const u8, options: MailOptions) Error!void { 518 543 try c.writer.print("MAIL FROM:<{s}>", .{from}); 544 + if (options.require_tls) try c.writer.writeAll(" REQUIRETLS"); 519 545 if (options.auth) |auth| try c.writer.print(" AUTH={f}", .{auth}); 520 546 if (options.body) |body| try c.writer.print(" BODY={f}", .{body}); 521 547 if (options.smtputf8) try c.writer.writeAll(" SMTPUTF8"); ··· 1367 1393 "RCPT TO:<bob@example.net> NOTIFY=FAILURE,DELAY ORCPT=rfc822;team@example.net\r\n", 1368 1394 writer.buffered(), 1369 1395 ); 1396 + } 1397 + 1398 + test "REQUIRETLS is refused on a session with nothing to guarantee" { 1399 + var reader: Io.Reader = .fixed(""); 1400 + var out_buf: [256]u8 = undefined; 1401 + var writer: Io.Writer = .fixed(&out_buf); 1402 + var reply_buf: [64]u8 = undefined; 1403 + var client: Client = .init(&reader, &writer, &reply_buf); 1404 + 1405 + // Asking for a guarantee over a channel that has none is asking for 1406 + // nothing, so it is refused here rather than sent and relied upon. 1407 + try std.testing.expectError( 1408 + error.InsecureTransport, 1409 + client.mail("a@example.com", .{ .require_tls = true }), 1410 + ); 1411 + try std.testing.expectEqualStrings("", writer.buffered()); 1412 + } 1413 + 1414 + test "REQUIRETLS goes out once the session is encrypted" { 1415 + var reader: Io.Reader = .fixed("250-mx.example.com\r\n250 REQUIRETLS\r\n250 2.1.0 Ok\r\n"); 1416 + var out_buf: [512]u8 = undefined; 1417 + var writer: Io.Writer = .fixed(&out_buf); 1418 + var reply_buf: [256]u8 = undefined; 1419 + var client: Client = .init(&reader, &writer, &reply_buf); 1420 + client.security = .encrypted; 1421 + 1422 + const extensions = try client.hello("client.example.org"); 1423 + try std.testing.expect(extensions.requiretls); 1424 + 1425 + try client.mail("a@example.com", .{ .require_tls = true }); 1426 + try std.testing.expect(std.mem.endsWith( 1427 + u8, 1428 + writer.buffered(), 1429 + "MAIL FROM:<a@example.com> REQUIRETLS\r\n", 1430 + )); 1370 1431 } 1371 1432 1372 1433 test "mail carries AUTH= for a relay speaking for somebody else" {
+146
src/Server.zig
··· 62 62 /// challenges. `Server.init` is called per connection anyway, so building 63 63 /// them alongside it is the natural place. 64 64 auth_mechanisms: []const sasl.Server = &.{}, 65 + /// Offer REQUIRETLS 66 + /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)), which a 67 + /// sender uses to say "bounce this rather than let it travel in the 68 + /// clear". 69 + /// 70 + /// **Setting this is a promise.** RFC 8689 §4 requires a server that 71 + /// advertises the keyword to honour the requirement, and a client that 72 + /// sees it advertised will rely on it — one that does *not* see it must 73 + /// quit and try another MX, and refuse the domain outright if no host 74 + /// offers it. This library can keep no part of that promise on its own: 75 + /// it does not relay, so honouring the request is whatever the handler 76 + /// does with `Envelope.require_tls`. Leave this false unless the handler 77 + /// will act on it. 78 + /// 79 + /// Advertised only while the session is TLS-protected. RFC 8689 states 80 + /// the obligation in terms of STARTTLS and separately requires that the 81 + /// session employ TLS; an implicit-TLS session satisfies the latter, so 82 + /// both kinds advertise here. 83 + requiretls: bool = false, 65 84 /// Scratch for the AUTH exchange, needed only when `auth_mechanisms` is 66 85 /// not empty. 67 86 /// ··· 181 200 /// whether *that* peer is entitled to is the handler's to judge, and 182 201 /// `authenticated_as` says who is doing the asserting. 183 202 submitter: ?protocol.Submitter = null, 203 + /// The client asked for REQUIRETLS 204 + /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)): this 205 + /// message must not travel onward over anything but a TLS-protected 206 + /// connection with a validated certificate, and must bounce rather than 207 + /// be downgraded. 208 + /// 209 + /// Only ever true when `Options.requiretls` was set, which is the 210 + /// caller's undertaking to honour it. Honouring it is the handler's: 211 + /// this library does not relay, so nothing here can. A relay that cannot 212 + /// meet the requirement should report 5.7.30, "REQUIRETLS support 213 + /// required", which RFC 8689 defines for exactly that. 214 + require_tls: bool = false, 184 215 /// The identity the client authenticated as, or null if it did not. 185 216 /// 186 217 /// This is what the mechanism reported, which is not always the username ··· 203 234 ret: ?protocol.Ret = null, 204 235 envid: ?[]const u8 = null, 205 236 submitter: ?protocol.Submitter = null, 237 + require_tls: bool = false, 206 238 207 239 /// The memory all of this points into is the session arena, which the 208 240 /// caller resets alongside. ··· 215 247 .from = t.from.?, 216 248 .authenticated_as = authenticated_as, 217 249 .submitter = t.submitter, 250 + .require_tls = t.require_tls, 218 251 .recipients = t.recipients.items, 219 252 .declared_size = t.declared_size, 220 253 .body = t.body, ··· 360 393 var mail_ret: ?protocol.Ret = null; 361 394 var mail_envid: ?[]const u8 = null; 362 395 var mail_submitter: ?protocol.Submitter = null; 396 + var mail_require_tls = false; 363 397 var params_ok = true; 364 398 var params = args.paramIterator(); 365 399 while (params.next()) |param| { ··· 388 422 break; 389 423 } 390 424 mail_smtputf8 = true; 425 + } else if (std.ascii.eqlIgnoreCase(param.keyword, "REQUIRETLS")) { 426 + // Only recognized when it was offered, which needs 427 + // both the option and a TLS session; otherwise it is 428 + // a parameter this server never advertised, and 555 429 + // is what RFC 5321 §4.1.1.11 gives for one of those. 430 + if (!s.options.requiretls or !s.secured) { 431 + try s.reply(555, "5.5.4 Unrecognized parameter"); 432 + params_ok = false; 433 + break; 434 + } 435 + if (param.value.len != 0) { 436 + try s.reply(501, "5.5.4 REQUIRETLS takes no value"); 437 + params_ok = false; 438 + break; 439 + } 440 + mail_require_tls = true; 391 441 } else if (std.ascii.eqlIgnoreCase(param.keyword, "AUTH")) { 392 442 // RFC 4954 §5 is explicit that a server advertising 393 443 // AUTH must take this parameter even from a client ··· 454 504 transaction.ret = mail_ret; 455 505 transaction.envid = mail_envid; 456 506 transaction.submitter = mail_submitter; 507 + transaction.require_tls = mail_require_tls; 457 508 try s.replyGrouped(250, "2.1.0 Ok"); 458 509 }, 459 510 .rcpt => |args| { ··· 635 686 if (config.mode == .starttls and !s.secured) 636 687 try s.writer.writeAll("250-STARTTLS\r\n"); 637 688 } 689 + // RFC 8689 §4: advertised only on a session that employs TLS, because 690 + // the guarantee is meaningless without one. 691 + if (s.options.requiretls and s.secured) try s.writer.writeAll("250-REQUIRETLS\r\n"); 638 692 if (s.options.auth_mechanisms.len != 0 and !authenticated) { 639 693 try s.writer.writeAll("250-AUTH"); 640 694 for (s.options.auth_mechanisms) |mechanism| ··· 1210 1264 last_orcpt_type: std.ArrayList(u8) = .empty, 1211 1265 last_orcpt_address: std.ArrayList(u8) = .empty, 1212 1266 ret: ?protocol.Ret = null, 1267 + require_tls: bool = false, 1213 1268 submitter: ?protocol.Submitter = null, 1214 1269 submitter_mailbox: std.ArrayList(u8) = .empty, 1215 1270 identity: std.ArrayList(u8) = .empty, ··· 1301 1356 h.smtputf8 = envelope.smtputf8; 1302 1357 h.ret = envelope.ret; 1303 1358 h.submitter = envelope.submitter; 1359 + h.require_tls = envelope.require_tls; 1304 1360 if (envelope.submitter) |who| switch (who) { 1305 1361 // Copied: it points into the session arena, which is reset the 1306 1362 // moment this transaction ends. ··· 1832 1888 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null); 1833 1889 try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null); 1834 1890 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 1891 + } 1892 + 1893 + test "REQUIRETLS is offered and honoured on a TLS session" { 1894 + var h: TestHandler = .{}; 1895 + defer h.deinit(); 1896 + 1897 + var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ 1898 + "MAIL FROM:<a@example.com> REQUIRETLS\r\n" ++ 1899 + "RCPT TO:<b@example.net>\r\n" ++ 1900 + "DATA\r\nsecret\r\n.\r\n" ++ 1901 + "MAIL FROM:<a@example.com> REQUIRETLS=yes\r\n"); // 501: it takes no value 1902 + // No QUIT: `secured` here is a stand-in for a handshake that never 1903 + // happened, and QUIT would close a TLS connection that was never opened. 1904 + var out_buf: [4096]u8 = undefined; 1905 + var writer: Io.Writer = .fixed(&out_buf); 1906 + var session: Server = .init(&reader, &writer, h.handler(), .{ .requiretls = true }); 1907 + // Stand in for a completed handshake: what the advertising rule turns on 1908 + // is that the session employs TLS, not how it came to. 1909 + session.secured = true; 1910 + try session.run(std.testing.allocator); 1911 + const output = writer.buffered(); 1912 + 1913 + try std.testing.expect(std.mem.indexOf(u8, output, "250-REQUIRETLS\r\n") != null); 1914 + try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.4 REQUIRETLS takes no value") != null); 1915 + // And the sender's requirement reached the handler, which is the only 1916 + // place it can be acted on: this library does not relay. 1917 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1918 + try std.testing.expect(h.require_tls); 1919 + } 1920 + 1921 + test "the requirement does not leak into the next transaction" { 1922 + var h: TestHandler = .{}; 1923 + defer h.deinit(); 1924 + 1925 + var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ 1926 + "MAIL FROM:<a@example.com> REQUIRETLS\r\n" ++ 1927 + "RSET\r\n" ++ 1928 + "MAIL FROM:<a@example.com>\r\n" ++ 1929 + "RCPT TO:<b@example.net>\r\n" ++ 1930 + "DATA\r\nordinary\r\n.\r\n"); // no QUIT; see the test above 1931 + var out_buf: [4096]u8 = undefined; 1932 + var writer: Io.Writer = .fixed(&out_buf); 1933 + var session: Server = .init(&reader, &writer, h.handler(), .{ .requiretls = true }); 1934 + session.secured = true; 1935 + try session.run(std.testing.allocator); 1936 + 1937 + // A requirement asserted for one message says nothing about the next, 1938 + // and carrying it over would be a promise nobody made. 1939 + try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1940 + try std.testing.expect(!h.require_tls); 1941 + } 1942 + 1943 + test "REQUIRETLS is not offered without TLS, and not taken without being offered" { 1944 + var h: TestHandler = .{}; 1945 + defer h.deinit(); 1946 + 1947 + var out_buf: [2048]u8 = undefined; 1948 + const output = try runScript( 1949 + "EHLO client.example.org\r\n" ++ 1950 + "MAIL FROM:<a@example.com> REQUIRETLS\r\n" ++ 1951 + "QUIT\r\n", 1952 + &out_buf, 1953 + h.handler(), 1954 + // The option is on; the session is not TLS. 1955 + .{ .requiretls = true }, 1956 + ); 1957 + 1958 + // RFC 8689 §4 ties the keyword to a session that employs TLS, so this 1959 + // one must not offer it... 1960 + try std.testing.expect(std.mem.indexOf(u8, output, "250-REQUIRETLS\r\n") == null); 1961 + // ...and a parameter that was never advertised is 555, per RFC 5321 1962 + // §4.1.1.11, rather than being taken and quietly not honoured. Silently 1963 + // accepting it would turn a sender's refusal to be downgraded into a 1964 + // downgrade. 1965 + try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); 1966 + try std.testing.expect(!h.require_tls); 1967 + } 1968 + 1969 + test "a server that does not promise REQUIRETLS does not advertise it" { 1970 + var h: TestHandler = .{}; 1971 + defer h.deinit(); 1972 + 1973 + var out_buf: [2048]u8 = undefined; 1974 + const output = try runScript( 1975 + "EHLO client.example.org\r\nQUIT\r\n", 1976 + &out_buf, 1977 + h.handler(), 1978 + .{}, // requiretls defaults false: the promise is opt-in 1979 + ); 1980 + try std.testing.expect(std.mem.indexOf(u8, output, "REQUIRETLS") == null); 1835 1981 } 1836 1982 1837 1983 test "EXPN is declined rather than disowned" {
+25 -2
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 + //! [--submitter <mailbox>|<>] [--requiretls] 12 12 //! [--lmtp] [--binarymime] <host> <port> <from> <to>... 13 13 //! send a message read from stdin; --tls speaks TLS from the first 14 14 //! byte (port 465 style), --starttls upgrades after EHLO (port 587 ··· 56 56 config.smtputf8 = true; 57 57 } else if (std.mem.eql(u8, rest[0], "--lmtp")) { 58 58 config.protocol = .lmtp; 59 + } else if (std.mem.eql(u8, rest[0], "--requiretls")) { 60 + config.require_tls = true; 59 61 } else if (std.mem.eql(u8, rest[0], "--binarymime")) { 60 62 // Binary content can only be framed by BDAT, so this is 61 63 // chunking plus a declaration of what the chunks hold. ··· 101 103 rest = rest[1..]; 102 104 } 103 105 if ((config.username == null) != (config.password == null)) return usage(); 106 + // RFC 8689 §4.1 wants a validated certificate before a client may 107 + // ask for this, and --insecure is the caller saying it skipped that. 108 + // The library cannot see the difference; here it can. 109 + if (config.require_tls and config.insecure) { 110 + std.log.err("--requiretls needs a validated certificate, so not with --insecure", .{}); 111 + return usage(); 112 + } 113 + if (config.require_tls and config.mode == .plain) { 114 + std.log.err("--requiretls needs TLS: use --tls or --starttls", .{}); 115 + return usage(); 116 + } 104 117 if (rest.len < 4) return usage(); 105 118 return send(io, arena, config, rest[0], rest[1], rest[2], rest[3..]); 106 119 } ··· 161 174 protocol: smtp.Client.Protocol = .smtp, 162 175 body: ?smtp.protocol.Body = null, 163 176 submitter: ?smtp.protocol.Submitter = null, 177 + require_tls: bool = false, 164 178 ret: ?smtp.protocol.Ret = null, 165 179 envid: ?[]const u8 = null, 166 180 notify: ?smtp.protocol.Notify = null, ··· 178 192 \\ [--auth-method plain|login|cram-md5] 179 193 \\ [--ret full|hdrs] [--envid <id>] 180 194 \\ [--notify never|success,failure,delay] [--orcpt <address>] 181 - \\ [--submitter <mailbox>|<>] 195 + \\ [--submitter <mailbox>|<>] [--requiretls] 182 196 \\ [--lmtp] [--binarymime] <host> <port> <from> <to>... 183 197 \\ (message is read from stdin) 184 198 \\ zig-smtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] ··· 331 345 std.log.err("server does not advertise BINARYMIME", .{}); 332 346 return error.BinaryMimeNotAdvertised; 333 347 } 348 + if (config.require_tls and !extensions.requiretls) { 349 + // RFC 8689 §4.1: a client that does not see the keyword must not 350 + // send the parameter, and a real sender must try another MX rather 351 + // than deliver without the guarantee. 352 + std.log.err("server does not advertise REQUIRETLS", .{}); 353 + return error.RequireTlsNotAdvertised; 354 + } 334 355 if (config.submitter != null and extensions.auth.len == 0) { 335 356 // RFC 4954 §5 obliges a server to take the parameter only if it 336 357 // advertised AUTH; one that did not will answer 555. ··· 376 397 try client.mail(from, .{ 377 398 .smtputf8 = config.smtputf8, 378 399 .auth = config.submitter, 400 + .require_tls = config.require_tls, 379 401 .body = config.body, 380 402 .ret = config.ret, 381 403 .envid = config.envid, ··· 595 617 if (envelope.envid) |envid| try printer.out.print(" ENVID={s}", .{envid}); 596 618 // The decoded mailbox rather than `{f}`, which would print the xtext 597 619 // that went over the wire. 620 + if (envelope.require_tls) try printer.out.writeAll(" REQUIRETLS"); 598 621 if (envelope.submitter) |who| switch (who) { 599 622 .unknown => try printer.out.writeAll(" AUTH=<>"), 600 623 .mailbox => |mailbox| try printer.out.print(" AUTH={s}", .{mailbox}),