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
28 kB 725 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. 94 message: *const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision, 95 }; 96}; 97 98pub fn init(reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options) Server { 99 return .{ .reader = reader, .writer = writer, .handler = handler, .options = options }; 100} 101 102pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory, TlsHandshakeFailed }; 103 104/// Serves the session until the client sends QUIT or disconnects. `gpa` 105/// backs per-transaction storage (envelope and message data); everything is 106/// freed on return. 107pub fn run(s: *Server, gpa: std.mem.Allocator) RunError!void { 108 var arena_state: std.heap.ArenaAllocator = .init(gpa); 109 defer arena_state.deinit(); 110 const arena = arena_state.allocator(); 111 112 std.debug.assert(!s.options.require_auth or s.handler.vtable.authenticate != null); 113 114 var greeted = false; 115 var authenticated = false; 116 var from: ?[]const u8 = null; 117 var recipients: std.ArrayList([]const u8) = .empty; 118 119 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname}); 120 try s.writer.flush(); 121 122 while (true) { 123 const line = protocol.readLine(s.reader) catch |err| switch (err) { 124 error.EndOfStream => return, // Client disconnected. 125 error.ReadFailed => return error.ReadFailed, 126 error.LineTooLong => { 127 try s.discardLine(); 128 try s.reply(500, "5.5.2 Line too long"); 129 continue; 130 }, 131 }; 132 const command = protocol.Command.parse(line) catch { 133 try s.reply(501, "5.5.4 Syntax error in parameters"); 134 continue; 135 }; 136 switch (command) { 137 .helo => { 138 greeted = true; 139 from = null; 140 recipients = .empty; 141 _ = arena_state.reset(.retain_capacity); 142 try s.reply(250, s.options.hostname); 143 }, 144 .ehlo => { 145 greeted = true; 146 from = null; 147 recipients = .empty; 148 _ = arena_state.reset(.retain_capacity); 149 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n", .{s.options.hostname}); 150 if (s.options.starttls != null and !s.secured) 151 try s.writer.writeAll("250-STARTTLS\r\n"); 152 if (s.handler.vtable.authenticate != null and !authenticated) 153 try s.writer.writeAll("250-AUTH PLAIN LOGIN\r\n"); 154 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); 155 try s.writer.flush(); 156 }, 157 .mail => |args| { 158 if (!greeted) { 159 try s.reply(503, "5.5.1 Send EHLO first"); 160 continue; 161 } 162 if (s.options.require_auth and !authenticated) { 163 try s.reply(530, "5.7.0 Authentication required"); 164 continue; 165 } 166 if (from != null) { 167 try s.reply(503, "5.5.1 Nested MAIL command"); 168 continue; 169 } 170 if (s.handler.vtable.mailFrom) |callback| { 171 switch (callback(s.handler.context, args.path)) { 172 .accept => {}, 173 .reject => |r| { 174 try s.reply(r.code, r.text); 175 continue; 176 }, 177 } 178 } 179 from = try arena.dupe(u8, args.path); 180 try s.reply(250, "2.1.0 Ok"); 181 }, 182 .rcpt => |args| { 183 if (from == null) { 184 try s.reply(503, "5.5.1 Need MAIL command first"); 185 continue; 186 } 187 if (recipients.items.len >= s.options.max_recipients) { 188 try s.reply(452, "4.5.3 Too many recipients"); 189 continue; 190 } 191 if (s.handler.vtable.rcptTo) |callback| { 192 switch (callback(s.handler.context, args.path)) { 193 .accept => {}, 194 .reject => |r| { 195 try s.reply(r.code, r.text); 196 continue; 197 }, 198 } 199 } 200 try recipients.append(arena, try arena.dupe(u8, args.path)); 201 try s.reply(250, "2.1.5 Ok"); 202 }, 203 .data => { 204 if (recipients.items.len == 0) { 205 try s.reply(503, "5.5.1 Need RCPT command first"); 206 continue; 207 } 208 try s.receiveData(arena, .{ 209 .from = from.?, 210 .recipients = recipients.items, 211 }); 212 from = null; 213 recipients = .empty; 214 _ = arena_state.reset(.retain_capacity); 215 }, 216 .rset => { 217 from = null; 218 recipients = .empty; 219 _ = arena_state.reset(.retain_capacity); 220 try s.reply(250, "2.0.0 Ok"); 221 }, 222 .noop => try s.reply(250, "2.0.0 Ok"), 223 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"), 224 .help => try s.reply(214, "2.0.0 See RFC 5321"), 225 .starttls => { 226 const config = s.options.starttls orelse { 227 try s.reply(502, "5.5.1 STARTTLS not supported"); 228 continue; 229 }; 230 if (s.secured) { 231 try s.reply(503, "5.5.1 TLS already active"); 232 continue; 233 } 234 try s.reply(220, "2.0.0 Ready to start TLS"); 235 var rng_source: std.Random.IoSource = .{ .io = config.io }; 236 s.tls_connection = tls.server(s.reader, s.writer, .{ 237 .auth = config.auth, 238 .rng = rng_source.interface(), 239 .now = Io.Clock.real.now(config.io), 240 }) catch return error.TlsHandshakeFailed; 241 s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer); 242 s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer); 243 s.reader = &s.tls_reader.interface; 244 s.writer = &s.tls_writer.interface; 245 s.secured = true; 246 // RFC 3207 §4.2: both sides return to their initial state; 247 // the client must EHLO again. 248 greeted = false; 249 authenticated = false; 250 from = null; 251 recipients = .empty; 252 _ = arena_state.reset(.retain_capacity); 253 }, 254 .quit => { 255 try s.reply(221, "2.0.0 Bye"); 256 if (s.secured) s.tls_connection.close() catch {}; 257 return; 258 }, 259 .auth => |args| { 260 if (s.handler.vtable.authenticate == null) { 261 try s.reply(503, "5.5.1 Authentication not enabled"); 262 continue; 263 } 264 if (!greeted) { 265 try s.reply(503, "5.5.1 Send EHLO first"); 266 continue; 267 } 268 if (authenticated) { 269 try s.reply(503, "5.5.1 Already authenticated"); 270 continue; 271 } 272 if (from != null) { 273 try s.reply(503, "5.5.1 MAIL transaction in progress"); 274 continue; 275 } 276 switch (try s.receiveAuth(args)) { 277 .authenticated => authenticated = true, 278 .rejected => {}, 279 .disconnected => return, 280 } 281 }, 282 .unknown => try s.reply(500, "5.5.2 Command not recognized"), 283 } 284 } 285} 286 287const AuthOutcome = enum { authenticated, rejected, disconnected }; 288 289/// Runs the challenge/response exchange for AUTH PLAIN or AUTH LOGIN 290/// (RFC 4954) and consults the handler's `authenticate` callback. Every 291/// outcome except `disconnected` has already sent its reply. 292fn receiveAuth(s: *Server, args: protocol.Command.AuthArgs) RunError!AuthOutcome { 293 const callback = s.handler.vtable.authenticate.?; 294 295 if (std.ascii.eqlIgnoreCase(args.mechanism, "PLAIN")) { 296 var decoded_buf: [576]u8 = undefined; 297 var response: []const u8 = args.initial; 298 if (response.len == 0) { 299 try s.reply(334, ""); 300 response = switch (try s.takeAuthLine()) { 301 .line => |line| line, 302 .cancelled => return .rejected, 303 .disconnected => return .disconnected, 304 }; 305 } 306 const decoded = decodeBase64(&decoded_buf, response) orelse { 307 try s.reply(501, "5.5.2 Invalid base64"); 308 return .rejected; 309 }; 310 // authzid NUL authcid NUL password; the authzid is ignored. 311 const first_nul = std.mem.indexOfScalar(u8, decoded, 0) orelse { 312 try s.reply(501, "5.5.2 Malformed PLAIN response"); 313 return .rejected; 314 }; 315 const after_authzid = decoded[first_nul + 1 ..]; 316 const second_nul = std.mem.indexOfScalar(u8, after_authzid, 0) orelse { 317 try s.reply(501, "5.5.2 Malformed PLAIN response"); 318 return .rejected; 319 }; 320 return s.finishAuth(callback, after_authzid[0..second_nul], after_authzid[second_nul + 1 ..]); 321 } 322 323 if (std.ascii.eqlIgnoreCase(args.mechanism, "LOGIN")) { 324 var user_buf: [192]u8 = undefined; 325 var pass_buf: [192]u8 = undefined; 326 327 var username: []const u8 = undefined; 328 if (args.initial.len > 0) { 329 // Some clients send the username as an initial response. 330 username = decodeBase64(&user_buf, args.initial) orelse { 331 try s.reply(501, "5.5.2 Invalid base64"); 332 return .rejected; 333 }; 334 } else { 335 try s.reply(334, "VXNlcm5hbWU6"); // base64("Username:") 336 const line = switch (try s.takeAuthLine()) { 337 .line => |line| line, 338 .cancelled => return .rejected, 339 .disconnected => return .disconnected, 340 }; 341 username = decodeBase64(&user_buf, line) orelse { 342 try s.reply(501, "5.5.2 Invalid base64"); 343 return .rejected; 344 }; 345 } 346 try s.reply(334, "UGFzc3dvcmQ6"); // base64("Password:") 347 const line = switch (try s.takeAuthLine()) { 348 .line => |line| line, 349 .cancelled => return .rejected, 350 .disconnected => return .disconnected, 351 }; 352 const password = decodeBase64(&pass_buf, line) orelse { 353 try s.reply(501, "5.5.2 Invalid base64"); 354 return .rejected; 355 }; 356 return s.finishAuth(callback, username, password); 357 } 358 359 try s.reply(504, "5.5.4 Unrecognized authentication type"); 360 return .rejected; 361} 362 363fn finishAuth( 364 s: *Server, 365 callback: *const fn (?*anyopaque, []const u8, []const u8) bool, 366 username: []const u8, 367 password: []const u8, 368) RunError!AuthOutcome { 369 if (callback(s.handler.context, username, password)) { 370 try s.reply(235, "2.7.0 Authentication successful"); 371 return .authenticated; 372 } 373 try s.reply(535, "5.7.8 Authentication credentials invalid"); 374 return .rejected; 375} 376 377const AuthLine = union(enum) { line: []u8, cancelled, disconnected }; 378 379/// Reads one continuation line of an AUTH exchange. `cancelled` covers both 380/// an explicit "*" and an overlong line; its reply has already been sent. 381fn takeAuthLine(s: *Server) RunError!AuthLine { 382 const line = protocol.readLine(s.reader) catch |err| switch (err) { 383 error.EndOfStream => return .disconnected, 384 error.ReadFailed => return error.ReadFailed, 385 error.LineTooLong => { 386 try s.discardLine(); 387 try s.reply(501, "5.5.2 Response too long"); 388 return .cancelled; 389 }, 390 }; 391 if (std.mem.eql(u8, line, "*")) { 392 try s.reply(501, "5.7.0 Authentication cancelled"); 393 return .cancelled; 394 } 395 return .{ .line = line }; 396} 397 398/// Decodes a base64 AUTH argument; "=" denotes an empty response. 399fn decodeBase64(out: []u8, encoded: []const u8) ?[]u8 { 400 if (std.mem.eql(u8, encoded, "=")) return out[0..0]; 401 const len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return null; 402 if (len > out.len) return null; 403 std.base64.standard.Decoder.decode(out[0..len], encoded) catch return null; 404 return out[0..len]; 405} 406 407/// Reads message content after DATA up to the terminating ".\r\n", 408/// un-stuffing dots, then asks the handler to accept or reject. 409fn receiveData(s: *Server, arena: std.mem.Allocator, envelope: Envelope) RunError!void { 410 try s.reply(354, "End data with <CR><LF>.<CR><LF>"); 411 var data: std.ArrayList(u8) = .empty; 412 var oversize = false; 413 while (true) { 414 const line = protocol.readLine(s.reader) catch |err| switch (err) { 415 error.EndOfStream => return, // Client disconnected mid-message. 416 error.ReadFailed => return error.ReadFailed, 417 error.LineTooLong => { 418 // Longer than our reader buffer; RFC 5321 caps text lines at 419 // 1000 octets, so treat it as oversize but keep scanning for 420 // the terminator. 421 try s.discardLine(); 422 oversize = true; 423 continue; 424 }, 425 }; 426 if (std.mem.eql(u8, line, ".")) break; 427 const content = if (line.len > 0 and line[0] == '.') line[1..] else line; 428 if (oversize) continue; 429 if (data.items.len + content.len + protocol.crlf.len > s.options.max_message_size) { 430 oversize = true; 431 continue; 432 } 433 try data.appendSlice(arena, content); 434 try data.appendSlice(arena, protocol.crlf); 435 } 436 if (oversize) { 437 try s.reply(552, "5.3.4 Message exceeds maximum size"); 438 return; 439 } 440 switch (s.handler.vtable.message(s.handler.context, envelope, data.items)) { 441 .accept => try s.reply(250, "2.0.0 Ok, message accepted"), 442 .reject => |r| try s.reply(r.code, r.text), 443 } 444} 445 446fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 447 try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text }); 448 try s.writer.flush(); 449} 450 451/// Discards input through the next newline after `error.LineTooLong`, which 452/// leaves the reader positioned at the start of the oversized line. 453fn discardLine(s: *Server) error{ReadFailed}!void { 454 _ = s.reader.discardDelimiterInclusive('\n') catch |err| switch (err) { 455 error.EndOfStream => {}, 456 error.ReadFailed => return error.ReadFailed, 457 }; 458} 459 460const TestHandler = struct { 461 from: std.ArrayList(u8) = .empty, 462 recipients: std.ArrayList(u8) = .empty, 463 data: std.ArrayList(u8) = .empty, 464 messages_accepted: usize = 0, 465 reject_recipient: ?[]const u8 = null, 466 /// When set, enables the authenticate callback accepting user "alice" 467 /// with this password. 468 password: ?[]const u8 = null, 469 470 fn deinit(h: *TestHandler) void { 471 h.from.deinit(std.testing.allocator); 472 h.recipients.deinit(std.testing.allocator); 473 h.data.deinit(std.testing.allocator); 474 } 475 476 fn handler(h: *TestHandler) Handler { 477 return .{ .context = h, .vtable = if (h.password != null) &.{ 478 .authenticate = onAuthenticate, 479 .rcptTo = onRcptTo, 480 .message = onMessage, 481 } else &.{ 482 .rcptTo = onRcptTo, 483 .message = onMessage, 484 } }; 485 } 486 487 fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool { 488 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 489 return std.mem.eql(u8, username, "alice") and 490 std.mem.eql(u8, password, h.password.?); 491 } 492 493 fn onRcptTo(context: ?*anyopaque, to: []const u8) Decision { 494 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 495 if (h.reject_recipient) |rejected| { 496 if (std.mem.eql(u8, to, rejected)) return .{ .reject = .{ 497 .code = 550, 498 .text = "5.1.1 No such user", 499 } }; 500 } 501 return .accept; 502 } 503 504 fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision { 505 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 506 const gpa = std.testing.allocator; 507 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} }; 508 for (envelope.recipients) |recipient| { 509 h.recipients.appendSlice(gpa, recipient) catch return .{ .reject = .{} }; 510 h.recipients.append(gpa, ';') catch return .{ .reject = .{} }; 511 } 512 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} }; 513 h.messages_accepted += 1; 514 return .accept; 515 } 516}; 517 518fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 { 519 var reader: Io.Reader = .fixed(input); 520 var writer: Io.Writer = .fixed(out_buf); 521 var session: Server = .init(&reader, &writer, handler, options); 522 try session.run(std.testing.allocator); 523 return writer.buffered(); 524} 525 526test "complete session" { 527 var h: TestHandler = .{}; 528 defer h.deinit(); 529 530 var out_buf: [1024]u8 = undefined; 531 const output = try runScript( 532 "EHLO client.example.org\r\n" ++ 533 "MAIL FROM:<alice@example.com>\r\n" ++ 534 "RCPT TO:<bob@example.net>\r\n" ++ 535 "RCPT TO:<carol@example.net>\r\n" ++ 536 "DATA\r\n" ++ 537 "Subject: hi\r\n" ++ 538 "\r\n" ++ 539 "..stuffed line\r\n" ++ 540 "body\r\n" ++ 541 ".\r\n" ++ 542 "QUIT\r\n", 543 &out_buf, 544 h.handler(), 545 .{ .hostname = "mx.test" }, 546 ); 547 548 try std.testing.expectEqualStrings("alice@example.com", h.from.items); 549 try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items); 550 try std.testing.expectEqualStrings("Subject: hi\r\n\r\n.stuffed line\r\nbody\r\n", h.data.items); 551 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 552 553 try std.testing.expectEqualStrings( 554 "220 mx.test ESMTP ready\r\n" ++ 555 "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 16777216\r\n" ++ 556 "250 2.1.0 Ok\r\n" ++ 557 "250 2.1.5 Ok\r\n" ++ 558 "250 2.1.5 Ok\r\n" ++ 559 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 560 "250 2.0.0 Ok, message accepted\r\n" ++ 561 "221 2.0.0 Bye\r\n", 562 output, 563 ); 564} 565 566test "command sequencing is enforced" { 567 var h: TestHandler = .{}; 568 defer h.deinit(); 569 570 var out_buf: [1024]u8 = undefined; 571 const output = try runScript( 572 "MAIL FROM:<early@example.com>\r\n" ++ 573 "EHLO client.example.org\r\n" ++ 574 "RCPT TO:<bob@example.net>\r\n" ++ 575 "DATA\r\n" ++ 576 "QUIT\r\n", 577 &out_buf, 578 h.handler(), 579 .{}, 580 ); 581 582 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 583 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Send EHLO first") != null); 584 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null); 585 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null); 586} 587 588test "handler can reject a recipient" { 589 var h: TestHandler = .{ .reject_recipient = "nobody@example.net" }; 590 defer h.deinit(); 591 592 var out_buf: [1024]u8 = undefined; 593 const output = try runScript( 594 "EHLO client.example.org\r\n" ++ 595 "MAIL FROM:<alice@example.com>\r\n" ++ 596 "RCPT TO:<nobody@example.net>\r\n" ++ 597 "RCPT TO:<bob@example.net>\r\n" ++ 598 "DATA\r\n" ++ 599 "hello\r\n" ++ 600 ".\r\n" ++ 601 "QUIT\r\n", 602 &out_buf, 603 h.handler(), 604 .{}, 605 ); 606 607 try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null); 608 try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items); 609 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 610} 611 612test "AUTH PLAIN with initial response" { 613 var h: TestHandler = .{ .password = "secret" }; 614 defer h.deinit(); 615 616 var out_buf: [1024]u8 = undefined; 617 // base64("\x00alice\x00secret") 618 const output = try runScript( 619 "EHLO client.example.org\r\n" ++ 620 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ 621 "MAIL FROM:<alice@example.com>\r\n" ++ 622 "RCPT TO:<bob@example.net>\r\n" ++ 623 "DATA\r\nauthed mail\r\n.\r\n" ++ 624 "QUIT\r\n", 625 &out_buf, 626 h.handler(), 627 .{ .require_auth = true }, 628 ); 629 630 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); 631 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 632 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 633} 634 635test "AUTH LOGIN challenge exchange" { 636 var h: TestHandler = .{ .password = "secret" }; 637 defer h.deinit(); 638 639 var out_buf: [1024]u8 = undefined; 640 // base64("alice"), base64("secret") 641 const output = try runScript( 642 "EHLO client.example.org\r\n" ++ 643 "AUTH LOGIN\r\n" ++ 644 "YWxpY2U=\r\n" ++ 645 "c2VjcmV0\r\n" ++ 646 "QUIT\r\n", 647 &out_buf, 648 h.handler(), 649 .{}, 650 ); 651 652 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null); 653 try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null); 654 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 655} 656 657test "AUTH failures and sequencing" { 658 var h: TestHandler = .{ .password = "secret" }; 659 defer h.deinit(); 660 661 var out_buf: [2048]u8 = undefined; 662 const output = try runScript( 663 "EHLO client.example.org\r\n" ++ 664 "MAIL FROM:<alice@example.com>\r\n" ++ // before auth: 530 665 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // wrong password: 535 666 "AUTH GSSAPI\r\n" ++ // unsupported: 504 667 "AUTH PLAIN not!base64\r\n" ++ // 501 668 "AUTH LOGIN\r\n" ++ 669 "*\r\n" ++ // cancelled: 501 670 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // correct: 235 671 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // again: 503 672 "QUIT\r\n", 673 &out_buf, 674 h.handler(), 675 .{ .require_auth = true }, 676 ); 677 678 try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null); 679 try std.testing.expect(std.mem.indexOf(u8, output, "535 5.7.8") != null); 680 try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null); 681 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid base64") != null); 682 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.7.0 Authentication cancelled") != null); 683 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 684 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Already authenticated") != null); 685} 686 687test "AUTH without a handler is refused" { 688 var h: TestHandler = .{}; 689 defer h.deinit(); 690 691 var out_buf: [1024]u8 = undefined; 692 const output = try runScript( 693 "EHLO client.example.org\r\nAUTH PLAIN AGEAYg==\r\nQUIT\r\n", 694 &out_buf, 695 h.handler(), 696 .{}, 697 ); 698 699 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH") == null); 700 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null); 701} 702 703test "oversize message is rejected but session continues" { 704 var h: TestHandler = .{}; 705 defer h.deinit(); 706 707 var out_buf: [1024]u8 = undefined; 708 const output = try runScript( 709 "EHLO client.example.org\r\n" ++ 710 "MAIL FROM:<alice@example.com>\r\n" ++ 711 "RCPT TO:<bob@example.net>\r\n" ++ 712 "DATA\r\n" ++ 713 "0123456789012345678901234567890123456789\r\n" ++ 714 ".\r\n" ++ 715 "NOOP\r\n" ++ 716 "QUIT\r\n", 717 &out_buf, 718 h.handler(), 719 .{ .max_message_size = 16 }, 720 ); 721 722 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 723 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 724 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 725}