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