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