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