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