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