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
42 kB 1058 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, STARTTLS is advertised and accepted. The underlying stream 44 /// reader/writer handed to `init` must then have buffers of at least 45 /// `tls.input_buffer_len` and `tls.output_buffer_len` bytes, since the 46 /// handshake and TLS records run over them. 47 starttls: ?StartTls = null, 48 /// Reject MAIL with 530 until the client has authenticated. Requires a 49 /// handler with an `authenticate` callback. 50 require_auth: bool = false, 51}; 52 53pub const StartTls = struct { 54 io: Io, 55 /// Server certificate chain and private key presented to clients. 56 auth: *tls.config.CertKeyPair, 57}; 58 59/// A handler's verdict on an envelope step or a complete message. 60pub const Decision = union(enum) { 61 accept, 62 reject: Rejection, 63 64 pub const Rejection = struct { 65 /// Use 4xx for "try again later", 5xx for permanent rejection. 66 code: u16 = 550, 67 text: []const u8 = "5.7.1 Rejected", 68 }; 69}; 70 71pub const Envelope = struct { 72 /// Empty for the null reverse-path (`MAIL FROM:<>`). 73 from: []const u8, 74 recipients: []const []const u8, 75 /// Value of the MAIL SIZE= parameter (RFC 1870), if the client 76 /// declared one. Already validated against `Options.max_message_size`. 77 declared_size: ?u64 = null, 78 /// Value of the MAIL BODY= parameter (RFC 6152). 79 body: Body = .unspecified, 80 81 pub const Body = enum { unspecified, seven_bit, eight_bit_mime }; 82}; 83 84/// Callbacks invoked during a session. All slices passed to callbacks are 85/// only valid for the duration of the call. 86pub const Handler = struct { 87 context: ?*anyopaque = null, 88 vtable: *const VTable, 89 90 pub const VTable = struct { 91 /// Called for AUTH with the decoded credentials; return true to 92 /// accept. When set, AUTH PLAIN and AUTH LOGIN are advertised and 93 /// accepted (RFC 4954). 94 authenticate: ?*const fn (context: ?*anyopaque, username: []const u8, password: []const u8) bool = null, 95 /// Called for MAIL FROM. Null accepts every sender. 96 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null, 97 /// Called for each RCPT TO. Null accepts every recipient. 98 rcptTo: ?*const fn (context: ?*anyopaque, to: []const u8) Decision = null, 99 /// Called once the complete message has been received. The data has 100 /// CRLF line endings and dot-stuffing already removed. Exactly one 101 /// of `message` and `messageReader` must be set. 102 message: ?*const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision = null, 103 /// Streaming alternative to `message`: called after DATA with a 104 /// reader that yields the message content (dot-stuffing removed, 105 /// line endings normalized to CRLF) until end of stream. Anything 106 /// the callback leaves unread is drained by the session, so 107 /// returning early is fine. `Options.max_message_size` is not 108 /// enforced in this mode; individual message lines must fit the 109 /// session's stream reader buffer. 110 messageReader: ?*const fn (context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision = null, 111 }; 112}; 113 114pub fn init(reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options) Server { 115 return .{ .reader = reader, .writer = writer, .handler = handler, .options = options }; 116} 117 118pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory, TlsHandshakeFailed }; 119 120/// Serves the session until the client sends QUIT or disconnects. `gpa` 121/// backs per-transaction storage (envelope and message data); everything is 122/// freed on return. 123pub fn run(s: *Server, gpa: std.mem.Allocator) RunError!void { 124 var arena_state: std.heap.ArenaAllocator = .init(gpa); 125 defer arena_state.deinit(); 126 const arena = arena_state.allocator(); 127 128 std.debug.assert(!s.options.require_auth or s.handler.vtable.authenticate != null); 129 std.debug.assert((s.handler.vtable.message == null) != (s.handler.vtable.messageReader == null)); 130 131 var greeted = false; 132 var authenticated = false; 133 var from: ?[]const u8 = null; 134 var recipients: std.ArrayList([]const u8) = .empty; 135 var declared_size: ?u64 = null; 136 var body: Envelope.Body = .unspecified; 137 138 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname}); 139 try s.writer.flush(); 140 141 while (true) { 142 const line = protocol.readLine(s.reader) catch |err| switch (err) { 143 error.EndOfStream => return, // Client disconnected. 144 error.ReadFailed => return error.ReadFailed, 145 error.LineTooLong => { 146 try s.discardLine(); 147 try s.reply(500, "5.5.2 Line too long"); 148 continue; 149 }, 150 }; 151 const command = protocol.Command.parse(line) catch { 152 try s.reply(501, "5.5.4 Syntax error in parameters"); 153 continue; 154 }; 155 switch (command) { 156 .helo => { 157 greeted = true; 158 from = null; 159 recipients = .empty; 160 declared_size = null; 161 body = .unspecified; 162 _ = arena_state.reset(.retain_capacity); 163 try s.reply(250, s.options.hostname); 164 }, 165 .ehlo => { 166 greeted = true; 167 from = null; 168 recipients = .empty; 169 declared_size = null; 170 body = .unspecified; 171 _ = arena_state.reset(.retain_capacity); 172 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n", .{s.options.hostname}); 173 if (s.options.starttls != null and !s.secured) 174 try s.writer.writeAll("250-STARTTLS\r\n"); 175 if (s.handler.vtable.authenticate != null and !authenticated) 176 try s.writer.writeAll("250-AUTH PLAIN LOGIN\r\n"); 177 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); 178 try s.writer.flush(); 179 }, 180 .mail => |args| { 181 if (!greeted) { 182 try s.reply(503, "5.5.1 Send EHLO first"); 183 continue; 184 } 185 if (s.options.require_auth and !authenticated) { 186 try s.reply(530, "5.7.0 Authentication required"); 187 continue; 188 } 189 if (from != null) { 190 try s.reply(503, "5.5.1 Nested MAIL command"); 191 continue; 192 } 193 var mail_declared_size: ?u64 = null; 194 var mail_body: Envelope.Body = .unspecified; 195 var params_ok = true; 196 var params = args.paramIterator(); 197 while (params.next()) |param| { 198 if (std.ascii.eqlIgnoreCase(param.keyword, "SIZE")) { 199 const size = std.fmt.parseInt(u64, param.value, 10) catch { 200 try s.reply(501, "5.5.2 Invalid SIZE parameter"); 201 params_ok = false; 202 break; 203 }; 204 if (size > s.options.max_message_size) { 205 try s.reply(552, "5.3.4 Message size exceeds fixed maximum"); 206 params_ok = false; 207 break; 208 } 209 mail_declared_size = size; 210 } else if (std.ascii.eqlIgnoreCase(param.keyword, "BODY")) { 211 if (std.ascii.eqlIgnoreCase(param.value, "7BIT")) { 212 mail_body = .seven_bit; 213 } else if (std.ascii.eqlIgnoreCase(param.value, "8BITMIME")) { 214 mail_body = .eight_bit_mime; 215 } else { 216 try s.reply(555, "5.5.4 Unsupported BODY value"); 217 params_ok = false; 218 break; 219 } 220 } else { 221 try s.reply(555, "5.5.4 Unrecognized parameter"); 222 params_ok = false; 223 break; 224 } 225 } 226 if (!params_ok) continue; 227 if (s.handler.vtable.mailFrom) |callback| { 228 switch (callback(s.handler.context, args.path)) { 229 .accept => {}, 230 .reject => |r| { 231 try s.reply(r.code, r.text); 232 continue; 233 }, 234 } 235 } 236 from = try arena.dupe(u8, args.path); 237 declared_size = mail_declared_size; 238 body = mail_body; 239 try s.reply(250, "2.1.0 Ok"); 240 }, 241 .rcpt => |args| { 242 if (from == null) { 243 try s.reply(503, "5.5.1 Need MAIL command first"); 244 continue; 245 } 246 if (args.params.len != 0) { 247 try s.reply(555, "5.5.4 Unrecognized parameter"); 248 continue; 249 } 250 if (recipients.items.len >= s.options.max_recipients) { 251 try s.reply(452, "4.5.3 Too many recipients"); 252 continue; 253 } 254 if (s.handler.vtable.rcptTo) |callback| { 255 switch (callback(s.handler.context, args.path)) { 256 .accept => {}, 257 .reject => |r| { 258 try s.reply(r.code, r.text); 259 continue; 260 }, 261 } 262 } 263 try recipients.append(arena, try arena.dupe(u8, args.path)); 264 try s.reply(250, "2.1.5 Ok"); 265 }, 266 .data => { 267 if (recipients.items.len == 0) { 268 try s.reply(503, "5.5.1 Need RCPT command first"); 269 continue; 270 } 271 try s.receiveData(arena, .{ 272 .from = from.?, 273 .recipients = recipients.items, 274 .declared_size = declared_size, 275 .body = body, 276 }); 277 from = null; 278 recipients = .empty; 279 declared_size = null; 280 body = .unspecified; 281 _ = arena_state.reset(.retain_capacity); 282 }, 283 .rset => { 284 from = null; 285 recipients = .empty; 286 declared_size = null; 287 body = .unspecified; 288 _ = arena_state.reset(.retain_capacity); 289 try s.reply(250, "2.0.0 Ok"); 290 }, 291 .noop => try s.reply(250, "2.0.0 Ok"), 292 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"), 293 .help => try s.reply(214, "2.0.0 See RFC 5321"), 294 .starttls => { 295 const config = s.options.starttls orelse { 296 try s.reply(502, "5.5.1 STARTTLS not supported"); 297 continue; 298 }; 299 if (s.secured) { 300 try s.reply(503, "5.5.1 TLS already active"); 301 continue; 302 } 303 try s.reply(220, "2.0.0 Ready to start TLS"); 304 var rng_source: std.Random.IoSource = .{ .io = config.io }; 305 s.tls_connection = tls.server(s.reader, s.writer, .{ 306 .auth = config.auth, 307 .rng = rng_source.interface(), 308 .now = Io.Clock.real.now(config.io), 309 }) catch return error.TlsHandshakeFailed; 310 s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer); 311 s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer); 312 s.reader = &s.tls_reader.interface; 313 s.writer = &s.tls_writer.interface; 314 s.secured = true; 315 // RFC 3207 §4.2: both sides return to their initial state; 316 // the client must EHLO again. 317 greeted = false; 318 authenticated = false; 319 from = null; 320 recipients = .empty; 321 declared_size = null; 322 body = .unspecified; 323 _ = arena_state.reset(.retain_capacity); 324 }, 325 .quit => { 326 try s.reply(221, "2.0.0 Bye"); 327 if (s.secured) s.tls_connection.close() catch {}; 328 return; 329 }, 330 .auth => |args| { 331 if (s.handler.vtable.authenticate == null) { 332 try s.reply(503, "5.5.1 Authentication not enabled"); 333 continue; 334 } 335 if (!greeted) { 336 try s.reply(503, "5.5.1 Send EHLO first"); 337 continue; 338 } 339 if (authenticated) { 340 try s.reply(503, "5.5.1 Already authenticated"); 341 continue; 342 } 343 if (from != null) { 344 try s.reply(503, "5.5.1 MAIL transaction in progress"); 345 continue; 346 } 347 switch (try s.receiveAuth(args)) { 348 .authenticated => authenticated = true, 349 .rejected => {}, 350 .disconnected => return, 351 } 352 }, 353 .unknown => try s.reply(500, "5.5.2 Command not recognized"), 354 } 355 } 356} 357 358const AuthOutcome = enum { authenticated, rejected, disconnected }; 359 360/// Runs the challenge/response exchange for AUTH PLAIN or AUTH LOGIN 361/// (RFC 4954) and consults the handler's `authenticate` callback. Every 362/// outcome except `disconnected` has already sent its reply. 363fn receiveAuth(s: *Server, args: protocol.Command.AuthArgs) RunError!AuthOutcome { 364 const callback = s.handler.vtable.authenticate.?; 365 366 if (std.ascii.eqlIgnoreCase(args.mechanism, "PLAIN")) { 367 var decoded_buf: [576]u8 = undefined; 368 var response: []const u8 = args.initial; 369 if (response.len == 0) { 370 try s.reply(334, ""); 371 response = switch (try s.takeAuthLine()) { 372 .line => |line| line, 373 .cancelled => return .rejected, 374 .disconnected => return .disconnected, 375 }; 376 } 377 const decoded = decodeBase64(&decoded_buf, response) orelse { 378 try s.reply(501, "5.5.2 Invalid base64"); 379 return .rejected; 380 }; 381 // authzid NUL authcid NUL password; the authzid is ignored. 382 const first_nul = std.mem.indexOfScalar(u8, decoded, 0) orelse { 383 try s.reply(501, "5.5.2 Malformed PLAIN response"); 384 return .rejected; 385 }; 386 const after_authzid = decoded[first_nul + 1 ..]; 387 const second_nul = std.mem.indexOfScalar(u8, after_authzid, 0) orelse { 388 try s.reply(501, "5.5.2 Malformed PLAIN response"); 389 return .rejected; 390 }; 391 return s.finishAuth(callback, after_authzid[0..second_nul], after_authzid[second_nul + 1 ..]); 392 } 393 394 if (std.ascii.eqlIgnoreCase(args.mechanism, "LOGIN")) { 395 var user_buf: [192]u8 = undefined; 396 var pass_buf: [192]u8 = undefined; 397 398 var username: []const u8 = undefined; 399 if (args.initial.len > 0) { 400 // Some clients send the username as an initial response. 401 username = decodeBase64(&user_buf, args.initial) orelse { 402 try s.reply(501, "5.5.2 Invalid base64"); 403 return .rejected; 404 }; 405 } else { 406 try s.reply(334, "VXNlcm5hbWU6"); // base64("Username:") 407 const line = switch (try s.takeAuthLine()) { 408 .line => |line| line, 409 .cancelled => return .rejected, 410 .disconnected => return .disconnected, 411 }; 412 username = decodeBase64(&user_buf, line) orelse { 413 try s.reply(501, "5.5.2 Invalid base64"); 414 return .rejected; 415 }; 416 } 417 try s.reply(334, "UGFzc3dvcmQ6"); // base64("Password:") 418 const line = switch (try s.takeAuthLine()) { 419 .line => |line| line, 420 .cancelled => return .rejected, 421 .disconnected => return .disconnected, 422 }; 423 const password = decodeBase64(&pass_buf, line) orelse { 424 try s.reply(501, "5.5.2 Invalid base64"); 425 return .rejected; 426 }; 427 return s.finishAuth(callback, username, password); 428 } 429 430 try s.reply(504, "5.5.4 Unrecognized authentication type"); 431 return .rejected; 432} 433 434fn finishAuth( 435 s: *Server, 436 callback: *const fn (?*anyopaque, []const u8, []const u8) bool, 437 username: []const u8, 438 password: []const u8, 439) RunError!AuthOutcome { 440 if (callback(s.handler.context, username, password)) { 441 try s.reply(235, "2.7.0 Authentication successful"); 442 return .authenticated; 443 } 444 try s.reply(535, "5.7.8 Authentication credentials invalid"); 445 return .rejected; 446} 447 448const AuthLine = union(enum) { line: []u8, cancelled, disconnected }; 449 450/// Reads one continuation line of an AUTH exchange. `cancelled` covers both 451/// an explicit "*" and an overlong line; its reply has already been sent. 452fn takeAuthLine(s: *Server) RunError!AuthLine { 453 const line = protocol.readLine(s.reader) catch |err| switch (err) { 454 error.EndOfStream => return .disconnected, 455 error.ReadFailed => return error.ReadFailed, 456 error.LineTooLong => { 457 try s.discardLine(); 458 try s.reply(501, "5.5.2 Response too long"); 459 return .cancelled; 460 }, 461 }; 462 if (std.mem.eql(u8, line, "*")) { 463 try s.reply(501, "5.7.0 Authentication cancelled"); 464 return .cancelled; 465 } 466 return .{ .line = line }; 467} 468 469/// Decodes a base64 AUTH argument; "=" denotes an empty response. 470fn decodeBase64(out: []u8, encoded: []const u8) ?[]u8 { 471 if (std.mem.eql(u8, encoded, "=")) return out[0..0]; 472 const len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return null; 473 if (len > out.len) return null; 474 std.base64.standard.Decoder.decode(out[0..len], encoded) catch return null; 475 return out[0..len]; 476} 477 478/// Reads message content after DATA up to the terminating ".\r\n", 479/// un-stuffing dots, then asks the handler to accept or reject. 480fn receiveData(s: *Server, arena: std.mem.Allocator, envelope: Envelope) RunError!void { 481 try s.reply(354, "End data with <CR><LF>.<CR><LF>"); 482 483 if (s.handler.vtable.messageReader) |callback| { 484 var buffer: [1024]u8 = undefined; 485 var data_reader: DataReader = .{ 486 .session_reader = s.reader, 487 .interface = .{ 488 .buffer = &buffer, 489 .vtable = &.{ .stream = DataReader.stream }, 490 .seek = 0, 491 .end = 0, 492 }, 493 }; 494 const decision = callback(s.handler.context, envelope, &data_reader.interface); 495 // Consume whatever the callback left unread, up to and including 496 // the terminating ".". 497 while (!data_reader.finished) { 498 const line = protocol.readLine(s.reader) catch |err| switch (err) { 499 error.EndOfStream => return, // Client disconnected mid-message. 500 error.ReadFailed => return error.ReadFailed, 501 error.LineTooLong => { 502 try s.discardLine(); 503 continue; 504 }, 505 }; 506 if (std.mem.eql(u8, line, ".")) break; 507 } 508 switch (decision) { 509 .accept => try s.reply(250, "2.0.0 Ok, message accepted"), 510 .reject => |r| try s.reply(r.code, r.text), 511 } 512 return; 513 } 514 515 var data: std.ArrayList(u8) = .empty; 516 var oversize = false; 517 while (true) { 518 const line = protocol.readLine(s.reader) catch |err| switch (err) { 519 error.EndOfStream => return, // Client disconnected mid-message. 520 error.ReadFailed => return error.ReadFailed, 521 error.LineTooLong => { 522 // Longer than our reader buffer; RFC 5321 caps text lines at 523 // 1000 octets, so treat it as oversize but keep scanning for 524 // the terminator. 525 try s.discardLine(); 526 oversize = true; 527 continue; 528 }, 529 }; 530 if (std.mem.eql(u8, line, ".")) break; 531 const content = if (line.len > 0 and line[0] == '.') line[1..] else line; 532 if (oversize) continue; 533 if (data.items.len + content.len + protocol.crlf.len > s.options.max_message_size) { 534 oversize = true; 535 continue; 536 } 537 try data.appendSlice(arena, content); 538 try data.appendSlice(arena, protocol.crlf); 539 } 540 if (oversize) { 541 try s.reply(552, "5.3.4 Message exceeds maximum size"); 542 return; 543 } 544 switch (s.handler.vtable.message.?(s.handler.context, envelope, data.items)) { 545 .accept => try s.reply(250, "2.0.0 Ok, message accepted"), 546 .reject => |r| try s.reply(r.code, r.text), 547 } 548} 549 550/// Adapts the session's line-based DATA phase into an `Io.Reader` of the 551/// unstuffed message content for `Handler.VTable.messageReader`. 552const DataReader = struct { 553 session_reader: *Io.Reader, 554 interface: Io.Reader, 555 /// Unread remainder of the current line (points into the session 556 /// reader's buffer, which only this reader touches during DATA). 557 line: []const u8 = &.{}, 558 line_ending: []const u8 = &.{}, 559 finished: bool = false, 560 561 fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize { 562 const dr: *DataReader = @alignCast(@fieldParentPtr("interface", io_r)); 563 if (dr.line.len == 0 and dr.line_ending.len == 0) { 564 if (dr.finished) return error.EndOfStream; 565 const raw = protocol.readLine(dr.session_reader) catch return error.ReadFailed; 566 if (std.mem.eql(u8, raw, ".")) { 567 dr.finished = true; 568 return error.EndOfStream; 569 } 570 dr.line = if (raw.len > 0 and raw[0] == '.') raw[1..] else raw; 571 dr.line_ending = protocol.crlf; 572 } 573 const dest = limit.slice(try w.writableSliceGreedy(1)); 574 const line_n = @min(dest.len, dr.line.len); 575 @memcpy(dest[0..line_n], dr.line[0..line_n]); 576 dr.line = dr.line[line_n..]; 577 var n = line_n; 578 if (dr.line.len == 0) { 579 const ending_n = @min(dest.len - n, dr.line_ending.len); 580 @memcpy(dest[n..][0..ending_n], dr.line_ending[0..ending_n]); 581 dr.line_ending = dr.line_ending[ending_n..]; 582 n += ending_n; 583 } 584 w.advance(n); 585 return n; 586 } 587}; 588 589fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 590 try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text }); 591 try s.writer.flush(); 592} 593 594/// Discards input through the next newline after `error.LineTooLong`, which 595/// leaves the reader positioned at the start of the oversized line. 596fn discardLine(s: *Server) error{ReadFailed}!void { 597 _ = s.reader.discardDelimiterInclusive('\n') catch |err| switch (err) { 598 error.EndOfStream => {}, 599 error.ReadFailed => return error.ReadFailed, 600 }; 601} 602 603const TestHandler = struct { 604 from: std.ArrayList(u8) = .empty, 605 recipients: std.ArrayList(u8) = .empty, 606 data: std.ArrayList(u8) = .empty, 607 messages_accepted: usize = 0, 608 reject_recipient: ?[]const u8 = null, 609 declared_size: ?u64 = null, 610 body: Envelope.Body = .unspecified, 611 /// When set, enables the authenticate callback accepting user "alice" 612 /// with this password. 613 password: ?[]const u8 = null, 614 615 fn deinit(h: *TestHandler) void { 616 h.from.deinit(std.testing.allocator); 617 h.recipients.deinit(std.testing.allocator); 618 h.data.deinit(std.testing.allocator); 619 } 620 621 fn handler(h: *TestHandler) Handler { 622 return .{ .context = h, .vtable = if (h.password != null) &.{ 623 .authenticate = onAuthenticate, 624 .rcptTo = onRcptTo, 625 .message = onMessage, 626 } else &.{ 627 .rcptTo = onRcptTo, 628 .message = onMessage, 629 } }; 630 } 631 632 fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool { 633 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 634 return std.mem.eql(u8, username, "alice") and 635 std.mem.eql(u8, password, h.password.?); 636 } 637 638 fn onRcptTo(context: ?*anyopaque, to: []const u8) Decision { 639 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 640 if (h.reject_recipient) |rejected| { 641 if (std.mem.eql(u8, to, rejected)) return .{ .reject = .{ 642 .code = 550, 643 .text = "5.1.1 No such user", 644 } }; 645 } 646 return .accept; 647 } 648 649 fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision { 650 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 651 const gpa = std.testing.allocator; 652 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} }; 653 for (envelope.recipients) |recipient| { 654 h.recipients.appendSlice(gpa, recipient) catch return .{ .reject = .{} }; 655 h.recipients.append(gpa, ';') catch return .{ .reject = .{} }; 656 } 657 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} }; 658 h.messages_accepted += 1; 659 h.declared_size = envelope.declared_size; 660 h.body = envelope.body; 661 return .accept; 662 } 663}; 664 665fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 { 666 var reader: Io.Reader = .fixed(input); 667 var writer: Io.Writer = .fixed(out_buf); 668 var session: Server = .init(&reader, &writer, handler, options); 669 try session.run(std.testing.allocator); 670 return writer.buffered(); 671} 672 673test run { 674 var h: TestHandler = .{}; 675 defer h.deinit(); 676 677 var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ 678 "MAIL FROM:<alice@example.com>\r\n" ++ 679 "RCPT TO:<bob@example.net>\r\n" ++ 680 "RCPT TO:<carol@example.net>\r\n" ++ 681 "DATA\r\n" ++ 682 "Subject: hi\r\n" ++ 683 "\r\n" ++ 684 "..stuffed line\r\n" ++ 685 "body\r\n" ++ 686 ".\r\n" ++ 687 "QUIT\r\n"); 688 var out_buf: [1024]u8 = undefined; 689 var writer: Io.Writer = .fixed(&out_buf); 690 691 var session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" }); 692 try session.run(std.testing.allocator); 693 const output = writer.buffered(); 694 695 try std.testing.expectEqualStrings("alice@example.com", h.from.items); 696 try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items); 697 try std.testing.expectEqualStrings("Subject: hi\r\n\r\n.stuffed line\r\nbody\r\n", h.data.items); 698 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 699 700 try std.testing.expectEqualStrings( 701 "220 mx.test ESMTP ready\r\n" ++ 702 "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 16777216\r\n" ++ 703 "250 2.1.0 Ok\r\n" ++ 704 "250 2.1.5 Ok\r\n" ++ 705 "250 2.1.5 Ok\r\n" ++ 706 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 707 "250 2.0.0 Ok, message accepted\r\n" ++ 708 "221 2.0.0 Bye\r\n", 709 output, 710 ); 711} 712 713test "command sequencing is enforced" { 714 var h: TestHandler = .{}; 715 defer h.deinit(); 716 717 var out_buf: [1024]u8 = undefined; 718 const output = try runScript( 719 "MAIL FROM:<early@example.com>\r\n" ++ 720 "EHLO client.example.org\r\n" ++ 721 "RCPT TO:<bob@example.net>\r\n" ++ 722 "DATA\r\n" ++ 723 "QUIT\r\n", 724 &out_buf, 725 h.handler(), 726 .{}, 727 ); 728 729 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 730 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Send EHLO first") != null); 731 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null); 732 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null); 733} 734 735test "handler can reject a recipient" { 736 var h: TestHandler = .{ .reject_recipient = "nobody@example.net" }; 737 defer h.deinit(); 738 739 var out_buf: [1024]u8 = undefined; 740 const output = try runScript( 741 "EHLO client.example.org\r\n" ++ 742 "MAIL FROM:<alice@example.com>\r\n" ++ 743 "RCPT TO:<nobody@example.net>\r\n" ++ 744 "RCPT TO:<bob@example.net>\r\n" ++ 745 "DATA\r\n" ++ 746 "hello\r\n" ++ 747 ".\r\n" ++ 748 "QUIT\r\n", 749 &out_buf, 750 h.handler(), 751 .{}, 752 ); 753 754 try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null); 755 try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items); 756 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 757} 758 759test "AUTH PLAIN with initial response" { 760 var h: TestHandler = .{ .password = "secret" }; 761 defer h.deinit(); 762 763 var out_buf: [1024]u8 = undefined; 764 // base64("\x00alice\x00secret") 765 const output = try runScript( 766 "EHLO client.example.org\r\n" ++ 767 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ 768 "MAIL FROM:<alice@example.com>\r\n" ++ 769 "RCPT TO:<bob@example.net>\r\n" ++ 770 "DATA\r\nauthed mail\r\n.\r\n" ++ 771 "QUIT\r\n", 772 &out_buf, 773 h.handler(), 774 .{ .require_auth = true }, 775 ); 776 777 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); 778 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 779 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 780} 781 782test "AUTH LOGIN challenge exchange" { 783 var h: TestHandler = .{ .password = "secret" }; 784 defer h.deinit(); 785 786 var out_buf: [1024]u8 = undefined; 787 // base64("alice"), base64("secret") 788 const output = try runScript( 789 "EHLO client.example.org\r\n" ++ 790 "AUTH LOGIN\r\n" ++ 791 "YWxpY2U=\r\n" ++ 792 "c2VjcmV0\r\n" ++ 793 "QUIT\r\n", 794 &out_buf, 795 h.handler(), 796 .{}, 797 ); 798 799 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null); 800 try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null); 801 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 802} 803 804test "AUTH failures and sequencing" { 805 var h: TestHandler = .{ .password = "secret" }; 806 defer h.deinit(); 807 808 var out_buf: [2048]u8 = undefined; 809 const output = try runScript( 810 "EHLO client.example.org\r\n" ++ 811 "MAIL FROM:<alice@example.com>\r\n" ++ // before auth: 530 812 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // wrong password: 535 813 "AUTH GSSAPI\r\n" ++ // unsupported: 504 814 "AUTH PLAIN not!base64\r\n" ++ // 501 815 "AUTH LOGIN\r\n" ++ 816 "*\r\n" ++ // cancelled: 501 817 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // correct: 235 818 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // again: 503 819 "QUIT\r\n", 820 &out_buf, 821 h.handler(), 822 .{ .require_auth = true }, 823 ); 824 825 try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null); 826 try std.testing.expect(std.mem.indexOf(u8, output, "535 5.7.8") != null); 827 try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null); 828 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid base64") != null); 829 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.7.0 Authentication cancelled") != null); 830 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 831 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Already authenticated") != null); 832} 833 834test "AUTH without a handler is refused" { 835 var h: TestHandler = .{}; 836 defer h.deinit(); 837 838 var out_buf: [1024]u8 = undefined; 839 const output = try runScript( 840 "EHLO client.example.org\r\nAUTH PLAIN AGEAYg==\r\nQUIT\r\n", 841 &out_buf, 842 h.handler(), 843 .{}, 844 ); 845 846 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH") == null); 847 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null); 848} 849 850test "oversize message is rejected but session continues" { 851 var h: TestHandler = .{}; 852 defer h.deinit(); 853 854 var out_buf: [1024]u8 = undefined; 855 const output = try runScript( 856 "EHLO client.example.org\r\n" ++ 857 "MAIL FROM:<alice@example.com>\r\n" ++ 858 "RCPT TO:<bob@example.net>\r\n" ++ 859 "DATA\r\n" ++ 860 "0123456789012345678901234567890123456789\r\n" ++ 861 ".\r\n" ++ 862 "NOOP\r\n" ++ 863 "QUIT\r\n", 864 &out_buf, 865 h.handler(), 866 .{ .max_message_size = 16 }, 867 ); 868 869 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 870 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 871 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 872} 873 874const StreamTestHandler = struct { 875 collected: std.ArrayList(u8) = .empty, 876 take_only: ?usize = null, 877 878 fn handler(h: *StreamTestHandler) Handler { 879 return .{ .context = h, .vtable = &.{ 880 .messageReader = onMessageReader, 881 } }; 882 } 883 884 fn onMessageReader(context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision { 885 const h: *StreamTestHandler = @ptrCast(@alignCast(context.?)); 886 _ = envelope; 887 const gpa = std.testing.allocator; 888 if (h.take_only) |n| { 889 const bytes = message.take(n) catch return .{ .reject = .{} }; 890 h.collected.appendSlice(gpa, bytes) catch return .{ .reject = .{} }; 891 return .accept; 892 } 893 message.appendRemaining(gpa, &h.collected, .unlimited) catch return .{ .reject = .{} }; 894 return .accept; 895 } 896}; 897 898test "streaming message handler receives unstuffed content" { 899 var h: StreamTestHandler = .{}; 900 defer h.collected.deinit(std.testing.allocator); 901 902 var out_buf: [1024]u8 = undefined; 903 const output = try runScript( 904 "EHLO client.example.org\r\n" ++ 905 "MAIL FROM:<alice@example.com>\r\n" ++ 906 "RCPT TO:<bob@example.net>\r\n" ++ 907 "DATA\r\n" ++ 908 "Subject: streamed\r\n" ++ 909 "\r\n" ++ 910 "..dot line\r\n" ++ 911 "body\r\n" ++ 912 ".\r\n" ++ 913 "QUIT\r\n", 914 &out_buf, 915 h.handler(), 916 .{}, 917 ); 918 919 try std.testing.expectEqualStrings( 920 "Subject: streamed\r\n\r\n.dot line\r\nbody\r\n", 921 h.collected.items, 922 ); 923 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 924} 925 926test "session drains what a streaming handler leaves unread" { 927 var h: StreamTestHandler = .{ .take_only = 7 }; 928 defer h.collected.deinit(std.testing.allocator); 929 930 var out_buf: [1024]u8 = undefined; 931 const output = try runScript( 932 "EHLO client.example.org\r\n" ++ 933 "MAIL FROM:<alice@example.com>\r\n" ++ 934 "RCPT TO:<bob@example.net>\r\n" ++ 935 "DATA\r\n" ++ 936 "Subject: mostly unread\r\n" ++ 937 "lots of body\r\n" ++ 938 ".\r\n" ++ 939 "NOOP\r\n" ++ 940 "QUIT\r\n", 941 &out_buf, 942 h.handler(), 943 .{}, 944 ); 945 946 try std.testing.expectEqualStrings("Subject", h.collected.items); 947 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 948 // The NOOP after DATA proves the terminator was consumed. 949 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 950} 951 952test "fuzz session with arbitrary client input" { 953 try std.testing.fuzz({}, fuzzSession, .{}); 954} 955 956fn fuzzSession(context: void, smith: *std.testing.Smith) !void { 957 _ = context; 958 var input_buf: [2048]u8 = undefined; 959 const input = input_buf[0..smith.value(u11)]; 960 smith.bytes(input); 961 962 var h: TestHandler = .{ .password = "secret" }; 963 defer h.deinit(); 964 965 var reader: Io.Reader = .fixed(input); 966 var discarding: Io.Writer.Discarding = .init(&.{}); 967 var session: Server = .init(&reader, &discarding.writer, h.handler(), .{ 968 .max_message_size = 512, 969 .max_recipients = 4, 970 }); 971 // Whatever the "client" sends, the session must fail cleanly, never crash. 972 session.run(std.testing.allocator) catch {}; 973} 974 975test "fuzz collecting and streaming DATA agree" { 976 try std.testing.fuzz({}, fuzzDataEquivalence, .{}); 977} 978 979fn fuzzDataEquivalence(context: void, smith: *std.testing.Smith) !void { 980 _ = context; 981 var body_buf: [1024]u8 = undefined; 982 const body = body_buf[0..smith.value(u10)]; 983 smith.bytes(body); 984 985 var script_buf: [1200]u8 = undefined; 986 const script = std.fmt.bufPrint( 987 &script_buf, 988 "EHLO fuzz.example.org\r\n" ++ 989 "MAIL FROM:<a@example.com>\r\n" ++ 990 "RCPT TO:<b@example.net>\r\n" ++ 991 "DATA\r\n{s}\r\n.\r\nQUIT\r\n", 992 .{body}, 993 ) catch unreachable; 994 995 var collecting: TestHandler = .{}; 996 defer collecting.deinit(); 997 var out_buf: [4096]u8 = undefined; 998 _ = runScript(script, &out_buf, collecting.handler(), .{}) catch {}; 999 1000 var streaming: StreamTestHandler = .{}; 1001 defer streaming.collected.deinit(std.testing.allocator); 1002 _ = runScript(script, &out_buf, streaming.handler(), .{}) catch {}; 1003 1004 try std.testing.expectEqualSlices(u8, collecting.data.items, streaming.collected.items); 1005} 1006 1007test "MAIL parameters SIZE and BODY are honored" { 1008 var h: TestHandler = .{}; 1009 defer h.deinit(); 1010 1011 var out_buf: [1024]u8 = undefined; 1012 const output = try runScript( 1013 "EHLO client.example.org\r\n" ++ 1014 "MAIL FROM:<alice@example.com> SIZE=42 BODY=8BITMIME\r\n" ++ 1015 "RCPT TO:<bob@example.net>\r\n" ++ 1016 "DATA\r\nsized body\r\n.\r\n" ++ 1017 "QUIT\r\n", 1018 &out_buf, 1019 h.handler(), 1020 .{ .max_message_size = 1024 }, 1021 ); 1022 1023 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1024 try std.testing.expectEqual(@as(?u64, 42), h.declared_size); 1025 try std.testing.expectEqual(Envelope.Body.eight_bit_mime, h.body); 1026 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null); 1027} 1028 1029test "invalid MAIL and RCPT parameters are rejected" { 1030 var h: TestHandler = .{}; 1031 defer h.deinit(); 1032 1033 var out_buf: [2048]u8 = undefined; 1034 const output = try runScript( 1035 "EHLO client.example.org\r\n" ++ 1036 "MAIL FROM:<a@example.com> SIZE=9999\r\n" ++ // over the maximum: 552 1037 "RCPT TO:<b@example.net>\r\n" ++ // that MAIL never started: 503 1038 "MAIL FROM:<a@example.com> SIZE=banana\r\n" ++ // 501 1039 "MAIL FROM:<a@example.com> BODY=BINARYMIME\r\n" ++ // 555 1040 "MAIL FROM:<a@example.com> FUTURE=yes\r\n" ++ // 555 1041 "MAIL FROM:<a@example.com> BODY=7bit\r\n" ++ // ok 1042 "RCPT TO:<b@example.net> NOTIFY=SUCCESS\r\n" ++ // no RCPT params: 555 1043 "RCPT TO:<b@example.net>\r\n" ++ 1044 "DATA\r\nbody\r\n.\r\nQUIT\r\n", 1045 &out_buf, 1046 h.handler(), 1047 .{ .max_message_size = 1024 }, 1048 ); 1049 1050 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 1051 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null); 1052 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid SIZE parameter") != null); 1053 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unsupported BODY value") != null); 1054 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); 1055 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1056 try std.testing.expectEqual(Envelope.Body.seven_bit, h.body); 1057 try std.testing.expectEqual(@as(?u64, null), h.declared_size); 1058}