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