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