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