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