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.

zig-smtp / src / Server.zig
120 kB 2872 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! A single-connection SMTP server session. Like the client, it runs over 5//! any `Io.Reader`/`Io.Writer` pair; accept a TCP connection and hand its 6//! stream reader/writer to `run`. Accepting connections, concurrency, and 7//! message storage are left to the caller — the session just speaks the 8//! protocol and forwards decisions to a `Handler`. 9//! 10//! Typical use: 11//! ``` 12//! var session: Server = .init(&stream_reader, &stream_writer, handler, .{ 13//! .hostname = "mx.example.com", 14//! }); 15//! try session.run(gpa); 16//! ``` 17 18const Server = @This(); 19 20const std = @import("std"); 21const Io = std.Io; 22const tls = @import("tls"); 23const protocol = @import("protocol.zig"); 24const sasl = @import("sasl"); 25 26reader: *Io.Reader, 27writer: *Io.Writer, 28handler: Handler, 29options: Options, 30/// True once a STARTTLS handshake has completed for this session. 31secured: bool = false, 32/// The identity the client authenticated as, kept for the life of the 33/// session and reported on every `Envelope`. 34identity_buf: [255]u8 = undefined, 35identity_len: usize = 0, 36tls_connection: tls.Connection = undefined, 37tls_reader: tls.Connection.Reader = undefined, 38tls_writer: tls.Connection.Writer = undefined, 39tls_read_buffer: [4096]u8 = undefined, 40tls_write_buffer: [4096]u8 = undefined, 41 42pub const Options = struct { 43 /// Which protocol the session speaks. See `Protocol`. 44 protocol: Protocol = .smtp, 45 /// Hostname announced in the greeting and the EHLO response. 46 hostname: []const u8 = "localhost", 47 /// Advertised via the SIZE extension and enforced during DATA. 48 max_message_size: usize = 16 * 1024 * 1024, 49 max_recipients: usize = 100, 50 /// When set, the session speaks TLS (see `TlsOptions.mode`). The 51 /// underlying stream reader/writer handed to `init` must then have 52 /// buffers of at least `tls.input_buffer_len` and 53 /// `tls.output_buffer_len` bytes, since the handshake and TLS records 54 /// run over them. 55 tls: ?TlsOptions = null, 56 /// The SASL mechanisms this session offers, from 57 /// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl) — `sasl.PlainServer` 58 /// and the rest. Advertised by name in the EHLO response, in this order. 59 /// 60 /// **They hold per-exchange state, so each session needs its own.** A set 61 /// shared between two connections would have them overwrite each other's 62 /// challenges. `Server.init` is called per connection anyway, so building 63 /// them alongside it is the natural place. 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, 84 /// Scratch for the AUTH exchange, needed only when `auth_mechanisms` is 85 /// not empty. 86 /// 87 /// It is the caller's for the same reason the stream buffers are: how 88 /// much room a mechanism needs is the caller's to know, and the 89 /// difference is large — the classic mechanisms want a few hundred 90 /// bytes, an OAuth token several kilobytes. `sasl_buffer_suggested` fits 91 /// everything short of an unusually fat token, and `sasl_buffer_min` is 92 /// the floor. 93 /// 94 /// Split four-to-three between base64 and plaintext, which is the ratio 95 /// base64 expands by, so the usable message is about three sevenths of 96 /// what is given. 97 sasl_buffer: []u8 = &.{}, 98 /// Reject MAIL with 530 until the client has authenticated. Requires at 99 /// least one entry in `auth_mechanisms`. 100 require_auth: bool = false, 101}; 102 103/// SMTP, or its local-delivery sibling LMTP 104/// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)). 105pub const Protocol = enum { 106 smtp, 107 /// LMTP differs from SMTP in two ways that matter here: the greeting is 108 /// `LHLO` and `HELO`/`EHLO` are refused, and the end of a message is 109 /// answered with one reply per accepted recipient instead of one for 110 /// the message. It exists so that a delivery agent can report a 111 /// different outcome for each mailbox, which SMTP gives no way to say. 112 /// 113 /// RFC 2033 §5 forbids running it on TCP port 25 and advises against 114 /// wide-area use at all: it is for the hop between a queueing MTA and 115 /// the thing that writes to mailboxes. 116 lmtp, 117}; 118 119pub const TlsOptions = struct { 120 io: Io, 121 /// Server certificate chain and private key presented to clients. 122 auth: *tls.config.CertKeyPair, 123 mode: Mode = .starttls, 124 125 pub const Mode = enum { 126 /// Advertise and accept the STARTTLS command 127 /// ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)). 128 starttls, 129 /// Perform the TLS handshake before the greeting (implicit TLS / 130 /// SMTPS, port 465 style; [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314)). 131 implicit, 132 }; 133}; 134 135/// A handler's verdict on an envelope step or a complete message. 136pub const Decision = union(enum) { 137 accept, 138 reject: Rejection, 139 140 pub const Rejection = struct { 141 /// Use 4xx for "try again later", 5xx for permanent rejection. 142 code: u16 = 550, 143 /// By convention prefixed with an enhanced status code 144 /// ([RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463)). 145 text: []const u8 = "5.7.1 Rejected", 146 }; 147}; 148 149/// One accepted recipient, with whatever the client attached to it. 150pub const Recipient = struct { 151 /// The forward-path from RCPT TO. 152 address: []const u8, 153 /// Value of the RCPT `NOTIFY=` parameter 154 /// ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)), if the 155 /// client sent one. Absent means the client did not say, which RFC 3461 156 /// lets a reporting MTA read as either `FAILURE` or `FAILURE,DELAY`. 157 notify: ?protocol.Notify = null, 158 /// Value of the RCPT `ORCPT=` parameter, xtext-decoded: the address the 159 /// message was originally addressed to, before whatever aliasing led 160 /// here. 161 orcpt: ?protocol.Orcpt = null, 162}; 163 164pub const Envelope = struct { 165 /// Empty for the null reverse-path (`MAIL FROM:<>`). 166 from: []const u8, 167 recipients: []const Recipient, 168 /// Value of the MAIL SIZE= parameter 169 /// ([RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870)), if the client 170 /// declared one. Already validated against `Options.max_message_size`. 171 declared_size: ?u64 = null, 172 /// Value of the MAIL `BODY=` parameter, if the client declared one. 173 /// `.binary_mime` ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) 174 /// means the content is arbitrary octets and arrived by BDAT, so the 175 /// handler must keep every bit of it: there is no line structure to 176 /// normalize and nothing was unstuffed. 177 body: ?protocol.Body = null, 178 /// True when the client requested the SMTPUTF8 extension 179 /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)); the 180 /// envelope addresses and message headers may then contain UTF-8. 181 smtputf8: bool = false, 182 /// Value of the MAIL `RET=` parameter 183 /// ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)): how much 184 /// of the message the sender wants carried back in a failure DSN. 185 /// Absent leaves the choice to whoever reports. 186 ret: ?protocol.Ret = null, 187 /// Value of the MAIL `ENVID=` parameter, xtext-decoded: an identifier 188 /// the sender wants quoted back in any DSN for this message. 189 envid: ?[]const u8 = null, 190 /// Value of the MAIL `AUTH=` parameter 191 /// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): 192 /// who the client says originally submitted this message, for a relay 193 /// carrying it on behalf of somebody else. 194 /// 195 /// Null when the parameter was absent. `.unknown` when it said `<>` — 196 /// and also when it named a mailbox that this session has no business 197 /// asserting, because RFC 4954 requires a server to behave as though 198 /// `<>` had been sent whenever the client has not authenticated. A 199 /// `.mailbox` here therefore means an authenticated peer asserted it; 200 /// whether *that* peer is entitled to is the handler's to judge, and 201 /// `authenticated_as` says who is doing the asserting. 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, 215 /// The identity the client authenticated as, or null if it did not. 216 /// 217 /// This is what the mechanism reported, which is not always the username 218 /// the client typed: PLAIN carries an authorization identity as well, so 219 /// a mechanism that honours one reports the identity being acted as. A 220 /// handler deciding whether to relay wants this rather than the envelope 221 /// sender, which anybody can write. 222 authenticated_as: ?[]const u8 = null, 223}; 224 225/// What a mail transaction accumulates between MAIL and the end of the 226/// message. Kept together so that resetting it cannot forget a field — 227/// RSET, a completed message and a new (L)HLO all discard the lot. 228const Transaction = struct { 229 from: ?[]const u8 = null, 230 recipients: std.ArrayList(Recipient) = .empty, 231 declared_size: ?u64 = null, 232 body: ?protocol.Body = null, 233 smtputf8: bool = false, 234 ret: ?protocol.Ret = null, 235 envid: ?[]const u8 = null, 236 submitter: ?protocol.Submitter = null, 237 require_tls: bool = false, 238 239 /// The memory all of this points into is the session arena, which the 240 /// caller resets alongside. 241 fn clear(t: *Transaction) void { 242 t.* = .{}; 243 } 244 245 fn envelope(t: Transaction, authenticated_as: ?[]const u8) Envelope { 246 return .{ 247 .from = t.from.?, 248 .authenticated_as = authenticated_as, 249 .submitter = t.submitter, 250 .require_tls = t.require_tls, 251 .recipients = t.recipients.items, 252 .declared_size = t.declared_size, 253 .body = t.body, 254 .smtputf8 = t.smtputf8, 255 .ret = t.ret, 256 .envid = t.envid, 257 }; 258 } 259}; 260 261/// Callbacks invoked during a session. All slices passed to callbacks are 262/// only valid for the duration of the call. 263pub const Handler = struct { 264 context: ?*anyopaque = null, 265 vtable: *const VTable, 266 267 pub const VTable = struct { 268 /// Called for MAIL FROM. Null accepts every sender. 269 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null, 270 /// Called for each RCPT TO, with the address and any DSN 271 /// parameters that came with it. Null accepts every recipient. 272 rcptTo: ?*const fn (context: ?*anyopaque, recipient: Recipient) Decision = null, 273 /// Called once the complete message has been received. The data has 274 /// CRLF line endings and dot-stuffing already removed. Exactly one 275 /// of `message` and `messageReader` must be set. 276 message: ?*const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision = null, 277 /// LMTP only: the verdict for one recipient of the message just 278 /// received, `envelope.recipients[index]`, called once per accepted 279 /// recipient after `message` or `messageReader` has returned 280 /// `.accept`. This is what LMTP exists for — one mailbox can be 281 /// full while another is fine — so a `.lmtp` session without it 282 /// answers every recipient identically and gains nothing over SMTP. 283 /// 284 /// Not called when the message itself was rejected: that verdict 285 /// applies to every recipient and is sent for each of them. 286 recipientResult: ?*const fn (context: ?*anyopaque, envelope: Envelope, index: usize) Decision = null, 287 /// Streaming alternative to `message`: called after DATA with a 288 /// reader that yields the message content (dot-stuffing removed, 289 /// line endings normalized to CRLF) until end of stream. Anything 290 /// the callback leaves unread is drained by the session, so 291 /// returning early is fine. `Options.max_message_size` is not 292 /// enforced in this mode; individual message lines must fit the 293 /// session's stream reader buffer. 294 messageReader: ?*const fn (context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision = null, 295 }; 296}; 297 298pub fn init(reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options) Server { 299 return .{ .reader = reader, .writer = writer, .handler = handler, .options = options }; 300} 301 302pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory, TlsHandshakeFailed }; 303 304/// Serves the session until the client sends QUIT or disconnects. `gpa` 305/// backs per-transaction storage (envelope and message data); everything is 306/// freed on return. 307pub fn run(s: *Server, gpa: std.mem.Allocator) RunError!void { 308 var arena_state: std.heap.ArenaAllocator = .init(gpa); 309 defer arena_state.deinit(); 310 const arena = arena_state.allocator(); 311 312 std.debug.assert(!s.options.require_auth or s.options.auth_mechanisms.len != 0); 313 // A session that offers mechanisms and no room to run them would answer 314 // every AUTH with a temporary failure, which is worth catching here. 315 std.debug.assert(s.options.auth_mechanisms.len == 0 or 316 s.options.sasl_buffer.len >= sasl_buffer_min); 317 std.debug.assert((s.handler.vtable.message == null) != (s.handler.vtable.messageReader == null)); 318 319 if (s.options.tls) |config| { 320 if (config.mode == .implicit and !s.secured) try s.upgradeToTls(config); 321 } 322 323 var greeted = false; 324 var authenticated = false; 325 var transaction: Transaction = .{}; 326 327 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname}); 328 try s.writer.flush(); 329 330 while (true) { 331 const line = protocol.readLine(s.reader) catch |err| switch (err) { 332 error.EndOfStream => return, // Client disconnected. 333 error.ReadFailed => return error.ReadFailed, 334 error.LineTooLong => { 335 try s.discardLine(); 336 try s.reply(500, "5.5.2 Line too long"); 337 continue; 338 }, 339 }; 340 const command = protocol.Command.parse(line) catch { 341 try s.reply(501, "5.5.4 Syntax error in parameters"); 342 continue; 343 }; 344 switch (command) { 345 .helo => { 346 // RFC 2033 §4: an LMTP server must not answer HELO or EHLO 347 // with a positive completion, and 500 is what it suggests. 348 if (s.options.protocol == .lmtp) { 349 try s.reply(500, "5.5.1 This is LMTP, use LHLO"); 350 continue; 351 } 352 greeted = true; 353 transaction.clear(); 354 _ = arena_state.reset(.retain_capacity); 355 try s.reply(250, s.options.hostname); 356 }, 357 .ehlo => { 358 if (s.options.protocol == .lmtp) { 359 try s.reply(500, "5.5.1 This is LMTP, use LHLO"); 360 continue; 361 } 362 greeted = true; 363 transaction.clear(); 364 _ = arena_state.reset(.retain_capacity); 365 try s.greetExtended(authenticated); 366 }, 367 .lhlo => { 368 if (s.options.protocol == .smtp) { 369 try s.reply(500, "5.5.2 Command not recognized"); 370 continue; 371 } 372 greeted = true; 373 transaction.clear(); 374 _ = arena_state.reset(.retain_capacity); 375 try s.greetExtended(authenticated); 376 }, 377 .mail => |args| { 378 if (!greeted) { 379 try s.reply(503, "5.5.1 Send EHLO first"); 380 continue; 381 } 382 if (s.options.require_auth and !authenticated) { 383 try s.reply(530, "5.7.0 Authentication required"); 384 continue; 385 } 386 if (transaction.from != null) { 387 try s.reply(503, "5.5.1 Nested MAIL command"); 388 continue; 389 } 390 var mail_declared_size: ?u64 = null; 391 var mail_body: ?protocol.Body = null; 392 var mail_smtputf8 = false; 393 var mail_ret: ?protocol.Ret = null; 394 var mail_envid: ?[]const u8 = null; 395 var mail_submitter: ?protocol.Submitter = null; 396 var mail_require_tls = false; 397 var params_ok = true; 398 var params = args.paramIterator(); 399 while (params.next()) |param| { 400 if (std.ascii.eqlIgnoreCase(param.keyword, "SIZE")) { 401 const size = std.fmt.parseInt(u64, param.value, 10) catch { 402 try s.reply(501, "5.5.2 Invalid SIZE parameter"); 403 params_ok = false; 404 break; 405 }; 406 if (size > s.options.max_message_size) { 407 try s.reply(552, "5.3.4 Message size exceeds fixed maximum"); 408 params_ok = false; 409 break; 410 } 411 mail_declared_size = size; 412 } else if (std.ascii.eqlIgnoreCase(param.keyword, "BODY")) { 413 mail_body = protocol.Body.parse(param.value) catch { 414 try s.reply(555, "5.5.4 Unsupported BODY value"); 415 params_ok = false; 416 break; 417 }; 418 } else if (std.ascii.eqlIgnoreCase(param.keyword, "SMTPUTF8")) { 419 if (param.value.len != 0) { 420 try s.reply(501, "5.5.4 SMTPUTF8 takes no value"); 421 params_ok = false; 422 break; 423 } 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; 441 } else if (std.ascii.eqlIgnoreCase(param.keyword, "AUTH")) { 442 // RFC 4954 §5 is explicit that a server advertising 443 // AUTH must take this parameter even from a client 444 // that has not authenticated — and then disregard 445 // what it says, which is what the check below does. 446 if (s.options.auth_mechanisms.len == 0) { 447 try s.reply(555, "5.5.4 Unrecognized parameter"); 448 params_ok = false; 449 break; 450 } 451 const decoded = arena.alloc(u8, param.value.len) catch 452 return error.OutOfMemory; 453 const asserted = protocol.Submitter.parse(decoded, param.value) catch { 454 try s.reply(501, "5.5.4 Invalid AUTH parameter"); 455 params_ok = false; 456 break; 457 }; 458 // "MUST behave as if the AUTH=<> parameter was 459 // supplied" when the client has not authenticated. 460 // The claim is still recorded as having been made, 461 // just not as having been believed. 462 mail_submitter = if (authenticated) asserted else .unknown; 463 } else if (std.ascii.eqlIgnoreCase(param.keyword, "RET")) { 464 mail_ret = protocol.Ret.parse(param.value) catch { 465 try s.reply(501, "5.5.4 Invalid RET parameter"); 466 params_ok = false; 467 break; 468 }; 469 } else if (std.ascii.eqlIgnoreCase(param.keyword, "ENVID")) { 470 // The cap is on the encoded form, which is what 471 // arrived, so it is checked before decoding. 472 if (param.value.len == 0 or param.value.len > protocol.max_envid_len) { 473 try s.reply(501, "5.5.4 Invalid ENVID parameter"); 474 params_ok = false; 475 break; 476 } 477 const decoded = arena.alloc(u8, param.value.len) catch return error.OutOfMemory; 478 mail_envid = protocol.xtextDecode(decoded, param.value) catch { 479 try s.reply(501, "5.5.4 Invalid ENVID parameter"); 480 params_ok = false; 481 break; 482 }; 483 } else { 484 try s.reply(555, "5.5.4 Unrecognized parameter"); 485 params_ok = false; 486 break; 487 } 488 } 489 if (!params_ok) continue; 490 if (!try s.validateAddress(args.path, mail_smtputf8)) continue; 491 if (s.handler.vtable.mailFrom) |callback| { 492 switch (callback(s.handler.context, args.path)) { 493 .accept => {}, 494 .reject => |r| { 495 try s.reply(r.code, r.text); 496 continue; 497 }, 498 } 499 } 500 transaction.from = try arena.dupe(u8, args.path); 501 transaction.declared_size = mail_declared_size; 502 transaction.body = mail_body; 503 transaction.smtputf8 = mail_smtputf8; 504 transaction.ret = mail_ret; 505 transaction.envid = mail_envid; 506 transaction.submitter = mail_submitter; 507 transaction.require_tls = mail_require_tls; 508 try s.replyGrouped(250, "2.1.0 Ok"); 509 }, 510 .rcpt => |args| { 511 if (transaction.from == null) { 512 try s.reply(503, "5.5.1 Need MAIL command first"); 513 continue; 514 } 515 var recipient: Recipient = .{ .address = args.path }; 516 var params_ok = true; 517 var params = args.paramIterator(); 518 while (params.next()) |param| { 519 if (std.ascii.eqlIgnoreCase(param.keyword, "NOTIFY")) { 520 recipient.notify = protocol.Notify.parse(param.value) catch { 521 try s.reply(501, "5.5.4 Invalid NOTIFY parameter"); 522 params_ok = false; 523 break; 524 }; 525 } else if (std.ascii.eqlIgnoreCase(param.keyword, "ORCPT")) { 526 if (param.value.len == 0 or param.value.len > protocol.Orcpt.max_len) { 527 try s.reply(501, "5.5.4 Invalid ORCPT parameter"); 528 params_ok = false; 529 break; 530 } 531 const decoded = arena.alloc(u8, param.value.len) catch return error.OutOfMemory; 532 recipient.orcpt = protocol.Orcpt.parse(decoded, param.value) catch { 533 try s.reply(501, "5.5.4 Invalid ORCPT parameter"); 534 params_ok = false; 535 break; 536 }; 537 } else { 538 try s.reply(555, "5.5.4 Unrecognized parameter"); 539 params_ok = false; 540 break; 541 } 542 } 543 if (!params_ok) continue; 544 if (!try s.validateAddress(args.path, transaction.smtputf8)) continue; 545 if (transaction.recipients.items.len >= s.options.max_recipients) { 546 try s.reply(452, "4.5.3 Too many recipients"); 547 continue; 548 } 549 if (s.handler.vtable.rcptTo) |callback| { 550 switch (callback(s.handler.context, recipient)) { 551 .accept => {}, 552 .reject => |r| { 553 try s.reply(r.code, r.text); 554 continue; 555 }, 556 } 557 } 558 recipient.address = try arena.dupe(u8, args.path); 559 if (recipient.orcpt) |*orcpt| orcpt.addr_type = try arena.dupe(u8, orcpt.addr_type); 560 try transaction.recipients.append(arena, recipient); 561 try s.replyGrouped(250, "2.1.5 Ok"); 562 }, 563 .data => { 564 if (transaction.recipients.items.len == 0) { 565 try s.reply(503, "5.5.1 Need RCPT command first"); 566 continue; 567 } 568 // RFC 3030 §3: binary content has no line structure, so it 569 // cannot be framed by a line holding a single dot. BDAT, 570 // which carries its length, is the only way to send it. 571 if (transaction.body == .binary_mime) { 572 try s.reply(503, "5.5.1 BINARYMIME requires BDAT"); 573 continue; 574 } 575 try s.receiveData(arena, transaction.envelope(s.identity())); 576 transaction.clear(); 577 _ = arena_state.reset(.retain_capacity); 578 }, 579 .bdat => |args| { 580 if (transaction.recipients.items.len == 0) { 581 // The chunk's octets follow regardless; consume them to 582 // keep the length-framed stream in sync. 583 s.reader.discardAll64(args.size) catch |err| switch (err) { 584 error.EndOfStream => return, 585 error.ReadFailed => return error.ReadFailed, 586 }; 587 try s.reply(503, "5.5.1 Need RCPT command first"); 588 continue; 589 } 590 const outcome = try s.receiveChunked(arena, transaction.envelope(s.identity()), args); 591 transaction.clear(); 592 _ = arena_state.reset(.retain_capacity); 593 switch (outcome) { 594 .done => {}, 595 .end_session => return, 596 } 597 }, 598 .rset => { 599 transaction.clear(); 600 _ = arena_state.reset(.retain_capacity); 601 try s.replyGrouped(250, "2.0.0 Ok"); 602 }, 603 .noop => try s.reply(250, "2.0.0 Ok"), 604 // 252 is the compliant answer for a server that will not check 605 // an address in advance but will take the mail: RFC 5321 §3.5.3. 606 // 500 or 502 here would put this server out of compliance, since 607 // §4.5.1 makes VRFY one of the commands it must support. 608 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"), 609 // EXPN is not required and is not implemented, and 502 says 610 // exactly that. 500 would be the reply of a server that had 611 // never heard of the command, which would not be true. 612 .expn => try s.reply(502, "5.5.1 EXPN not implemented"), 613 .help => try s.reply(214, "2.0.0 See RFC 5321"), 614 .starttls => { 615 const config = s.options.tls orelse { 616 try s.reply(502, "5.5.1 STARTTLS not supported"); 617 continue; 618 }; 619 if (config.mode != .starttls) { 620 try s.reply(502, "5.5.1 STARTTLS not supported"); 621 continue; 622 } 623 if (s.secured) { 624 try s.reply(503, "5.5.1 TLS already active"); 625 continue; 626 } 627 try s.reply(220, "2.0.0 Ready to start TLS"); 628 try s.upgradeToTls(config); 629 // RFC 3207 §4.2: both sides return to their initial state; 630 // the client must EHLO again. 631 greeted = false; 632 authenticated = false; 633 transaction.clear(); 634 _ = arena_state.reset(.retain_capacity); 635 }, 636 .quit => { 637 try s.reply(221, "2.0.0 Bye"); 638 if (s.secured) s.tls_connection.close() catch {}; 639 return; 640 }, 641 .auth => |args| { 642 if (s.options.auth_mechanisms.len == 0) { 643 try s.reply(503, "5.5.1 Authentication not enabled"); 644 continue; 645 } 646 if (!greeted) { 647 try s.reply(503, "5.5.1 Send EHLO first"); 648 continue; 649 } 650 if (authenticated) { 651 try s.reply(503, "5.5.1 Already authenticated"); 652 continue; 653 } 654 if (transaction.from != null) { 655 try s.reply(503, "5.5.1 MAIL transaction in progress"); 656 continue; 657 } 658 switch (try s.receiveAuth(args)) { 659 .authenticated => authenticated = true, 660 .rejected => {}, 661 .disconnected => return, 662 } 663 }, 664 .unknown => try s.reply(500, "5.5.2 Command not recognized"), 665 } 666 } 667} 668 669/// The smallest `Options.sasl_buffer` worth offering: enough plaintext for 670/// PLAIN, LOGIN, CRAM-MD5, EXTERNAL, ANONYMOUS and DIGEST-MD5. 671pub const sasl_buffer_min = 896; 672 673/// A `Options.sasl_buffer` size that fits everything, OAuth tokens included. 674/// See `Client.sasl_buffer_suggested`, which says where the number is from. 675pub const sasl_buffer_suggested = 7168; 676 677/// Writes the EHLO or LHLO response: the hostname, then one line per 678/// extension. The two are the same list — RFC 2033 gives LHLO the semantics 679/// of EHLO — and it requires PIPELINING and ENHANCEDSTATUSCODES of an LMTP 680/// server, both of which are here for every session anyway. 681fn greetExtended(s: *Server, authenticated: bool) error{WriteFailed}!void { 682 // Every reply carries an enhanced status code (RFC 3463), so the 683 // ENHANCEDSTATUSCODES extension (RFC 2034) is advertised. 684 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-BINARYMIME\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n", .{s.options.hostname}); 685 if (s.options.tls) |config| { 686 if (config.mode == .starttls and !s.secured) 687 try s.writer.writeAll("250-STARTTLS\r\n"); 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"); 692 if (s.options.auth_mechanisms.len != 0 and !authenticated) { 693 try s.writer.writeAll("250-AUTH"); 694 for (s.options.auth_mechanisms) |mechanism| 695 try s.writer.print(" {s}", .{mechanism.name()}); 696 try s.writer.writeAll("\r\n"); 697 } 698 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); 699 try s.writer.flush(); 700} 701 702/// Performs the server-side TLS handshake over the current transport and 703/// swaps the session onto the encrypted connection. 704fn upgradeToTls(s: *Server, config: TlsOptions) error{TlsHandshakeFailed}!void { 705 var rng_source: std.Random.IoSource = .{ .io = config.io }; 706 s.tls_connection = tls.server(s.reader, s.writer, .{ 707 .auth = config.auth, 708 .rng = rng_source.interface(), 709 .now = Io.Clock.real.now(config.io), 710 }) catch return error.TlsHandshakeFailed; 711 s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer); 712 s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer); 713 s.reader = &s.tls_reader.interface; 714 s.writer = &s.tls_writer.interface; 715 s.secured = true; 716} 717 718const AuthOutcome = enum { authenticated, rejected, disconnected }; 719 720/// Runs the challenge/response exchange for AUTH PLAIN or AUTH LOGIN 721/// (RFC 4954) and consults the handler's `authenticate` callback. Every 722/// outcome except `disconnected` has already sent its reply. 723/// Runs a SASL exchange with whichever of `Options.auth_mechanisms` the 724/// client named ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 725/// 726/// The mechanisms come from 727/// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl); what is here is the 728/// SMTP half of it — the 334 challenges, the `*` that cancels, 235, and the 729/// 504 for a name nothing answers to. 730fn receiveAuth(s: *Server, args: protocol.Command.AuthArgs) RunError!AuthOutcome { 731 const mechanism = for (s.options.auth_mechanisms) |candidate| { 732 if (std.ascii.eqlIgnoreCase(candidate.name(), args.mechanism)) break candidate; 733 } else { 734 try s.reply(504, "5.5.4 Unrecognized authentication type"); 735 return .rejected; 736 }; 737 738 // The two halves never hold anything at once: a challenge is written as 739 // plaintext and encoded into `coded`, and the client's answer decodes 740 // back over it once that has gone out. 741 if (s.options.sasl_buffer.len < sasl_buffer_min) { 742 try s.reply(454, "4.7.0 Temporary authentication failure"); 743 return .rejected; 744 } 745 const unit = s.options.sasl_buffer.len / 7; 746 const coded = s.options.sasl_buffer[0 .. unit * 4]; 747 const plain = s.options.sasl_buffer[unit * 4 ..][0 .. unit * 3]; 748 var challenge: Io.Writer = .fixed(plain); 749 750 // RFC 4954 §4: no argument at all and a single `=` are different. The 751 // first is "I have nothing to send yet", the second an initial response 752 // that happens to be empty, and mechanisms read them differently. 753 const initial: ?[]const u8 = if (args.initial.len == 0) null else decodeBase64( 754 coded, 755 args.initial, 756 ) orelse { 757 try s.reply(501, "5.5.2 Invalid base64"); 758 return .rejected; 759 }; 760 761 var step = mechanism.start(initial, &challenge) catch |err| return s.authFailed(err); 762 while (true) { 763 switch (step) { 764 .accepted => |who| { 765 s.setIdentity(who); 766 try s.reply(235, "2.7.0 Authentication successful"); 767 return .authenticated; 768 }, 769 .rejected => { 770 // No distinction between "no such user" and "wrong password" 771 // reaches the wire: that difference is worth money to 772 // somebody enumerating accounts. 773 try s.reply(535, "5.7.8 Authentication credentials invalid"); 774 return .rejected; 775 }, 776 .challenge => { 777 const encoded = std.base64.standard.Encoder.encode(coded, challenge.buffered()); 778 // A zero-length challenge is "334 " — the code, a space, and 779 // nothing after it, which `reply` produces for empty text. 780 try s.reply(334, encoded); 781 782 const line = switch (try s.takeAuthLine()) { 783 .line => |line| line, 784 .cancelled => return .rejected, 785 .disconnected => return .disconnected, 786 }; 787 const response = decodeBase64(coded, line) orelse { 788 try s.reply(501, "5.5.2 Invalid base64"); 789 return .rejected; 790 }; 791 challenge = .fixed(plain); 792 step = mechanism.respond(response, &challenge) catch |err| 793 return s.authFailed(err); 794 }, 795 } 796 } 797} 798 799/// A mechanism that could not make sense of what the client sent. Its own 800/// errors are not worth distinguishing on the wire. 801fn authFailed(s: *Server, err: sasl.Server.Error) RunError!AuthOutcome { 802 switch (err) { 803 error.OutOfMemory => return error.OutOfMemory, 804 error.WriteFailed => return error.WriteFailed, 805 error.BadResponse => { 806 try s.reply(501, "5.5.2 Malformed authentication response"); 807 return .rejected; 808 }, 809 } 810} 811 812/// The identity the client authenticated as, or null if it has not. 813pub fn identity(s: *const Server) ?[]const u8 { 814 if (s.identity_len == 0) return null; 815 return s.identity_buf[0..s.identity_len]; 816} 817 818/// Keeps the authenticated identity for the rest of the session. 819/// 820/// Copied because a mechanism may report a slice of the response it was 821/// handed, which lives in a buffer that does not outlive the exchange — and 822/// this has to survive every transaction that follows. 823fn setIdentity(s: *Server, who: []const u8) void { 824 s.identity_len = @min(who.len, s.identity_buf.len); 825 @memcpy(s.identity_buf[0..s.identity_len], who[0..s.identity_len]); 826} 827 828const AuthLine = union(enum) { line: []u8, cancelled, disconnected }; 829 830/// Reads one continuation line of an AUTH exchange. `cancelled` covers both 831/// an explicit "*" and an overlong line; its reply has already been sent. 832fn takeAuthLine(s: *Server) RunError!AuthLine { 833 const line = protocol.readLine(s.reader) catch |err| switch (err) { 834 error.EndOfStream => return .disconnected, 835 error.ReadFailed => return error.ReadFailed, 836 error.LineTooLong => { 837 try s.discardLine(); 838 try s.reply(501, "5.5.2 Response too long"); 839 return .cancelled; 840 }, 841 }; 842 if (std.mem.eql(u8, line, "*")) { 843 try s.reply(501, "5.7.0 Authentication cancelled"); 844 return .cancelled; 845 } 846 return .{ .line = line }; 847} 848 849/// Decodes a base64 AUTH argument; "=" denotes an empty response. 850fn decodeBase64(out: []u8, encoded: []const u8) ?[]u8 { 851 if (std.mem.eql(u8, encoded, "=")) return out[0..0]; 852 const len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return null; 853 if (len > out.len) return null; 854 std.base64.standard.Decoder.decode(out[0..len], encoded) catch return null; 855 return out[0..len]; 856} 857 858const ChunkOutcome = enum { done, end_session }; 859 860/// Receives a message sent with BDAT chunks (RFC 3030 CHUNKING), starting 861/// from the already-parsed first chunk header. Chunk data is raw: no 862/// dot-stuffing and no line-ending normalization. 863fn receiveChunked( 864 s: *Server, 865 arena: std.mem.Allocator, 866 envelope: Envelope, 867 first: protocol.Command.BdatArgs, 868) RunError!ChunkOutcome { 869 if (s.handler.vtable.messageReader) |callback| { 870 var buffer: [1024]u8 = undefined; 871 var bdat_reader: BdatReader = .{ 872 .server = s, 873 .remaining = first.size, 874 .last = first.last, 875 .interface = .{ 876 .buffer = &buffer, 877 .vtable = &.{ .stream = BdatReader.stream }, 878 .seek = 0, 879 .end = 0, 880 }, 881 }; 882 const decision = callback(s.handler.context, envelope, &bdat_reader.interface); 883 if (bdat_reader.abort == null and !bdat_reader.finished) { 884 // Consume whatever the callback left unread, through LAST. 885 var discard_buf: [256]u8 = undefined; 886 var discarding: Io.Writer.Discarding = .init(&discard_buf); 887 _ = bdat_reader.interface.streamRemaining(&discarding.writer) catch {}; 888 } 889 if (bdat_reader.abort) |abort| switch (abort) { 890 .rset, .protocol => return .done, // Replies already sent. 891 .quit, .disconnected => return .end_session, 892 .transport_failure => return error.ReadFailed, 893 }; 894 try s.replyMessage(envelope, decision); 895 return .done; 896 } 897 898 var data: std.ArrayList(u8) = .empty; 899 var oversize = false; 900 var size = first.size; 901 var last = first.last; 902 while (true) { 903 var left = size; 904 while (left > 0) { 905 const available = s.reader.peekGreedy(1) catch |err| switch (err) { 906 error.EndOfStream => return .end_session, 907 error.ReadFailed => return error.ReadFailed, 908 }; 909 const n: usize = @intCast(@min(@as(u64, available.len), left)); 910 if (!oversize) { 911 if (data.items.len + n > s.options.max_message_size) { 912 oversize = true; 913 } else { 914 try data.appendSlice(arena, available[0..n]); 915 } 916 } 917 s.reader.toss(n); 918 left -= n; 919 } 920 if (last) break; 921 try s.reply(250, "2.0.0 Chunk received"); 922 const line = protocol.readLine(s.reader) catch |err| switch (err) { 923 error.EndOfStream => return .end_session, 924 error.ReadFailed => return error.ReadFailed, 925 error.LineTooLong => { 926 try s.discardLine(); 927 try s.reply(500, "5.5.2 Line too long"); 928 return .done; // Transaction aborted. 929 }, 930 }; 931 const command = protocol.Command.parse(line) catch { 932 try s.reply(501, "5.5.4 Syntax error in parameters"); 933 return .done; 934 }; 935 switch (command) { 936 .bdat => |b| { 937 size = b.size; 938 last = b.last; 939 }, 940 .rset => { 941 try s.reply(250, "2.0.0 Ok"); 942 return .done; 943 }, 944 .quit => { 945 try s.reply(221, "2.0.0 Bye"); 946 if (s.secured) s.tls_connection.close() catch {}; 947 return .end_session; 948 }, 949 else => { 950 try s.reply(503, "5.5.1 BDAT expected"); 951 return .done; 952 }, 953 } 954 } 955 if (oversize) { 956 try s.reply(552, "5.3.4 Message exceeds maximum size"); 957 return .done; 958 } 959 try s.replyMessage(envelope, s.handler.vtable.message.?(s.handler.context, envelope, data.items)); 960 return .done; 961} 962 963/// Adapts a BDAT chunk sequence into an `Io.Reader` of the raw message 964/// content for `Handler.VTable.messageReader`, replying 250 between chunks 965/// and following the chunk headers as they arrive. 966const BdatReader = struct { 967 server: *Server, 968 interface: Io.Reader, 969 remaining: u64, 970 last: bool, 971 finished: bool = false, 972 abort: ?Abort = null, 973 974 const Abort = enum { rset, quit, protocol, disconnected, transport_failure }; 975 976 fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize { 977 const br: *BdatReader = @alignCast(@fieldParentPtr("interface", io_r)); 978 const s = br.server; 979 while (br.remaining == 0) { 980 if (br.last) { 981 br.finished = true; 982 return error.EndOfStream; 983 } 984 s.reply(250, "2.0.0 Chunk received") catch { 985 br.abort = .transport_failure; 986 return error.ReadFailed; 987 }; 988 const line = protocol.readLine(s.reader) catch |err| { 989 switch (err) { 990 error.EndOfStream => br.abort = .disconnected, 991 error.ReadFailed => br.abort = .transport_failure, 992 error.LineTooLong => { 993 s.discardLine() catch {}; 994 s.reply(500, "5.5.2 Line too long") catch {}; 995 br.abort = .protocol; 996 }, 997 } 998 return error.ReadFailed; 999 }; 1000 const command = protocol.Command.parse(line) catch { 1001 s.reply(501, "5.5.4 Syntax error in parameters") catch {}; 1002 br.abort = .protocol; 1003 return error.ReadFailed; 1004 }; 1005 switch (command) { 1006 .bdat => |b| { 1007 br.remaining = b.size; 1008 br.last = b.last; 1009 }, 1010 .rset => { 1011 s.reply(250, "2.0.0 Ok") catch {}; 1012 br.abort = .rset; 1013 return error.ReadFailed; 1014 }, 1015 .quit => { 1016 s.reply(221, "2.0.0 Bye") catch {}; 1017 if (s.secured) s.tls_connection.close() catch {}; 1018 br.abort = .quit; 1019 return error.ReadFailed; 1020 }, 1021 else => { 1022 s.reply(503, "5.5.1 BDAT expected") catch {}; 1023 br.abort = .protocol; 1024 return error.ReadFailed; 1025 }, 1026 } 1027 } 1028 const available = s.reader.peekGreedy(1) catch |err| switch (err) { 1029 error.EndOfStream => { 1030 br.abort = .disconnected; 1031 return error.ReadFailed; 1032 }, 1033 error.ReadFailed => { 1034 br.abort = .transport_failure; 1035 return error.ReadFailed; 1036 }, 1037 }; 1038 const dest = limit.slice(try w.writableSliceGreedy(1)); 1039 const n: usize = @intCast(@min(@min(@as(u64, available.len), @as(u64, dest.len)), br.remaining)); 1040 @memcpy(dest[0..n], available[0..n]); 1041 s.reader.toss(n); 1042 br.remaining -= n; 1043 w.advance(n); 1044 return n; 1045 } 1046}; 1047 1048/// Reads message content after DATA up to the terminating ".\r\n", 1049/// un-stuffing dots, then asks the handler to accept or reject. 1050fn receiveData(s: *Server, arena: std.mem.Allocator, envelope: Envelope) RunError!void { 1051 try s.reply(354, "End data with <CR><LF>.<CR><LF>"); 1052 1053 if (s.handler.vtable.messageReader) |callback| { 1054 var buffer: [1024]u8 = undefined; 1055 var data_reader: DataReader = .{ 1056 .session_reader = s.reader, 1057 .interface = .{ 1058 .buffer = &buffer, 1059 .vtable = &.{ .stream = DataReader.stream }, 1060 .seek = 0, 1061 .end = 0, 1062 }, 1063 }; 1064 const decision = callback(s.handler.context, envelope, &data_reader.interface); 1065 // Consume whatever the callback left unread, up to and including 1066 // the terminating ".". 1067 while (!data_reader.finished) { 1068 const line = protocol.readLine(s.reader) catch |err| switch (err) { 1069 error.EndOfStream => return, // Client disconnected mid-message. 1070 error.ReadFailed => return error.ReadFailed, 1071 error.LineTooLong => { 1072 try s.discardLine(); 1073 continue; 1074 }, 1075 }; 1076 if (std.mem.eql(u8, line, ".")) break; 1077 } 1078 try s.replyMessage(envelope, decision); 1079 return; 1080 } 1081 1082 var data: std.ArrayList(u8) = .empty; 1083 var oversize = false; 1084 while (true) { 1085 const line = protocol.readLine(s.reader) catch |err| switch (err) { 1086 error.EndOfStream => return, // Client disconnected mid-message. 1087 error.ReadFailed => return error.ReadFailed, 1088 error.LineTooLong => { 1089 // Longer than our reader buffer; RFC 5321 caps text lines at 1090 // 1000 octets, so treat it as oversize but keep scanning for 1091 // the terminator. 1092 try s.discardLine(); 1093 oversize = true; 1094 continue; 1095 }, 1096 }; 1097 if (std.mem.eql(u8, line, ".")) break; 1098 const content = if (line.len > 0 and line[0] == '.') line[1..] else line; 1099 if (oversize) continue; 1100 if (data.items.len + content.len + protocol.crlf.len > s.options.max_message_size) { 1101 oversize = true; 1102 continue; 1103 } 1104 try data.appendSlice(arena, content); 1105 try data.appendSlice(arena, protocol.crlf); 1106 } 1107 if (oversize) { 1108 try s.reply(552, "5.3.4 Message exceeds maximum size"); 1109 return; 1110 } 1111 try s.replyMessage(envelope, s.handler.vtable.message.?(s.handler.context, envelope, data.items)); 1112} 1113 1114/// Adapts the session's line-based DATA phase into an `Io.Reader` of the 1115/// unstuffed message content for `Handler.VTable.messageReader`. 1116const DataReader = struct { 1117 session_reader: *Io.Reader, 1118 interface: Io.Reader, 1119 /// Unread remainder of the current line (points into the session 1120 /// reader's buffer, which only this reader touches during DATA). 1121 line: []const u8 = &.{}, 1122 line_ending: []const u8 = &.{}, 1123 finished: bool = false, 1124 1125 fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize { 1126 const dr: *DataReader = @alignCast(@fieldParentPtr("interface", io_r)); 1127 if (dr.line.len == 0 and dr.line_ending.len == 0) { 1128 if (dr.finished) return error.EndOfStream; 1129 const raw = protocol.readLine(dr.session_reader) catch return error.ReadFailed; 1130 if (std.mem.eql(u8, raw, ".")) { 1131 dr.finished = true; 1132 return error.EndOfStream; 1133 } 1134 dr.line = if (raw.len > 0 and raw[0] == '.') raw[1..] else raw; 1135 dr.line_ending = protocol.crlf; 1136 } 1137 const dest = limit.slice(try w.writableSliceGreedy(1)); 1138 const line_n = @min(dest.len, dr.line.len); 1139 @memcpy(dest[0..line_n], dr.line[0..line_n]); 1140 dr.line = dr.line[line_n..]; 1141 var n = line_n; 1142 if (dr.line.len == 0) { 1143 const ending_n = @min(dest.len - n, dr.line_ending.len); 1144 @memcpy(dest[n..][0..ending_n], dr.line_ending[0..ending_n]); 1145 dr.line_ending = dr.line_ending[ending_n..]; 1146 n += ending_n; 1147 } 1148 w.advance(n); 1149 return n; 1150 } 1151}; 1152 1153/// Enforces RFC 6531: a non-ASCII envelope address is only allowed when 1154/// the transaction requested SMTPUTF8, and must be well-formed UTF-8. 1155/// Replies and returns false on rejection. 1156fn validateAddress(s: *Server, path: []const u8, smtputf8: bool) error{WriteFailed}!bool { 1157 for (path) |byte| { 1158 if (byte >= 0x80) { 1159 if (!smtputf8) { 1160 try s.reply(553, "5.6.7 Non-ASCII address requires SMTPUTF8"); 1161 return false; 1162 } 1163 if (!std.unicode.utf8ValidateSlice(path)) { 1164 try s.reply(553, "5.6.7 Address is not valid UTF-8"); 1165 return false; 1166 } 1167 return true; 1168 } 1169 } 1170 return true; 1171} 1172 1173/// Answers a command that ends a pipelined group, which is every command 1174/// RFC 2920 §3.2 names as one whose reply must not be held back: EHLO, 1175/// DATA, VRFY, EXPN, TURN, QUIT and NOOP, and anything that went wrong. 1176fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 1177 try s.replyLine(code, text); 1178 try s.writer.flush(); 1179} 1180 1181/// Answers one of the commands that may appear anywhere in a pipelined 1182/// group — RSET, MAIL FROM and RCPT TO — by holding the reply back while 1183/// the client has already sent more for the server to read. 1184/// 1185/// RFC 2920 §3.2 asks for exactly this: keep those replies in a buffer so 1186/// they go out as a unit, and send everything pending the moment the input 1187/// is empty. The condition is what makes it safe rather than a deadlock — 1188/// a reply is only ever held while there is another command to answer, so 1189/// the client is never left waiting for something still in the buffer. 1190fn replyGrouped(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 1191 try s.replyLine(code, text); 1192 if (s.reader.bufferedLen() == 0) try s.writer.flush(); 1193} 1194 1195/// A reply without the flush, for when several are going out together. 1196fn replyLine(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 1197 try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text }); 1198} 1199 1200/// Answers a completed message. 1201/// 1202/// SMTP gets one reply. LMTP gets one for each previously successful RCPT, 1203/// in the order they were issued 1204/// ([RFC 2033 §4.2](https://datatracker.ietf.org/doc/html/rfc2033#section-4.2)) 1205/// — including a repeat for a recipient named twice, which is why this 1206/// walks the accepted list rather than a set of addresses. 1207fn replyMessage(s: *Server, envelope: Envelope, decision: Decision) error{WriteFailed}!void { 1208 if (s.options.protocol == .smtp) { 1209 try s.writeVerdict(decision); 1210 try s.writer.flush(); 1211 return; 1212 } 1213 for (envelope.recipients, 0..) |_, index| { 1214 // A rejected message is rejected for everybody; there is nothing 1215 // left to ask about an individual recipient. 1216 const verdict: Decision = switch (decision) { 1217 .reject => decision, 1218 .accept => if (s.handler.vtable.recipientResult) |callback| 1219 callback(s.handler.context, envelope, index) 1220 else 1221 .accept, 1222 }; 1223 try s.writeVerdict(verdict); 1224 } 1225 try s.writer.flush(); 1226} 1227 1228fn writeVerdict(s: *Server, decision: Decision) error{WriteFailed}!void { 1229 switch (decision) { 1230 .accept => try s.replyLine(250, "2.0.0 Ok, message accepted"), 1231 .reject => |r| try s.replyLine(r.code, r.text), 1232 } 1233} 1234 1235/// Discards input through the next newline after `error.LineTooLong`, which 1236/// leaves the reader positioned at the start of the oversized line. 1237fn discardLine(s: *Server) error{ReadFailed}!void { 1238 _ = s.reader.discardDelimiterInclusive('\n') catch |err| switch (err) { 1239 error.EndOfStream => {}, 1240 error.ReadFailed => return error.ReadFailed, 1241 }; 1242} 1243 1244const TestHandler = struct { 1245 from: std.ArrayList(u8) = .empty, 1246 recipients: std.ArrayList(u8) = .empty, 1247 data: std.ArrayList(u8) = .empty, 1248 messages_accepted: usize = 0, 1249 reject_recipient: ?[]const u8 = null, 1250 /// Accepted at RCPT time and then failed per-recipient at the end of 1251 /// the message, which only LMTP can express. 1252 fail_delivery: ?[]const u8 = null, 1253 /// Returned for the message as a whole, before any per-recipient 1254 /// verdict is asked for. 1255 reject_message: ?Decision.Rejection = null, 1256 declared_size: ?u64 = null, 1257 body: ?protocol.Body = null, 1258 smtputf8: bool = false, 1259 /// DSN parameters, kept from the last RCPT and the last message. The 1260 /// strings are copied because everything a callback is handed lives 1261 /// only for the duration of the call. 1262 last_notify: ?protocol.Notify = null, 1263 last_orcpt: bool = false, 1264 last_orcpt_type: std.ArrayList(u8) = .empty, 1265 last_orcpt_address: std.ArrayList(u8) = .empty, 1266 ret: ?protocol.Ret = null, 1267 require_tls: bool = false, 1268 submitter: ?protocol.Submitter = null, 1269 submitter_mailbox: std.ArrayList(u8) = .empty, 1270 identity: std.ArrayList(u8) = .empty, 1271 envid: std.ArrayList(u8) = .empty, 1272 /// When set, enables the authenticate callback accepting user "alice" 1273 /// with this password. 1274 password: ?[]const u8 = null, 1275 1276 fn deinit(h: *TestHandler) void { 1277 h.from.deinit(std.testing.allocator); 1278 h.recipients.deinit(std.testing.allocator); 1279 h.data.deinit(std.testing.allocator); 1280 h.envid.deinit(std.testing.allocator); 1281 h.identity.deinit(std.testing.allocator); 1282 h.submitter_mailbox.deinit(std.testing.allocator); 1283 h.last_orcpt_type.deinit(std.testing.allocator); 1284 h.last_orcpt_address.deinit(std.testing.allocator); 1285 } 1286 1287 fn handler(h: *TestHandler) Handler { 1288 return .{ .context = h, .vtable = &.{ 1289 .rcptTo = onRcptTo, 1290 .message = onMessage, 1291 .recipientResult = onRecipientResult, 1292 } }; 1293 } 1294 1295 /// The credential check the SASL mechanisms are built from, accepting 1296 /// "alice" with whatever `password` holds. 1297 fn check(h: *TestHandler) sasl.Server.PasswordCheck { 1298 return .{ .context = h, .verify = verify }; 1299 } 1300 1301 fn verify( 1302 context: ?*anyopaque, 1303 authzid: []const u8, 1304 authcid: []const u8, 1305 password: []const u8, 1306 ) ?[]const u8 { 1307 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1308 if (authzid.len != 0) return null; 1309 if (!std.mem.eql(u8, authcid, "alice")) return null; 1310 if (!std.mem.eql(u8, password, h.password.?)) return null; 1311 return "alice"; 1312 } 1313 1314 /// LMTP's per-recipient verdict: everybody is fine except the one 1315 /// address `fail_delivery` names, which is the outcome that has no 1316 /// spelling in SMTP. 1317 fn onRecipientResult(context: ?*anyopaque, envelope: Envelope, index: usize) Decision { 1318 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1319 const failing = h.fail_delivery orelse return .accept; 1320 if (std.mem.eql(u8, envelope.recipients[index].address, failing)) 1321 return .{ .reject = .{ .code = 550, .text = "5.2.1 Mailbox disabled" } }; 1322 return .accept; 1323 } 1324 1325 fn onRcptTo(context: ?*anyopaque, recipient: Recipient) Decision { 1326 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1327 h.last_notify = recipient.notify; 1328 if (recipient.orcpt) |orcpt| { 1329 const gpa = std.testing.allocator; 1330 h.last_orcpt = true; 1331 h.last_orcpt_type.appendSlice(gpa, orcpt.addr_type) catch return .{ .reject = .{} }; 1332 h.last_orcpt_address.appendSlice(gpa, orcpt.address) catch return .{ .reject = .{} }; 1333 } 1334 if (h.reject_recipient) |rejected| { 1335 if (std.mem.eql(u8, recipient.address, rejected)) return .{ .reject = .{ 1336 .code = 550, 1337 .text = "5.1.1 No such user", 1338 } }; 1339 } 1340 return .accept; 1341 } 1342 1343 fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision { 1344 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1345 if (h.reject_message) |rejection| return .{ .reject = rejection }; 1346 const gpa = std.testing.allocator; 1347 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} }; 1348 for (envelope.recipients) |recipient| { 1349 h.recipients.appendSlice(gpa, recipient.address) catch return .{ .reject = .{} }; 1350 h.recipients.append(gpa, ';') catch return .{ .reject = .{} }; 1351 } 1352 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} }; 1353 h.messages_accepted += 1; 1354 h.declared_size = envelope.declared_size; 1355 h.body = envelope.body; 1356 h.smtputf8 = envelope.smtputf8; 1357 h.ret = envelope.ret; 1358 h.submitter = envelope.submitter; 1359 h.require_tls = envelope.require_tls; 1360 if (envelope.submitter) |who| switch (who) { 1361 // Copied: it points into the session arena, which is reset the 1362 // moment this transaction ends. 1363 .mailbox => |mailbox| h.submitter_mailbox.appendSlice(gpa, mailbox) catch 1364 return .{ .reject = .{} }, 1365 .unknown => {}, 1366 }; 1367 if (envelope.authenticated_as) |who| 1368 h.identity.appendSlice(gpa, who) catch return .{ .reject = .{} }; 1369 if (envelope.envid) |envid| h.envid.appendSlice(gpa, envid) catch return .{ .reject = .{} }; 1370 return .accept; 1371 } 1372}; 1373 1374/// A writer that records where its flush boundaries fell, so that a test 1375/// can tell one reply per write from several replies in one. 1376const BatchingWriter = struct { 1377 interface: Io.Writer, 1378 sink: std.ArrayList(u8) = .empty, 1379 /// The bytes handed over at each drain — one entry per effective flush. 1380 batches: std.ArrayList(usize) = .empty, 1381 1382 fn init(buffer: []u8) BatchingWriter { 1383 return .{ .interface = .{ 1384 .buffer = buffer, 1385 .vtable = &.{ .drain = drain }, 1386 .end = 0, 1387 } }; 1388 } 1389 1390 fn deinit(bw: *BatchingWriter) void { 1391 bw.sink.deinit(std.testing.allocator); 1392 bw.batches.deinit(std.testing.allocator); 1393 } 1394 1395 fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize { 1396 const bw: *BatchingWriter = @alignCast(@fieldParentPtr("interface", w)); 1397 const gpa = std.testing.allocator; 1398 var handed: usize = w.buffered().len; 1399 bw.sink.appendSlice(gpa, w.buffered()) catch return error.WriteFailed; 1400 w.end = 0; 1401 var n: usize = 0; 1402 if (chunks.len > 0) { 1403 for (chunks[0 .. chunks.len - 1]) |bytes| { 1404 bw.sink.appendSlice(gpa, bytes) catch return error.WriteFailed; 1405 n += bytes.len; 1406 } 1407 const pattern = chunks[chunks.len - 1]; 1408 for (0..splat) |_| { 1409 bw.sink.appendSlice(gpa, pattern) catch return error.WriteFailed; 1410 n += pattern.len; 1411 } 1412 } 1413 handed += n; 1414 if (handed > 0) bw.batches.append(gpa, handed) catch return error.WriteFailed; 1415 return n; 1416 } 1417}; 1418 1419test "replies to a pipelined group go out together and in order" { 1420 var h: TestHandler = .{}; 1421 defer h.deinit(); 1422 1423 // One group: MAIL, two RCPTs and DATA, which RFC 2920 §3.1 allows as 1424 // the last command of one. A fixed reader has the whole session 1425 // buffered, which is what a client that pipelines looks like. 1426 var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ 1427 "MAIL FROM:<alice@example.com>\r\n" ++ 1428 "RCPT TO:<bob@example.net>\r\n" ++ 1429 "RCPT TO:<carol@example.net>\r\n" ++ 1430 "DATA\r\nhi\r\n.\r\nQUIT\r\n"); 1431 var buffer: [4096]u8 = undefined; 1432 var bw: BatchingWriter = .init(&buffer); 1433 defer bw.deinit(); 1434 1435 var session: Server = .init(&reader, &bw.interface, h.handler(), .{ .hostname = "mx.test" }); 1436 try session.run(std.testing.allocator); 1437 1438 // Order first: every reply is there, once, in the order asked for. 1439 const out = bw.sink.items; 1440 const envelope_replies = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n" ++ 1441 "354 End data with <CR><LF>.<CR><LF>\r\n"; 1442 try std.testing.expect(std.mem.indexOf(u8, out, envelope_replies) != null); 1443 1444 // And batching: the three envelope replies were held back and left 1445 // with the 354, rather than going out one at a time. There are five 1446 // replies after the greeting and the EHLO response, and fewer writes. 1447 // And batching: eight replies left in five writes, because the three 1448 // envelope replies were held back and went out with the 354 as one. 1449 // The others are the greeting, the EHLO response, the message verdict 1450 // and the goodbye — all of which RFC 2920 §3.2 says must not be held. 1451 try std.testing.expectEqual(@as(usize, 5), bw.batches.items.len); 1452 try std.testing.expectEqual(envelope_replies.len, bw.batches.items[2]); 1453} 1454 1455test "a held reply is released as soon as there is nothing left to read" { 1456 var h: TestHandler = .{}; 1457 defer h.deinit(); 1458 1459 // MAIL alone: its reply may not be held, because nothing follows it in 1460 // the buffer and the client is waiting for it. 1461 var reader: Io.Reader = .fixed("EHLO client.example.org\r\nMAIL FROM:<a@example.com>\r\n"); 1462 var buffer: [4096]u8 = undefined; 1463 var bw: BatchingWriter = .init(&buffer); 1464 defer bw.deinit(); 1465 1466 var session: Server = .init(&reader, &bw.interface, h.handler(), .{ .hostname = "mx.test" }); 1467 try session.run(std.testing.allocator); 1468 1469 try std.testing.expect(std.mem.endsWith(u8, bw.sink.items, "250 2.1.0 Ok\r\n")); 1470} 1471 1472/// The mechanisms a test session offers, built from a `TestHandler`'s 1473/// credential check. They hold per-exchange state, so each test makes its 1474/// own rather than sharing a constant. 1475const TestMechanisms = struct { 1476 plain: sasl.PlainServer, 1477 login: sasl.LoginServer, 1478 storage: [2]sasl.Server = undefined, 1479 /// The scratch a session needs to run them, which `Options` takes from 1480 /// the caller rather than putting on the stack. 1481 buffer: [sasl_buffer_suggested]u8 = undefined, 1482 1483 fn init(h: *TestHandler) TestMechanisms { 1484 return .{ .plain = .init(h.check()), .login = .init(h.check()) }; 1485 } 1486 1487 fn list(m: *TestMechanisms) []const sasl.Server { 1488 m.storage = .{ m.plain.server(), m.login.server() }; 1489 return &m.storage; 1490 } 1491 1492 fn scratch(m: *TestMechanisms) []u8 { 1493 return &m.buffer; 1494 } 1495}; 1496 1497fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 { 1498 var reader: Io.Reader = .fixed(input); 1499 var writer: Io.Writer = .fixed(out_buf); 1500 var session: Server = .init(&reader, &writer, handler, options); 1501 try session.run(std.testing.allocator); 1502 return writer.buffered(); 1503} 1504 1505test "BINARYMIME is advertised, accepted, and refused on DATA" { 1506 var h: TestHandler = .{}; 1507 defer h.deinit(); 1508 1509 var out_buf: [4096]u8 = undefined; 1510 const out = try runScript( 1511 "EHLO client.example.org\r\n" ++ 1512 "MAIL FROM:<alice@example.com> BODY=BINARYMIME\r\n" ++ 1513 "RCPT TO:<bob@example.net>\r\n" ++ 1514 "DATA\r\n" ++ // 503: binary content cannot be framed by a dot 1515 "BDAT 5 LAST\r\n\x00\r\n.\r\nQUIT\r\n", 1516 &out_buf, 1517 h.handler(), 1518 .{ .hostname = "mx.test" }, 1519 ); 1520 1521 // RFC 3030: BINARYMIME may only be offered alongside CHUNKING. 1522 try std.testing.expect(std.mem.indexOf(u8, out, "250-BINARYMIME\r\n") != null); 1523 try std.testing.expect(std.mem.indexOf(u8, out, "250-CHUNKING\r\n") != null); 1524 try std.testing.expect(std.mem.indexOf(u8, out, "503 5.5.1 BINARYMIME requires BDAT") != null); 1525 try std.testing.expectEqual(protocol.Body.binary_mime, h.body.?); 1526 // Five octets, delivered as they were sent: a NUL, and a lone dot on a 1527 // line of its own, which over DATA would have ended the message. 1528 try std.testing.expectEqualStrings("\x00\r\n.\r", h.data.items); 1529 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1530} 1531 1532test "every octet survives a binary chunk" { 1533 var h: TestHandler = .{}; 1534 defer h.deinit(); 1535 1536 // All 256 byte values, which is the "preserve all bits in each octet" 1537 // requirement of RFC 3030 §5 stated as a test. 1538 const octets = comptime blk: { 1539 var all: [256]u8 = undefined; 1540 for (&all, 0..) |*byte, i| byte.* = @intCast(i); 1541 break :blk all; 1542 }; 1543 1544 var out_buf: [4096]u8 = undefined; 1545 _ = try runScript( 1546 "EHLO client.example.org\r\n" ++ 1547 "MAIL FROM:<alice@example.com> BODY=BINARYMIME\r\n" ++ 1548 "RCPT TO:<bob@example.net>\r\n" ++ 1549 "BDAT 256 LAST\r\n" ++ octets ++ "QUIT\r\n", 1550 &out_buf, 1551 h.handler(), 1552 .{ .hostname = "mx.test" }, 1553 ); 1554 try std.testing.expectEqualSlices(u8, &octets, h.data.items); 1555} 1556 1557test "LMTP answers once per accepted recipient" { 1558 var h: TestHandler = .{ .fail_delivery = "bad@example.net" }; 1559 defer h.deinit(); 1560 1561 var out_buf: [2048]u8 = undefined; 1562 const out = try runScript( 1563 "LHLO client.example.org\r\n" ++ 1564 "MAIL FROM:<alice@example.com>\r\n" ++ 1565 "RCPT TO:<good@example.net>\r\n" ++ 1566 "RCPT TO:<bad@example.net>\r\n" ++ 1567 // RFC 2033 §4.2 is explicit that a repeated forward-path still 1568 // gets a reply of its own. 1569 "RCPT TO:<good@example.net>\r\n" ++ 1570 "DATA\r\nhi\r\n.\r\nQUIT\r\n", 1571 &out_buf, 1572 h.handler(), 1573 .{ .protocol = .lmtp, .hostname = "mx.test" }, 1574 ); 1575 1576 const tail = out[std.mem.indexOf(u8, out, "354").?..]; 1577 try std.testing.expectEqualStrings( 1578 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 1579 "250 2.0.0 Ok, message accepted\r\n" ++ 1580 "550 5.2.1 Mailbox disabled\r\n" ++ 1581 "250 2.0.0 Ok, message accepted\r\n" ++ 1582 "221 2.0.0 Bye\r\n", 1583 tail, 1584 ); 1585} 1586 1587test "a message rejected outright is rejected for every LMTP recipient" { 1588 var h: TestHandler = .{ 1589 .reject_message = .{ .code = 452, .text = "4.3.1 Out of storage" }, 1590 }; 1591 defer h.deinit(); 1592 1593 var out_buf: [2048]u8 = undefined; 1594 const out = try runScript( 1595 "LHLO client.example.org\r\n" ++ 1596 "MAIL FROM:<alice@example.com>\r\n" ++ 1597 "RCPT TO:<a@example.net>\r\n" ++ 1598 "RCPT TO:<b@example.net>\r\n" ++ 1599 "DATA\r\nhi\r\n.\r\nQUIT\r\n", 1600 &out_buf, 1601 h.handler(), 1602 .{ .protocol = .lmtp, .hostname = "mx.test" }, 1603 ); 1604 1605 const tail = out[std.mem.indexOf(u8, out, "354").?..]; 1606 try std.testing.expectEqualStrings( 1607 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 1608 "452 4.3.1 Out of storage\r\n" ++ 1609 "452 4.3.1 Out of storage\r\n" ++ 1610 "221 2.0.0 Bye\r\n", 1611 tail, 1612 ); 1613} 1614 1615test "BDAT LAST also answers once per LMTP recipient" { 1616 var h: TestHandler = .{ .fail_delivery = "bad@example.net" }; 1617 defer h.deinit(); 1618 1619 var out_buf: [2048]u8 = undefined; 1620 const out = try runScript( 1621 "LHLO client.example.org\r\n" ++ 1622 "MAIL FROM:<alice@example.com>\r\n" ++ 1623 "RCPT TO:<good@example.net>\r\n" ++ 1624 "RCPT TO:<bad@example.net>\r\n" ++ 1625 "BDAT 4 LAST\r\nhi\r\nQUIT\r\n", 1626 &out_buf, 1627 h.handler(), 1628 .{ .protocol = .lmtp, .hostname = "mx.test" }, 1629 ); 1630 1631 const tail = out[std.mem.lastIndexOf(u8, out, "250 2.1.5 Ok\r\n").? + "250 2.1.5 Ok\r\n".len ..]; 1632 try std.testing.expectEqualStrings( 1633 "250 2.0.0 Ok, message accepted\r\n" ++ 1634 "550 5.2.1 Mailbox disabled\r\n" ++ 1635 "221 2.0.0 Bye\r\n", 1636 tail, 1637 ); 1638} 1639 1640test "each protocol refuses the other's greeting" { 1641 var h: TestHandler = .{}; 1642 defer h.deinit(); 1643 1644 var out_buf: [2048]u8 = undefined; 1645 // RFC 2033 §4: an LMTP server must not answer HELO or EHLO positively. 1646 const lmtp = try runScript( 1647 "EHLO client.example.org\r\nHELO client.example.org\r\nQUIT\r\n", 1648 &out_buf, 1649 h.handler(), 1650 .{ .protocol = .lmtp, .hostname = "mx.test" }, 1651 ); 1652 try std.testing.expectEqualStrings( 1653 "220 mx.test ESMTP ready\r\n" ++ 1654 "500 5.5.1 This is LMTP, use LHLO\r\n" ++ 1655 "500 5.5.1 This is LMTP, use LHLO\r\n" ++ 1656 "221 2.0.0 Bye\r\n", 1657 lmtp, 1658 ); 1659 1660 var smtp_buf: [2048]u8 = undefined; 1661 const smtp = try runScript( 1662 "LHLO client.example.org\r\nQUIT\r\n", 1663 &smtp_buf, 1664 h.handler(), 1665 .{ .hostname = "mx.test" }, 1666 ); 1667 try std.testing.expectEqualStrings( 1668 "220 mx.test ESMTP ready\r\n" ++ 1669 "500 5.5.2 Command not recognized\r\n" ++ 1670 "221 2.0.0 Bye\r\n", 1671 smtp, 1672 ); 1673} 1674 1675test "LHLO advertises what LMTP requires" { 1676 var h: TestHandler = .{}; 1677 defer h.deinit(); 1678 1679 var out_buf: [2048]u8 = undefined; 1680 const out = try runScript( 1681 "LHLO client.example.org\r\nQUIT\r\n", 1682 &out_buf, 1683 h.handler(), 1684 .{ .protocol = .lmtp, .hostname = "mx.test" }, 1685 ); 1686 // RFC 2033 §5 requires both of these of an LMTP server. 1687 try std.testing.expect(std.mem.indexOf(u8, out, "250-PIPELINING\r\n") != null); 1688 try std.testing.expect(std.mem.indexOf(u8, out, "250-ENHANCEDSTATUSCODES\r\n") != null); 1689} 1690 1691test "DSN parameters reach the handler" { 1692 var h: TestHandler = .{}; 1693 defer h.deinit(); 1694 1695 var out_buf: [2048]u8 = undefined; 1696 const out = try runScript( 1697 "EHLO client.example.org\r\n" ++ 1698 "MAIL FROM:<alice@example.com> RET=HDRS ENVID=batch+207\r\n" ++ 1699 "RCPT TO:<bob@example.net> NOTIFY=SUCCESS,FAILURE ORCPT=rfc822;team@example.net\r\n" ++ 1700 "DATA\r\nhi\r\n.\r\nQUIT\r\n", 1701 &out_buf, 1702 h.handler(), 1703 .{ .hostname = "mx.test" }, 1704 ); 1705 1706 // Nothing in the session was refused. 1707 try std.testing.expect(std.mem.indexOf(u8, out, "\r\n5") == null); 1708 try std.testing.expectEqual(protocol.Ret.hdrs, h.ret.?); 1709 // The ENVID arrives xtext-decoded: "batch+207" carried a space. 1710 try std.testing.expectEqualStrings("batch 7", h.envid.items); 1711 const notify = h.last_notify.?; 1712 try std.testing.expect(notify.on.success and notify.on.failure and !notify.on.delay); 1713 try std.testing.expect(h.last_orcpt); 1714 try std.testing.expectEqualStrings("rfc822", h.last_orcpt_type.items); 1715 try std.testing.expectEqualStrings("team@example.net", h.last_orcpt_address.items); 1716} 1717 1718test "the DSN extension is advertised and its parameters are validated" { 1719 var h: TestHandler = .{}; 1720 defer h.deinit(); 1721 1722 var out_buf: [2048]u8 = undefined; 1723 const out = try runScript( 1724 "EHLO client.example.org\r\n" ++ 1725 "MAIL FROM:<a@example.com> RET=PARTIAL\r\n" ++ // 501: not FULL or HDRS 1726 "MAIL FROM:<a@example.com> ENVID=bad+ZZ\r\n" ++ // 501: not xtext 1727 "MAIL FROM:<a@example.com> ENVID=" ++ ("x" ** 101) ++ "\r\n" ++ // 501: too long 1728 "MAIL FROM:<a@example.com>\r\n" ++ 1729 "RCPT TO:<b@example.net> NOTIFY=NEVER,SUCCESS\r\n" ++ // 501: NEVER stands alone 1730 "RCPT TO:<b@example.net> NOTIFY=SOMETIMES\r\n" ++ // 501: not a keyword 1731 "RCPT TO:<b@example.net> ORCPT=team@example.net\r\n" ++ // 501: no addr-type 1732 "RCPT TO:<b@example.net> FROB=1\r\n" ++ // 555: still unrecognized 1733 "QUIT\r\n", 1734 &out_buf, 1735 h.handler(), 1736 .{ .hostname = "mx.test" }, 1737 ); 1738 1739 try std.testing.expect(std.mem.indexOf(u8, out, "250-DSN\r\n") != null); 1740 var replies = std.mem.splitSequence(u8, out, "\r\n"); 1741 var codes: std.ArrayList([]const u8) = .empty; 1742 defer codes.deinit(std.testing.allocator); 1743 while (replies.next()) |line| { 1744 if (line.len >= 4 and line[3] == ' ') try codes.append(std.testing.allocator, line[0..3]); 1745 } 1746 // 220 greeting, 250 EHLO, then the parameter verdicts, then 221. 1747 try std.testing.expectEqualStrings("220", codes.items[0]); 1748 try std.testing.expectEqualStrings("250", codes.items[1]); 1749 try std.testing.expectEqualStrings("501", codes.items[2]); 1750 try std.testing.expectEqualStrings("501", codes.items[3]); 1751 try std.testing.expectEqualStrings("501", codes.items[4]); 1752 try std.testing.expectEqualStrings("250", codes.items[5]); 1753 try std.testing.expectEqualStrings("501", codes.items[6]); 1754 try std.testing.expectEqualStrings("501", codes.items[7]); 1755 try std.testing.expectEqualStrings("501", codes.items[8]); 1756 try std.testing.expectEqualStrings("555", codes.items[9]); 1757 try std.testing.expectEqualStrings("221", codes.items[10]); 1758} 1759 1760test run { 1761 var h: TestHandler = .{}; 1762 defer h.deinit(); 1763 1764 var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ 1765 "MAIL FROM:<alice@example.com>\r\n" ++ 1766 "RCPT TO:<bob@example.net>\r\n" ++ 1767 "RCPT TO:<carol@example.net>\r\n" ++ 1768 "DATA\r\n" ++ 1769 "Subject: hi\r\n" ++ 1770 "\r\n" ++ 1771 "..stuffed line\r\n" ++ 1772 "body\r\n" ++ 1773 ".\r\n" ++ 1774 "QUIT\r\n"); 1775 var out_buf: [1024]u8 = undefined; 1776 var writer: Io.Writer = .fixed(&out_buf); 1777 1778 var session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" }); 1779 try session.run(std.testing.allocator); 1780 const output = writer.buffered(); 1781 1782 try std.testing.expectEqualStrings("alice@example.com", h.from.items); 1783 try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items); 1784 try std.testing.expectEqualStrings("Subject: hi\r\n\r\n.stuffed line\r\nbody\r\n", h.data.items); 1785 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1786 1787 try std.testing.expectEqualStrings( 1788 "220 mx.test ESMTP ready\r\n" ++ 1789 "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-BINARYMIME\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n250 SIZE 16777216\r\n" ++ 1790 "250 2.1.0 Ok\r\n" ++ 1791 "250 2.1.5 Ok\r\n" ++ 1792 "250 2.1.5 Ok\r\n" ++ 1793 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 1794 "250 2.0.0 Ok, message accepted\r\n" ++ 1795 "221 2.0.0 Bye\r\n", 1796 output, 1797 ); 1798} 1799 1800test "command sequencing is enforced" { 1801 var h: TestHandler = .{}; 1802 defer h.deinit(); 1803 1804 var out_buf: [1024]u8 = undefined; 1805 const output = try runScript( 1806 "MAIL FROM:<early@example.com>\r\n" ++ 1807 "EHLO client.example.org\r\n" ++ 1808 "RCPT TO:<bob@example.net>\r\n" ++ 1809 "DATA\r\n" ++ 1810 "QUIT\r\n", 1811 &out_buf, 1812 h.handler(), 1813 .{}, 1814 ); 1815 1816 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 1817 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Send EHLO first") != null); 1818 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null); 1819 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null); 1820} 1821 1822test "handler can reject a recipient" { 1823 var h: TestHandler = .{ .reject_recipient = "nobody@example.net" }; 1824 defer h.deinit(); 1825 1826 var out_buf: [1024]u8 = undefined; 1827 const output = try runScript( 1828 "EHLO client.example.org\r\n" ++ 1829 "MAIL FROM:<alice@example.com>\r\n" ++ 1830 "RCPT TO:<nobody@example.net>\r\n" ++ 1831 "RCPT TO:<bob@example.net>\r\n" ++ 1832 "DATA\r\n" ++ 1833 "hello\r\n" ++ 1834 ".\r\n" ++ 1835 "QUIT\r\n", 1836 &out_buf, 1837 h.handler(), 1838 .{}, 1839 ); 1840 1841 try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null); 1842 try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items); 1843 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1844} 1845 1846test "AUTH PLAIN with initial response" { 1847 var h: TestHandler = .{ .password = "secret" }; 1848 defer h.deinit(); 1849 var mechanisms: TestMechanisms = .init(&h); 1850 1851 var out_buf: [1024]u8 = undefined; 1852 // base64("\x00alice\x00secret") 1853 const output = try runScript( 1854 "EHLO client.example.org\r\n" ++ 1855 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ 1856 "MAIL FROM:<alice@example.com>\r\n" ++ 1857 "RCPT TO:<bob@example.net>\r\n" ++ 1858 "DATA\r\nauthed mail\r\n.\r\n" ++ 1859 "QUIT\r\n", 1860 &out_buf, 1861 h.handler(), 1862 .{ .require_auth = true, .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 1863 ); 1864 1865 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); 1866 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 1867 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1868} 1869 1870test "AUTH LOGIN challenge exchange" { 1871 var h: TestHandler = .{ .password = "secret" }; 1872 defer h.deinit(); 1873 var mechanisms: TestMechanisms = .init(&h); 1874 1875 var out_buf: [1024]u8 = undefined; 1876 // base64("alice"), base64("secret") 1877 const output = try runScript( 1878 "EHLO client.example.org\r\n" ++ 1879 "AUTH LOGIN\r\n" ++ 1880 "YWxpY2U=\r\n" ++ 1881 "c2VjcmV0\r\n" ++ 1882 "QUIT\r\n", 1883 &out_buf, 1884 h.handler(), 1885 .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 1886 ); 1887 1888 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null); 1889 try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null); 1890 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 1891} 1892 1893test "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 1921test "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 1943test "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 1969test "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); 1981} 1982 1983test "EXPN is declined rather than disowned" { 1984 var h: TestHandler = .{}; 1985 defer h.deinit(); 1986 1987 var out_buf: [2048]u8 = undefined; 1988 const output = try runScript( 1989 "EHLO client.example.org\r\n" ++ 1990 "EXPN staff\r\n" ++ // 502: known, not implemented 1991 "VRFY somebody@example.net\r\n" ++ // 252: will not check, will take 1992 "EXPN\r\n" ++ // 501: the argument is not optional 1993 "VRFY\r\n" ++ // 501: likewise 1994 "FROB list\r\n" ++ // 500: genuinely never heard of 1995 "QUIT\r\n", 1996 &out_buf, 1997 h.handler(), 1998 .{}, 1999 ); 2000 2001 var replies = std.mem.splitSequence(u8, output, "\r\n"); 2002 var codes: std.ArrayList([]const u8) = .empty; 2003 defer codes.deinit(std.testing.allocator); 2004 while (replies.next()) |line| { 2005 if (line.len >= 4 and line[3] == ' ') try codes.append(std.testing.allocator, line[0..3]); 2006 } 2007 // 220 greeting, 250 EHLO, then the verdicts, then 221. 2008 try std.testing.expectEqualStrings("502", codes.items[2]); 2009 try std.testing.expectEqualStrings("252", codes.items[3]); 2010 try std.testing.expectEqualStrings("501", codes.items[4]); 2011 try std.testing.expectEqualStrings("501", codes.items[5]); 2012 // The distinction that matters: 500 is "I have never heard of that", 2013 // 502 is "I know it and will not do it", and only one of them is true 2014 // of EXPN here. 2015 try std.testing.expectEqualStrings("500", codes.items[6]); 2016 try std.testing.expect(std.mem.indexOf(u8, output, "502 5.5.1 EXPN not implemented") != null); 2017} 2018 2019test "every reply that should carry an enhanced status code does" { 2020 // RFC 2034 §4: a server implementing the extension prefaces the text of 2021 // every 2xx, 4xx and 5xx reply with a status code whose class agrees -- 2022 // except the greeting, the response to HELO or EHLO, and any 3xx. This 2023 // walks a session that touches most of the command table and checks the 2024 // whole transcript against that rule rather than reply by reply. 2025 var h: TestHandler = .{ .password = "secret" }; 2026 defer h.deinit(); 2027 var mechanisms: TestMechanisms = .init(&h); 2028 2029 var out_buf: [8192]u8 = undefined; 2030 const output = try runScript( 2031 "EHLO client.example.org\r\n" ++ 2032 "NOOP\r\n" ++ 2033 "VRFY somebody\r\n" ++ 2034 "EXPN staff\r\n" ++ // 502 2035 "HELP\r\n" ++ 2036 "WHAT\r\n" ++ // 500 2037 "MAIL FROM:<a@example.com> FROB=1\r\n" ++ // 555 2038 "MAIL FROM:<a@example.com> SIZE=99999999\r\n" ++ // 552 2039 "RCPT TO:<b@example.net>\r\n" ++ // 503, no MAIL yet 2040 "AUTH GSSAPI\r\n" ++ // 504 2041 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // 535 2042 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // 235 2043 "MAIL FROM:<a@example.com>\r\n" ++ 2044 "RCPT TO:<b@example.net>\r\n" ++ 2045 "DATA\r\nbody\r\n.\r\n" ++ 2046 "RSET\r\n" ++ 2047 "QUIT\r\n", 2048 &out_buf, 2049 h.handler(), 2050 .{ 2051 .max_message_size = 1024, 2052 .auth_mechanisms = mechanisms.list(), 2053 .sasl_buffer = mechanisms.scratch(), 2054 }, 2055 ); 2056 2057 var checked: usize = 0; 2058 var greeting = true; 2059 var in_ehlo = false; 2060 var lines = std.mem.splitSequence(u8, output, "\r\n"); 2061 while (lines.next()) |line| { 2062 if (line.len < 4) continue; 2063 const code = std.fmt.parseInt(u16, line[0..3], 10) catch continue; 2064 const continued = line[3] == '-'; 2065 const text = line[4..]; 2066 2067 // The exclusions, in the order a session meets them. 2068 if (greeting) { 2069 greeting = false; 2070 continue; 2071 } 2072 if (in_ehlo or (code == 250 and continued)) { 2073 in_ehlo = continued; 2074 continue; 2075 } 2076 if (code / 100 == 3) { 2077 // 354 and the 334 challenges, which RFC 2034 leaves out. 2078 try std.testing.expectEqual(@as(?protocol.Enhanced, null), protocol.Enhanced.parse(text)); 2079 continue; 2080 } 2081 2082 const status = protocol.Enhanced.parse(text) orelse { 2083 std.debug.print("no enhanced status code: {s}\n", .{line}); 2084 return error.TestUnexpectedResult; 2085 }; 2086 if (!status.agrees(code)) { 2087 std.debug.print("class disagrees with the reply code: {s}\n", .{line}); 2088 return error.TestUnexpectedResult; 2089 } 2090 checked += 1; 2091 } 2092 // Enough of them to mean the walk actually walked. 2093 try std.testing.expect(checked >= 14); 2094} 2095 2096test "an authenticated client's AUTH= assertion reaches the handler" { 2097 var h: TestHandler = .{ .password = "secret" }; 2098 defer h.deinit(); 2099 var mechanisms: TestMechanisms = .init(&h); 2100 2101 var out_buf: [2048]u8 = undefined; 2102 _ = try runScript( 2103 "EHLO client.example.org\r\n" ++ 2104 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ 2105 // xtext: "e=mc2@example.com", the '=' escaped as +3D. 2106 "MAIL FROM:<relay@example.com> AUTH=e+3Dmc2@example.com\r\n" ++ 2107 "RCPT TO:<bob@example.net>\r\n" ++ 2108 "DATA\r\nrelayed\r\n.\r\nQUIT\r\n", 2109 &out_buf, 2110 h.handler(), 2111 .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 2112 ); 2113 2114 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2115 try std.testing.expectEqualStrings("e=mc2@example.com", h.submitter_mailbox.items); 2116 // And who did the asserting, which is the other half of judging it. 2117 try std.testing.expectEqualStrings("alice", h.identity.items); 2118} 2119 2120test "an unauthenticated client's AUTH= is taken and disbelieved" { 2121 var h: TestHandler = .{ .password = "secret" }; 2122 defer h.deinit(); 2123 var mechanisms: TestMechanisms = .init(&h); 2124 2125 var out_buf: [2048]u8 = undefined; 2126 const output = try runScript( 2127 "EHLO client.example.org\r\n" ++ 2128 "MAIL FROM:<relay@example.com> AUTH=alice@example.com\r\n" ++ 2129 "RCPT TO:<bob@example.net>\r\n" ++ 2130 "DATA\r\nrelayed\r\n.\r\nQUIT\r\n", 2131 &out_buf, 2132 h.handler(), 2133 .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 2134 ); 2135 2136 // RFC 4954 §5: a server advertising AUTH must accept the parameter even 2137 // from a client that has not authenticated -- so this is not a 501 -- 2138 // and must then behave as though `<>` had been sent. 2139 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null); 2140 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2141 try std.testing.expectEqual(protocol.Submitter.unknown, h.submitter.?); 2142 try std.testing.expectEqualStrings("", h.submitter_mailbox.items); 2143} 2144 2145test "AUTH= is rejected outright by a server that offers no AUTH at all" { 2146 var h: TestHandler = .{}; 2147 defer h.deinit(); 2148 2149 var out_buf: [2048]u8 = undefined; 2150 const output = try runScript( 2151 "EHLO client.example.org\r\n" ++ 2152 "MAIL FROM:<relay@example.com> AUTH=alice@example.com\r\n" ++ 2153 "QUIT\r\n", 2154 &out_buf, 2155 h.handler(), 2156 .{}, 2157 ); 2158 // The obligation to take it belongs to a server that advertises the 2159 // extension; one that does not is seeing a parameter it never offered. 2160 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); 2161} 2162 2163test "AUTH=<> says the peer considered the question and does not know" { 2164 var h: TestHandler = .{ .password = "secret" }; 2165 defer h.deinit(); 2166 var mechanisms: TestMechanisms = .init(&h); 2167 2168 var out_buf: [2048]u8 = undefined; 2169 const output = try runScript( 2170 "EHLO client.example.org\r\n" ++ 2171 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ 2172 "MAIL FROM:<relay@example.com> AUTH=<>\r\n" ++ 2173 "RSET\r\n" ++ 2174 // `+` must introduce two hex digits; "ZZ" are not. 2175 "MAIL FROM:<relay@example.com> AUTH=bad+ZZ\r\n" ++ // 501 2176 "QUIT\r\n", 2177 &out_buf, 2178 h.handler(), 2179 .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 2180 ); 2181 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.4 Invalid AUTH parameter") != null); 2182} 2183 2184test "the server can now offer CRAM-MD5, which it never could before" { 2185 var h: TestHandler = .{ .password = "secret" }; 2186 defer h.deinit(); 2187 2188 // The challenge is the server's to choose; a real one would not repeat. 2189 const challenge = "<1896.697170952@postoffice.reston.mci.net>"; 2190 const Lookup = struct { 2191 fn lookup(context: ?*anyopaque, username: []const u8) ?[]const u8 { 2192 const handler: *TestHandler = @ptrCast(@alignCast(context.?)); 2193 if (!std.mem.eql(u8, username, "tim")) return null; 2194 _ = handler; 2195 return "tanstaaftanstaaf"; 2196 } 2197 }; 2198 var cram: sasl.CramMd5Server = .init(challenge, .{ 2199 .context = &h, 2200 .lookup = Lookup.lookup, 2201 }); 2202 const mechanisms: []const sasl.Server = &.{cram.server()}; 2203 var sasl_scratch: [sasl_buffer_suggested]u8 = undefined; 2204 2205 var out_buf: [2048]u8 = undefined; 2206 const output = try runScript( 2207 "EHLO client.example.org\r\n" ++ 2208 "AUTH CRAM-MD5\r\n" ++ 2209 // base64("tim b913a602c7eda7a495b4e6e7334d3890"), the response 2210 // RFC 2195 publishes for this challenge and account. 2211 "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n" ++ 2212 "MAIL FROM:<tim@example.com>\r\n" ++ 2213 "RCPT TO:<bob@example.net>\r\n" ++ 2214 "DATA\r\nbody\r\n.\r\nQUIT\r\n", 2215 &out_buf, 2216 h.handler(), 2217 .{ .require_auth = true, .auth_mechanisms = mechanisms, .sasl_buffer = &sasl_scratch }, 2218 ); 2219 2220 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH CRAM-MD5\r\n") != null); 2221 // The challenge went out base64'd, and the login was accepted. 2222 try std.testing.expect(std.mem.indexOf(u8, output, "334 PDE4OTYuNjk3") != null); 2223 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 2224 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2225 // And the identity the mechanism reported reached the envelope, which is 2226 // what a handler deciding whether to relay actually needs. 2227 try std.testing.expectEqualStrings("tim", h.identity.items); 2228} 2229 2230test "the advertised mechanisms are the ones offered, in order" { 2231 var h: TestHandler = .{ .password = "secret" }; 2232 defer h.deinit(); 2233 var mechanisms: TestMechanisms = .init(&h); 2234 2235 var out_buf: [2048]u8 = undefined; 2236 const output = try runScript( 2237 "EHLO client.example.org\r\nAUTH SCRAM-SHA-256\r\nQUIT\r\n", 2238 &out_buf, 2239 h.handler(), 2240 .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 2241 ); 2242 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); 2243 // A name nothing answers to is 504, not 535: the credentials were never 2244 // in question. 2245 try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null); 2246} 2247 2248test "a session with no mechanisms does not advertise AUTH at all" { 2249 var h: TestHandler = .{}; 2250 defer h.deinit(); 2251 2252 var out_buf: [2048]u8 = undefined; 2253 const output = try runScript( 2254 "EHLO client.example.org\r\nAUTH PLAIN AGFsaWNlAHNlY3JldA==\r\nQUIT\r\n", 2255 &out_buf, 2256 h.handler(), 2257 .{}, 2258 ); 2259 try std.testing.expect(std.mem.indexOf(u8, output, "AUTH") == null or 2260 std.mem.indexOf(u8, output, "250-AUTH") == null); 2261 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null); 2262} 2263 2264test "AUTH failures and sequencing" { 2265 var h: TestHandler = .{ .password = "secret" }; 2266 defer h.deinit(); 2267 var mechanisms: TestMechanisms = .init(&h); 2268 2269 var out_buf: [2048]u8 = undefined; 2270 const output = try runScript( 2271 "EHLO client.example.org\r\n" ++ 2272 "MAIL FROM:<alice@example.com>\r\n" ++ // before auth: 530 2273 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // wrong password: 535 2274 "AUTH GSSAPI\r\n" ++ // unsupported: 504 2275 "AUTH PLAIN not!base64\r\n" ++ // 501 2276 "AUTH LOGIN\r\n" ++ 2277 "*\r\n" ++ // cancelled: 501 2278 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // correct: 235 2279 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // again: 503 2280 "QUIT\r\n", 2281 &out_buf, 2282 h.handler(), 2283 .{ .require_auth = true, .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 2284 ); 2285 2286 try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null); 2287 try std.testing.expect(std.mem.indexOf(u8, output, "535 5.7.8") != null); 2288 try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null); 2289 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid base64") != null); 2290 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.7.0 Authentication cancelled") != null); 2291 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 2292 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Already authenticated") != null); 2293} 2294 2295test "AUTH without a handler is refused" { 2296 var h: TestHandler = .{}; 2297 defer h.deinit(); 2298 2299 var out_buf: [1024]u8 = undefined; 2300 const output = try runScript( 2301 "EHLO client.example.org\r\nAUTH PLAIN AGEAYg==\r\nQUIT\r\n", 2302 &out_buf, 2303 h.handler(), 2304 .{}, 2305 ); 2306 2307 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH") == null); 2308 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null); 2309} 2310 2311test "oversize message is rejected but session continues" { 2312 var h: TestHandler = .{}; 2313 defer h.deinit(); 2314 2315 var out_buf: [1024]u8 = undefined; 2316 const output = try runScript( 2317 "EHLO client.example.org\r\n" ++ 2318 "MAIL FROM:<alice@example.com>\r\n" ++ 2319 "RCPT TO:<bob@example.net>\r\n" ++ 2320 "DATA\r\n" ++ 2321 "0123456789012345678901234567890123456789\r\n" ++ 2322 ".\r\n" ++ 2323 "NOOP\r\n" ++ 2324 "QUIT\r\n", 2325 &out_buf, 2326 h.handler(), 2327 .{ .max_message_size = 16 }, 2328 ); 2329 2330 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 2331 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 2332 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 2333} 2334 2335const StreamTestHandler = struct { 2336 collected: std.ArrayList(u8) = .empty, 2337 take_only: ?usize = null, 2338 2339 fn handler(h: *StreamTestHandler) Handler { 2340 return .{ .context = h, .vtable = &.{ 2341 .messageReader = onMessageReader, 2342 } }; 2343 } 2344 2345 fn onMessageReader(context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision { 2346 const h: *StreamTestHandler = @ptrCast(@alignCast(context.?)); 2347 _ = envelope; 2348 const gpa = std.testing.allocator; 2349 if (h.take_only) |n| { 2350 const bytes = message.take(n) catch return .{ .reject = .{} }; 2351 h.collected.appendSlice(gpa, bytes) catch return .{ .reject = .{} }; 2352 return .accept; 2353 } 2354 message.appendRemaining(gpa, &h.collected, .unlimited) catch return .{ .reject = .{} }; 2355 return .accept; 2356 } 2357}; 2358 2359test "streaming message handler receives unstuffed content" { 2360 var h: StreamTestHandler = .{}; 2361 defer h.collected.deinit(std.testing.allocator); 2362 2363 var out_buf: [1024]u8 = undefined; 2364 const output = try runScript( 2365 "EHLO client.example.org\r\n" ++ 2366 "MAIL FROM:<alice@example.com>\r\n" ++ 2367 "RCPT TO:<bob@example.net>\r\n" ++ 2368 "DATA\r\n" ++ 2369 "Subject: streamed\r\n" ++ 2370 "\r\n" ++ 2371 "..dot line\r\n" ++ 2372 "body\r\n" ++ 2373 ".\r\n" ++ 2374 "QUIT\r\n", 2375 &out_buf, 2376 h.handler(), 2377 .{}, 2378 ); 2379 2380 try std.testing.expectEqualStrings( 2381 "Subject: streamed\r\n\r\n.dot line\r\nbody\r\n", 2382 h.collected.items, 2383 ); 2384 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 2385} 2386 2387test "session drains what a streaming handler leaves unread" { 2388 var h: StreamTestHandler = .{ .take_only = 7 }; 2389 defer h.collected.deinit(std.testing.allocator); 2390 2391 var out_buf: [1024]u8 = undefined; 2392 const output = try runScript( 2393 "EHLO client.example.org\r\n" ++ 2394 "MAIL FROM:<alice@example.com>\r\n" ++ 2395 "RCPT TO:<bob@example.net>\r\n" ++ 2396 "DATA\r\n" ++ 2397 "Subject: mostly unread\r\n" ++ 2398 "lots of body\r\n" ++ 2399 ".\r\n" ++ 2400 "NOOP\r\n" ++ 2401 "QUIT\r\n", 2402 &out_buf, 2403 h.handler(), 2404 .{}, 2405 ); 2406 2407 try std.testing.expectEqualStrings("Subject", h.collected.items); 2408 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 2409 // The NOOP after DATA proves the terminator was consumed. 2410 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 2411} 2412 2413test "fuzz session with arbitrary client input" { 2414 try std.testing.fuzz({}, fuzzSession, .{}); 2415} 2416 2417fn fuzzSession(context: void, smith: *std.testing.Smith) !void { 2418 _ = context; 2419 var input_buf: [2048]u8 = undefined; 2420 const input = input_buf[0..smith.value(u11)]; 2421 smith.bytes(input); 2422 2423 var h: TestHandler = .{ .password = "secret" }; 2424 defer h.deinit(); 2425 2426 var reader: Io.Reader = .fixed(input); 2427 var discarding: Io.Writer.Discarding = .init(&.{}); 2428 var session: Server = .init(&reader, &discarding.writer, h.handler(), .{ 2429 .max_message_size = 512, 2430 .max_recipients = 4, 2431 }); 2432 // Whatever the "client" sends, the session must fail cleanly, never crash. 2433 session.run(std.testing.allocator) catch {}; 2434} 2435 2436test "fuzz collecting and streaming DATA agree" { 2437 try std.testing.fuzz({}, fuzzDataEquivalence, .{}); 2438} 2439 2440fn fuzzDataEquivalence(context: void, smith: *std.testing.Smith) !void { 2441 _ = context; 2442 var body_buf: [1024]u8 = undefined; 2443 const body = body_buf[0..smith.value(u10)]; 2444 smith.bytes(body); 2445 2446 var script_buf: [1200]u8 = undefined; 2447 const script = std.fmt.bufPrint( 2448 &script_buf, 2449 "EHLO fuzz.example.org\r\n" ++ 2450 "MAIL FROM:<a@example.com>\r\n" ++ 2451 "RCPT TO:<b@example.net>\r\n" ++ 2452 "DATA\r\n{s}\r\n.\r\nQUIT\r\n", 2453 .{body}, 2454 ) catch unreachable; 2455 2456 var collecting: TestHandler = .{}; 2457 defer collecting.deinit(); 2458 var out_buf: [4096]u8 = undefined; 2459 _ = runScript(script, &out_buf, collecting.handler(), .{}) catch {}; 2460 2461 var streaming: StreamTestHandler = .{}; 2462 defer streaming.collected.deinit(std.testing.allocator); 2463 _ = runScript(script, &out_buf, streaming.handler(), .{}) catch {}; 2464 2465 try std.testing.expectEqualSlices(u8, collecting.data.items, streaming.collected.items); 2466} 2467 2468test "MAIL parameters SIZE and BODY are honored" { 2469 var h: TestHandler = .{}; 2470 defer h.deinit(); 2471 2472 var out_buf: [1024]u8 = undefined; 2473 const output = try runScript( 2474 "EHLO client.example.org\r\n" ++ 2475 "MAIL FROM:<alice@example.com> SIZE=42 BODY=8BITMIME\r\n" ++ 2476 "RCPT TO:<bob@example.net>\r\n" ++ 2477 "DATA\r\nsized body\r\n.\r\n" ++ 2478 "QUIT\r\n", 2479 &out_buf, 2480 h.handler(), 2481 .{ .max_message_size = 1024 }, 2482 ); 2483 2484 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2485 try std.testing.expectEqual(@as(?u64, 42), h.declared_size); 2486 try std.testing.expectEqual(protocol.Body.eight_bit_mime, h.body.?); 2487 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null); 2488} 2489 2490test "invalid MAIL and RCPT parameters are rejected" { 2491 var h: TestHandler = .{}; 2492 defer h.deinit(); 2493 2494 var out_buf: [2048]u8 = undefined; 2495 const output = try runScript( 2496 "EHLO client.example.org\r\n" ++ 2497 "MAIL FROM:<a@example.com> SIZE=9999\r\n" ++ // over the maximum: 552 2498 "RCPT TO:<b@example.net>\r\n" ++ // that MAIL never started: 503 2499 "MAIL FROM:<a@example.com> SIZE=banana\r\n" ++ // 501 2500 "MAIL FROM:<a@example.com> BODY=BINARY\r\n" ++ // 555: not a body-value 2501 "MAIL FROM:<a@example.com> FUTURE=yes\r\n" ++ // 555 2502 "MAIL FROM:<a@example.com> BODY=7bit\r\n" ++ // ok 2503 "RCPT TO:<b@example.net> NOTIFY=SUCCESS\r\n" ++ // no RCPT params: 555 2504 "RCPT TO:<b@example.net>\r\n" ++ 2505 "DATA\r\nbody\r\n.\r\nQUIT\r\n", 2506 &out_buf, 2507 h.handler(), 2508 .{ .max_message_size = 1024 }, 2509 ); 2510 2511 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 2512 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null); 2513 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid SIZE parameter") != null); 2514 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unsupported BODY value") != null); 2515 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); 2516 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2517 try std.testing.expectEqual(protocol.Body.seven_bit, h.body.?); 2518 try std.testing.expectEqual(@as(?u64, null), h.declared_size); 2519} 2520 2521test init { 2522 var reader: Io.Reader = .fixed(""); 2523 var out_buf: [16]u8 = undefined; 2524 var writer: Io.Writer = .fixed(&out_buf); 2525 var h: TestHandler = .{}; 2526 const session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" }); 2527 try std.testing.expectEqualStrings("mx.test", session.options.hostname); 2528 try std.testing.expect(!session.secured); 2529} 2530 2531test Options { 2532 const options: Options = .{}; 2533 try std.testing.expectEqualStrings("localhost", options.hostname); 2534 try std.testing.expect(options.tls == null); 2535 try std.testing.expect(!options.require_auth); 2536} 2537 2538test Decision { 2539 const ok: Decision = .accept; 2540 try std.testing.expectEqual(Decision.accept, ok); 2541 2542 const no: Decision = .{ .reject = .{ .code = 451, .text = "4.3.0 Try again later" } }; 2543 try std.testing.expectEqual(@as(u16, 451), no.reject.code); 2544} 2545 2546test Envelope { 2547 const envelope: Envelope = .{ .from = "", .recipients = &.{.{ .address = "a@example.com" }} }; 2548 try std.testing.expectEqual(@as(usize, 1), envelope.recipients.len); 2549 try std.testing.expectEqual(@as(?u64, null), envelope.declared_size); 2550 try std.testing.expectEqual(@as(?protocol.Body, null), envelope.body); 2551} 2552 2553test Handler { 2554 const Callbacks = struct { 2555 fn onMessage(context: ?*anyopaque, envelope: Envelope, message_data: []const u8) Decision { 2556 _ = context; 2557 _ = envelope; 2558 _ = message_data; 2559 return .accept; 2560 } 2561 }; 2562 const handler: Handler = .{ .vtable = &.{ .message = Callbacks.onMessage } }; 2563 const envelope: Envelope = .{ .from = "", .recipients = &.{} }; 2564 try std.testing.expectEqual(Decision.accept, handler.vtable.message.?(null, envelope, "")); 2565} 2566 2567// SPDX-SnippetBegin 2568// SPDX-SnippetCopyrightText: © The Exim Maintainers 2569// SPDX-SnippetCopyrightText: © University of Cambridge 2570// SPDX-SnippetCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2571// SPDX-License-Identifier: GPL-2.0-or-later 2572// 2573// The command dialogue and message lines below are adapted from exim's 2574// test suite (test/scripts/0000-Basic); the reply expectations are ours. 2575test "protocol gauntlet adapted from exim's test suite" { 2576 // Command sequences and dot-stuffing cases distilled from exim's 2577 // test/scripts/0000-Basic (notably 0019's SMTP syntax-error dialogue 2578 // and 0008/0100's dotted message lines), verified against this server 2579 // with exim's own scriptable test client. 2580 var h: TestHandler = .{}; 2581 defer h.deinit(); 2582 2583 var out_buf: [4096]u8 = undefined; 2584 const output = try runScript( 2585 "NOOP\r\n" ++ 2586 "rhubarb\r\n" ++ 2587 "mail from:<x@y>\r\n" ++ 2588 "rcpt to:<a@b>\r\n" ++ 2589 "ehlo test.client\r\n" ++ 2590 "mail\r\n" ++ 2591 "mail from:\r\n" ++ 2592 "mail from:<>\r\n" ++ 2593 "mail from:<x@y>\r\n" ++ 2594 "rcpt to:\r\n" ++ 2595 "data\r\n" ++ 2596 "rset\r\n" ++ 2597 "etrn abc\r\n" ++ 2598 "vrfy userx\r\n" ++ 2599 "help\r\n" ++ 2600 "mail from:<ok@test1> SIZE=100 BODY=8BITMIME\r\n" ++ 2601 "rcpt to:<userx@test.ex>\r\n" ++ 2602 "rcpt to:<@relay.example:route@test.ex>\r\n" ++ 2603 "data\r\n" ++ 2604 "..that line started with a dot\r\n" ++ 2605 ".. and one starting with two dots\r\n" ++ 2606 "Message body\r\n" ++ 2607 ".\r\n" ++ 2608 "mail from:<a@b> SIZE=99999999\r\n" ++ 2609 "mail from:<a@b> BODY=BINARY\r\n" ++ 2610 "mail from:<a@b> FOO=bar\r\n" ++ 2611 "mail from:<a@b> SIZE=nan\r\n" ++ 2612 "starttls\r\n" ++ 2613 "mail from:<böb@test.ex>\r\n" ++ 2614 "mail from:<a@b> SMTPUTF8=YES\r\n" ++ 2615 "mail from:<böb@test.ex> SMTPUTF8\r\n" ++ 2616 "rset\r\n" ++ 2617 "BDAT 5\r\n" ++ 2618 "abc\r\n" ++ 2619 "mail from:<chunky@test.ex>\r\n" ++ 2620 "rcpt to:<userx@test.ex>\r\n" ++ 2621 "BDAT 7\r\n" ++ 2622 "hello\r\n" ++ 2623 "BDAT 23 LAST\r\n" ++ 2624 "world of chunked mail\r\n" ++ 2625 "quit\r\n", 2626 &out_buf, 2627 h.handler(), 2628 .{}, 2629 ); 2630 2631 try std.testing.expectEqualStrings( 2632 "220 localhost ESMTP ready\r\n" ++ 2633 "250 2.0.0 Ok\r\n" ++ 2634 "500 5.5.2 Command not recognized\r\n" ++ 2635 "503 5.5.1 Send EHLO first\r\n" ++ 2636 "503 5.5.1 Need MAIL command first\r\n" ++ 2637 "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-BINARYMIME\r\n" ++ 2638 "250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n250 SIZE 16777216\r\n" ++ 2639 "501 5.5.4 Syntax error in parameters\r\n" ++ 2640 "501 5.5.4 Syntax error in parameters\r\n" ++ 2641 "250 2.1.0 Ok\r\n" ++ 2642 "503 5.5.1 Nested MAIL command\r\n" ++ 2643 "501 5.5.4 Syntax error in parameters\r\n" ++ 2644 "503 5.5.1 Need RCPT command first\r\n" ++ 2645 "250 2.0.0 Ok\r\n" ++ 2646 "500 5.5.2 Command not recognized\r\n" ++ 2647 "252 2.5.2 Cannot VRFY user\r\n" ++ 2648 "214 2.0.0 See RFC 5321\r\n" ++ 2649 "250 2.1.0 Ok\r\n" ++ 2650 "250 2.1.5 Ok\r\n" ++ 2651 "250 2.1.5 Ok\r\n" ++ 2652 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 2653 "250 2.0.0 Ok, message accepted\r\n" ++ 2654 "552 5.3.4 Message size exceeds fixed maximum\r\n" ++ 2655 "555 5.5.4 Unsupported BODY value\r\n" ++ 2656 "555 5.5.4 Unrecognized parameter\r\n" ++ 2657 "501 5.5.2 Invalid SIZE parameter\r\n" ++ 2658 "502 5.5.1 STARTTLS not supported\r\n" ++ 2659 "553 5.6.7 Non-ASCII address requires SMTPUTF8\r\n" ++ 2660 "501 5.5.4 SMTPUTF8 takes no value\r\n" ++ 2661 "250 2.1.0 Ok\r\n" ++ 2662 "250 2.0.0 Ok\r\n" ++ 2663 "503 5.5.1 Need RCPT command first\r\n" ++ 2664 "250 2.1.0 Ok\r\n" ++ 2665 "250 2.1.5 Ok\r\n" ++ 2666 "250 2.0.0 Chunk received\r\n" ++ 2667 "250 2.0.0 Ok, message accepted\r\n" ++ 2668 "221 2.0.0 Bye\r\n", 2669 output, 2670 ); 2671 try std.testing.expectEqual(@as(usize, 2), h.messages_accepted); 2672 try std.testing.expectEqualStrings("ok@test1chunky@test.ex", h.from.items); 2673 try std.testing.expectEqualStrings( 2674 "userx@test.ex;route@test.ex;userx@test.ex;", 2675 h.recipients.items, 2676 ); 2677 try std.testing.expectEqualStrings( 2678 ".that line started with a dot\r\n. and one starting with two dots\r\nMessage body\r\n" ++ 2679 "hello\r\nworld of chunked mail\r\n", 2680 h.data.items, 2681 ); 2682} 2683// SPDX-SnippetEnd 2684 2685test "BDAT chunks are reassembled without unstuffing" { 2686 var h: TestHandler = .{}; 2687 defer h.deinit(); 2688 2689 var out_buf: [1024]u8 = undefined; 2690 const output = try runScript( 2691 "EHLO client.example.org\r\n" ++ 2692 "MAIL FROM:<alice@example.com>\r\n" ++ 2693 "RCPT TO:<bob@example.net>\r\n" ++ 2694 "BDAT 20\r\n" ++ 2695 "Subject: chunked\r\n\r\n" ++ // exactly 20 raw octets 2696 "BDAT 18\r\n" ++ 2697 ".dots stay\nas-is\r\n" ++ // 18 raw octets, no unstuffing 2698 "BDAT 0 LAST\r\n" ++ 2699 "QUIT\r\n", 2700 &out_buf, 2701 h.handler(), 2702 .{}, 2703 ); 2704 2705 try std.testing.expectEqualStrings( 2706 "Subject: chunked\r\n\r\n.dots stay\nas-is\r\n", 2707 h.data.items, 2708 ); 2709 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2710 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Chunk received") != null); 2711 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 2712} 2713 2714test "BDAT framing is length-based, not content-based" { 2715 var h: TestHandler = .{}; 2716 defer h.deinit(); 2717 2718 var out_buf: [1024]u8 = undefined; 2719 const output = try runScript( 2720 "EHLO client.example.org\r\n" ++ 2721 // Without a transaction the chunk must still be consumed, or the 2722 // embedded commands would be executed. 2723 "BDAT 12\r\n" ++ 2724 "QUIT\r\nRSET\r\n" ++ 2725 "MAIL FROM:<alice@example.com>\r\n" ++ 2726 "RCPT TO:<bob@example.net>\r\n" ++ 2727 // A chunk whose payload looks like commands is still just data. 2728 "BDAT 23 LAST\r\n" ++ 2729 "QUIT\r\nMAIL FROM:<x@y>\r\n" ++ 2730 "QUIT\r\n", 2731 &out_buf, 2732 h.handler(), 2733 .{}, 2734 ); 2735 2736 try std.testing.expectEqualStrings("QUIT\r\nMAIL FROM:<x@y>\r\n", h.data.items); 2737 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null); 2738 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2739 try std.testing.expect(std.mem.indexOf(u8, output, "221 2.0.0 Bye") != null); 2740} 2741 2742test "RSET between BDAT chunks aborts the message" { 2743 var h: TestHandler = .{}; 2744 defer h.deinit(); 2745 2746 var out_buf: [1024]u8 = undefined; 2747 const output = try runScript( 2748 "EHLO client.example.org\r\n" ++ 2749 "MAIL FROM:<alice@example.com>\r\n" ++ 2750 "RCPT TO:<bob@example.net>\r\n" ++ 2751 "BDAT 5\r\n" ++ 2752 "abc\r\n" ++ 2753 "RSET\r\n" ++ 2754 "NOOP\r\n" ++ 2755 "QUIT\r\n", 2756 &out_buf, 2757 h.handler(), 2758 .{}, 2759 ); 2760 2761 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 2762 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Chunk received") != null); 2763 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n250 2.0.0 Ok\r\n221") != null); 2764} 2765 2766test "oversize BDAT message is rejected" { 2767 var h: TestHandler = .{}; 2768 defer h.deinit(); 2769 2770 var out_buf: [1024]u8 = undefined; 2771 const output = try runScript( 2772 "EHLO client.example.org\r\n" ++ 2773 "MAIL FROM:<alice@example.com>\r\n" ++ 2774 "RCPT TO:<bob@example.net>\r\n" ++ 2775 "BDAT 40 LAST\r\n" ++ 2776 "0123456789012345678901234567890123456789" ++ 2777 "NOOP\r\n" ++ 2778 "QUIT\r\n", 2779 &out_buf, 2780 h.handler(), 2781 .{ .max_message_size = 16 }, 2782 ); 2783 2784 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 2785 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 2786 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 2787} 2788 2789test "streaming handler receives BDAT chunks" { 2790 var h: StreamTestHandler = .{}; 2791 defer h.collected.deinit(std.testing.allocator); 2792 2793 var out_buf: [1024]u8 = undefined; 2794 const output = try runScript( 2795 "EHLO client.example.org\r\n" ++ 2796 "MAIL FROM:<alice@example.com>\r\n" ++ 2797 "RCPT TO:<bob@example.net>\r\n" ++ 2798 "BDAT 6\r\n" ++ 2799 "part1\n" ++ 2800 "BDAT 8 LAST\r\n" ++ 2801 ".part2\r\n" ++ 2802 "QUIT\r\n", 2803 &out_buf, 2804 h.handler(), 2805 .{}, 2806 ); 2807 2808 try std.testing.expectEqualStrings("part1\n.part2\r\n", h.collected.items); 2809 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 2810} 2811 2812test "session drains BDAT chunks a streaming handler leaves unread" { 2813 var h: StreamTestHandler = .{ .take_only = 4 }; 2814 defer h.collected.deinit(std.testing.allocator); 2815 2816 var out_buf: [1024]u8 = undefined; 2817 const output = try runScript( 2818 "EHLO client.example.org\r\n" ++ 2819 "MAIL FROM:<alice@example.com>\r\n" ++ 2820 "RCPT TO:<bob@example.net>\r\n" ++ 2821 "BDAT 10\r\n" ++ 2822 "0123456789" ++ 2823 "BDAT 10 LAST\r\n" ++ 2824 "abcdefghij" ++ 2825 "NOOP\r\n" ++ 2826 "QUIT\r\n", 2827 &out_buf, 2828 h.handler(), 2829 .{}, 2830 ); 2831 2832 try std.testing.expectEqualStrings("0123", h.collected.items); 2833 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 2834 // The NOOP after the final chunk proves the stream stayed in sync. 2835 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 2836} 2837 2838test "SMTPUTF8 transactions and non-ASCII address enforcement" { 2839 var h: TestHandler = .{}; 2840 defer h.deinit(); 2841 2842 var out_buf: [2048]u8 = undefined; 2843 const output = try runScript( 2844 "EHLO client.example.org\r\n" ++ 2845 // Non-ASCII without the parameter: rejected. 2846 "MAIL FROM:<böb@example.com>\r\n" ++ 2847 "MAIL FROM:<alice@example.com>\r\n" ++ 2848 "RCPT TO:<jürgen@example.net>\r\n" ++ 2849 "RSET\r\n" ++ 2850 // The parameter takes no value. 2851 "MAIL FROM:<a@example.com> SMTPUTF8=YES\r\n" ++ 2852 // Invalid UTF-8 bytes even with the parameter: rejected. 2853 "MAIL FROM:<b\xff\xfeb@example.com> SMTPUTF8\r\n" ++ 2854 // Proper internationalized transaction. 2855 "MAIL FROM:<böb@example.com> SMTPUTF8\r\n" ++ 2856 "RCPT TO:<jürgen@example.net>\r\n" ++ 2857 "DATA\r\nSubject: ünïcode\r\n\r\nhello\r\n.\r\n" ++ 2858 "QUIT\r\n", 2859 &out_buf, 2860 h.handler(), 2861 .{}, 2862 ); 2863 2864 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2865 try std.testing.expect(h.smtputf8); 2866 try std.testing.expectEqualStrings("böb@example.com", h.from.items); 2867 try std.testing.expectEqualStrings("jürgen@example.net;", h.recipients.items); 2868 try std.testing.expect(std.mem.indexOf(u8, output, "250-SMTPUTF8\r\n") != null); 2869 try std.testing.expect(std.mem.indexOf(u8, output, "553 5.6.7 Non-ASCII address requires SMTPUTF8") != null); 2870 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.4 SMTPUTF8 takes no value") != null); 2871 try std.testing.expect(std.mem.indexOf(u8, output, "553 5.6.7 Address is not valid UTF-8") != null); 2872}