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