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