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