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
130 kB 3100 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"); 24const sasl = @import("sasl"); 25const mime = @import("mime"); 26const datetime = @import("datetime"); 27 28reader: *Io.Reader, 29writer: *Io.Writer, 30handler: Handler, 31options: Options, 32/// True once a STARTTLS handshake has completed for this session. 33secured: bool = false, 34/// The identity the client authenticated as, kept for the life of the 35/// session and reported on every `Envelope`. 36identity_buf: [255]u8 = undefined, 37identity_len: usize = 0, 38/// The name the client gave in HELO, EHLO or LHLO, copied because it points 39/// into the reader's buffer and does not survive the next command. A domain 40/// is at most 255 octets; a client claiming more is truncated rather than 41/// refused, since the name is a claim and not a credential. 42greeting_buf: [255]u8 = undefined, 43greeting_len: usize = 0, 44tls_connection: tls.Connection = undefined, 45tls_reader: tls.Connection.Reader = undefined, 46tls_writer: tls.Connection.Writer = undefined, 47tls_read_buffer: [4096]u8 = undefined, 48tls_write_buffer: [4096]u8 = undefined, 49 50pub const Options = struct { 51 /// Which protocol the session speaks. See `Protocol`. 52 protocol: Protocol = .smtp, 53 /// Hostname announced in the greeting and the EHLO response. 54 hostname: []const u8 = "localhost", 55 /// Advertised via the SIZE extension and enforced during DATA. 56 max_message_size: usize = 16 * 1024 * 1024, 57 max_recipients: usize = 100, 58 /// When set, the session speaks TLS (see `TlsOptions.mode`). The 59 /// underlying stream reader/writer handed to `init` must then have 60 /// buffers of at least `tls.input_buffer_len` and 61 /// `tls.output_buffer_len` bytes, since the handshake and TLS records 62 /// run over them. 63 tls: ?TlsOptions = null, 64 /// The SASL mechanisms this session offers, from 65 /// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl) — `sasl.PlainServer` 66 /// and the rest. Advertised by name in the EHLO response, in this order. 67 /// 68 /// **They hold per-exchange state, so each session needs its own.** A set 69 /// shared between two connections would have them overwrite each other's 70 /// challenges. `Server.init` is called per connection anyway, so building 71 /// them alongside it is the natural place. 72 auth_mechanisms: []const sasl.Server = &.{}, 73 /// Offer REQUIRETLS 74 /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)), which a 75 /// sender uses to say "bounce this rather than let it travel in the 76 /// clear". 77 /// 78 /// **Setting this is a promise.** RFC 8689 §4 requires a server that 79 /// advertises the keyword to honour the requirement, and a client that 80 /// sees it advertised will rely on it — one that does *not* see it must 81 /// quit and try another MX, and refuse the domain outright if no host 82 /// offers it. This library can keep no part of that promise on its own: 83 /// it does not relay, so honouring the request is whatever the handler 84 /// does with `Envelope.require_tls`. Leave this false unless the handler 85 /// will act on it. 86 /// 87 /// Advertised only while the session is TLS-protected. RFC 8689 states 88 /// the obligation in terms of STARTTLS and separately requires that the 89 /// session employ TLS; an implicit-TLS session satisfies the latter, so 90 /// both kinds advertise here. 91 requiretls: bool = false, 92 /// Scratch for the AUTH exchange, needed only when `auth_mechanisms` is 93 /// not empty. 94 /// 95 /// It is the caller's for the same reason the stream buffers are: how 96 /// much room a mechanism needs is the caller's to know, and the 97 /// difference is large — the classic mechanisms want a few hundred 98 /// bytes, an OAuth token several kilobytes. `sasl_buffer_suggested` fits 99 /// everything short of an unusually fat token, and `sasl_buffer_min` is 100 /// the floor. 101 /// 102 /// Split four-to-three between base64 and plaintext, which is the ratio 103 /// base64 expands by, so the usable message is about three sevenths of 104 /// what is given. 105 sasl_buffer: []u8 = &.{}, 106 /// Stamp a `Received:` field on every message. See `ReceivedOptions`. 107 received: ?ReceivedOptions = null, 108 /// Reject MAIL with 530 until the client has authenticated. Requires at 109 /// least one entry in `auth_mechanisms`. 110 require_auth: bool = false, 111}; 112 113/// SMTP, or its local-delivery sibling LMTP 114/// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)). 115pub const Protocol = enum { 116 smtp, 117 /// LMTP differs from SMTP in two ways that matter here: the greeting is 118 /// `LHLO` and `HELO`/`EHLO` are refused, and the end of a message is 119 /// answered with one reply per accepted recipient instead of one for 120 /// the message. It exists so that a delivery agent can report a 121 /// different outcome for each mailbox, which SMTP gives no way to say. 122 /// 123 /// RFC 2033 §5 forbids running it on TCP port 25 and advises against 124 /// wide-area use at all: it is for the hop between a queueing MTA and 125 /// the thing that writes to mailboxes. 126 lmtp, 127}; 128 129/// What `Envelope.received` needs that a session cannot work out for itself. 130/// 131/// Set it to have every message carry a composed `Received:` field. Left 132/// null, `Envelope.received` is empty and nothing is stamped — which is a 133/// choice the caller is making, since 134/// [RFC 5321 §4.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.4) 135/// requires a receiving server to insert one. 136pub const ReceivedOptions = struct { 137 /// Read for the clock, once per message. 138 io: Io, 139 /// What goes in parentheses after the client's greeting name: what this 140 /// server *observed* about the peer, as against what the peer said. 141 /// Conventionally the reverse-DNS name and the address literal — 142 /// `client.example.com [192.0.2.1]` — and the only part of the trace 143 /// worth believing. 144 /// 145 /// The caller's, because this library is handed a reader and a writer 146 /// and has never seen an address. It is also why a session that leaves 147 /// this null still produces a usable trace: the greeting name alone is 148 /// worth little, but a trace with a `by` and a timestamp is still a 149 /// trace. 150 peer: ?[]const u8 = null, 151 /// What goes in parentheses after `by`, conventionally the software. 152 by_info: ?[]const u8 = null, 153}; 154 155pub const TlsOptions = struct { 156 io: Io, 157 /// Server certificate chain and private key presented to clients. 158 auth: *tls.config.CertKeyPair, 159 mode: Mode = .starttls, 160 161 pub const Mode = enum { 162 /// Advertise and accept the STARTTLS command 163 /// ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)). 164 starttls, 165 /// Perform the TLS handshake before the greeting (implicit TLS / 166 /// SMTPS, port 465 style; [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314)). 167 implicit, 168 }; 169}; 170 171/// A handler's verdict on an envelope step or a complete message. 172pub const Decision = union(enum) { 173 accept, 174 reject: Rejection, 175 176 pub const Rejection = struct { 177 /// Use 4xx for "try again later", 5xx for permanent rejection. 178 code: u16 = 550, 179 /// By convention prefixed with an enhanced status code 180 /// ([RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463)). 181 text: []const u8 = "5.7.1 Rejected", 182 }; 183}; 184 185/// One accepted recipient, with whatever the client attached to it. 186pub const Recipient = struct { 187 /// The forward-path from RCPT TO. 188 address: []const u8, 189 /// Value of the RCPT `NOTIFY=` parameter 190 /// ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)), if the 191 /// client sent one. Absent means the client did not say, which RFC 3461 192 /// lets a reporting MTA read as either `FAILURE` or `FAILURE,DELAY`. 193 notify: ?protocol.Notify = null, 194 /// Value of the RCPT `ORCPT=` parameter, xtext-decoded: the address the 195 /// message was originally addressed to, before whatever aliasing led 196 /// here. 197 orcpt: ?protocol.Orcpt = null, 198}; 199 200pub const Envelope = struct { 201 /// Empty for the null reverse-path (`MAIL FROM:<>`). 202 from: []const u8, 203 recipients: []const Recipient, 204 /// Value of the MAIL SIZE= parameter 205 /// ([RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870)), if the client 206 /// declared one. Already validated against `Options.max_message_size`. 207 declared_size: ?u64 = null, 208 /// Value of the MAIL `BODY=` parameter, if the client declared one. 209 /// `.binary_mime` ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) 210 /// means the content is arbitrary octets and arrived by BDAT, so the 211 /// handler must keep every bit of it: there is no line structure to 212 /// normalize and nothing was unstuffed. 213 body: ?protocol.Body = null, 214 /// True when the client requested the SMTPUTF8 extension 215 /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)); the 216 /// envelope addresses and message headers may then contain UTF-8. 217 smtputf8: bool = false, 218 /// Value of the MAIL `RET=` parameter 219 /// ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)): how much 220 /// of the message the sender wants carried back in a failure DSN. 221 /// Absent leaves the choice to whoever reports. 222 ret: ?protocol.Ret = null, 223 /// Value of the MAIL `ENVID=` parameter, xtext-decoded: an identifier 224 /// the sender wants quoted back in any DSN for this message. 225 envid: ?[]const u8 = null, 226 /// Value of the MAIL `AUTH=` parameter 227 /// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): 228 /// who the client says originally submitted this message, for a relay 229 /// carrying it on behalf of somebody else. 230 /// 231 /// Null when the parameter was absent. `.unknown` when it said `<>` — 232 /// and also when it named a mailbox that this session has no business 233 /// asserting, because RFC 4954 requires a server to behave as though 234 /// `<>` had been sent whenever the client has not authenticated. A 235 /// `.mailbox` here therefore means an authenticated peer asserted it; 236 /// whether *that* peer is entitled to is the handler's to judge, and 237 /// `authenticated_as` says who is doing the asserting. 238 submitter: ?protocol.Submitter = null, 239 /// The client asked for REQUIRETLS 240 /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)): this 241 /// message must not travel onward over anything but a TLS-protected 242 /// connection with a validated certificate, and must bounce rather than 243 /// be downgraded. 244 /// 245 /// Only ever true when `Options.requiretls` was set, which is the 246 /// caller's undertaking to honour it. Honouring it is the handler's: 247 /// this library does not relay, so nothing here can. A relay that cannot 248 /// meet the requirement should report 5.7.30, "REQUIRETLS support 249 /// required", which RFC 8689 defines for exactly that. 250 require_tls: bool = false, 251 /// The `Received:` field this server would stamp, as a complete field 252 /// ready to write — name, value, folding and terminating CRLF — or empty 253 /// when `Options.received` was not set. 254 /// 255 /// **Write it before the message.** 256 /// [RFC 5321 §4.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.4) 257 /// requires a receiving server to insert trace information "at the 258 /// beginning of the message content", and this library does not touch 259 /// the bytes it hands over — it unstuffs them and nothing else — so the 260 /// stamping is the handler's to do and the composing is done for it. 261 /// 262 /// Valid for the duration of the callback, like everything else here. 263 received: []const u8 = "", 264 /// The name the client gave in its greeting, which is a string the peer 265 /// chose and is worth exactly that. What this server observed about the 266 /// peer instead is `ReceivedOptions.peer`, which the caller supplies. 267 greeting: []const u8 = "", 268 /// The identity the client authenticated as, or null if it did not. 269 /// 270 /// This is what the mechanism reported, which is not always the username 271 /// the client typed: PLAIN carries an authorization identity as well, so 272 /// a mechanism that honours one reports the identity being acted as. A 273 /// handler deciding whether to relay wants this rather than the envelope 274 /// sender, which anybody can write. 275 authenticated_as: ?[]const u8 = null, 276}; 277 278/// What a mail transaction accumulates between MAIL and the end of the 279/// message. Kept together so that resetting it cannot forget a field — 280/// RSET, a completed message and a new (L)HLO all discard the lot. 281const Transaction = struct { 282 from: ?[]const u8 = null, 283 recipients: std.ArrayList(Recipient) = .empty, 284 declared_size: ?u64 = null, 285 body: ?protocol.Body = null, 286 smtputf8: bool = false, 287 ret: ?protocol.Ret = null, 288 envid: ?[]const u8 = null, 289 submitter: ?protocol.Submitter = null, 290 require_tls: bool = false, 291 292 /// The memory all of this points into is the session arena, which the 293 /// caller resets alongside. 294 fn clear(t: *Transaction) void { 295 t.* = .{}; 296 } 297 298 fn envelope( 299 t: Transaction, 300 authenticated_as: ?[]const u8, 301 received: []const u8, 302 greeting_name: []const u8, 303 ) Envelope { 304 return .{ 305 .from = t.from.?, 306 .authenticated_as = authenticated_as, 307 .received = received, 308 .greeting = greeting_name, 309 .submitter = t.submitter, 310 .require_tls = t.require_tls, 311 .recipients = t.recipients.items, 312 .declared_size = t.declared_size, 313 .body = t.body, 314 .smtputf8 = t.smtputf8, 315 .ret = t.ret, 316 .envid = t.envid, 317 }; 318 } 319}; 320 321/// Callbacks invoked during a session. All slices passed to callbacks are 322/// only valid for the duration of the call. 323pub const Handler = struct { 324 context: ?*anyopaque = null, 325 vtable: *const VTable, 326 327 pub const VTable = struct { 328 /// Called for MAIL FROM. Null accepts every sender. 329 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null, 330 /// Called for each RCPT TO, with the address and any DSN 331 /// parameters that came with it. Null accepts every recipient. 332 rcptTo: ?*const fn (context: ?*anyopaque, recipient: Recipient) Decision = null, 333 /// Called once the complete message has been received. The data has 334 /// CRLF line endings and dot-stuffing already removed. Exactly one 335 /// of `message` and `messageReader` must be set. 336 message: ?*const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision = null, 337 /// LMTP only: the verdict for one recipient of the message just 338 /// received, `envelope.recipients[index]`, called once per accepted 339 /// recipient after `message` or `messageReader` has returned 340 /// `.accept`. This is what LMTP exists for — one mailbox can be 341 /// full while another is fine — so a `.lmtp` session without it 342 /// answers every recipient identically and gains nothing over SMTP. 343 /// 344 /// Not called when the message itself was rejected: that verdict 345 /// applies to every recipient and is sent for each of them. 346 recipientResult: ?*const fn (context: ?*anyopaque, envelope: Envelope, index: usize) Decision = null, 347 /// Streaming alternative to `message`: called after DATA with a 348 /// reader that yields the message content (dot-stuffing removed, 349 /// line endings normalized to CRLF) until end of stream. Anything 350 /// the callback leaves unread is drained by the session, so 351 /// returning early is fine. `Options.max_message_size` is not 352 /// enforced in this mode; individual message lines must fit the 353 /// session's stream reader buffer. 354 messageReader: ?*const fn (context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision = null, 355 }; 356}; 357 358pub fn init(reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options) Server { 359 return .{ .reader = reader, .writer = writer, .handler = handler, .options = options }; 360} 361 362pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory, TlsHandshakeFailed }; 363 364/// Serves the session until the client sends QUIT or disconnects. `gpa` 365/// backs per-transaction storage (envelope and message data); everything is 366/// freed on return. 367pub fn run(s: *Server, gpa: std.mem.Allocator) RunError!void { 368 var arena_state: std.heap.ArenaAllocator = .init(gpa); 369 defer arena_state.deinit(); 370 const arena = arena_state.allocator(); 371 372 std.debug.assert(!s.options.require_auth or s.options.auth_mechanisms.len != 0); 373 // A session that offers mechanisms and no room to run them would answer 374 // every AUTH with a temporary failure, which is worth catching here. 375 std.debug.assert(s.options.auth_mechanisms.len == 0 or 376 s.options.sasl_buffer.len >= sasl_buffer_min); 377 std.debug.assert((s.handler.vtable.message == null) != (s.handler.vtable.messageReader == null)); 378 379 if (s.options.tls) |config| { 380 if (config.mode == .implicit and !s.secured) try s.upgradeToTls(config); 381 } 382 383 var greeted = false; 384 var authenticated = false; 385 var transaction: Transaction = .{}; 386 387 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname}); 388 try s.writer.flush(); 389 390 while (true) { 391 const line = protocol.readLine(s.reader) catch |err| switch (err) { 392 error.EndOfStream => return, // Client disconnected. 393 error.ReadFailed => return error.ReadFailed, 394 error.LineTooLong => { 395 try s.discardLine(); 396 try s.reply(500, "5.5.2 Line too long"); 397 continue; 398 }, 399 }; 400 const command = protocol.Command.parse(line) catch { 401 try s.reply(501, "5.5.4 Syntax error in parameters"); 402 continue; 403 }; 404 switch (command) { 405 .helo => |name| { 406 // RFC 2033 §4: an LMTP server must not answer HELO or EHLO 407 // with a positive completion, and 500 is what it suggests. 408 if (s.options.protocol == .lmtp) { 409 try s.reply(500, "5.5.1 This is LMTP, use LHLO"); 410 continue; 411 } 412 greeted = true; 413 s.setGreeting(name); 414 transaction.clear(); 415 _ = arena_state.reset(.retain_capacity); 416 try s.reply(250, s.options.hostname); 417 }, 418 .ehlo => |name| { 419 if (s.options.protocol == .lmtp) { 420 try s.reply(500, "5.5.1 This is LMTP, use LHLO"); 421 continue; 422 } 423 greeted = true; 424 s.setGreeting(name); 425 transaction.clear(); 426 _ = arena_state.reset(.retain_capacity); 427 try s.greetExtended(authenticated); 428 }, 429 .lhlo => |name| { 430 if (s.options.protocol == .smtp) { 431 try s.reply(500, "5.5.2 Command not recognized"); 432 continue; 433 } 434 greeted = true; 435 s.setGreeting(name); 436 transaction.clear(); 437 _ = arena_state.reset(.retain_capacity); 438 try s.greetExtended(authenticated); 439 }, 440 .mail => |args| { 441 if (!greeted) { 442 try s.reply(503, "5.5.1 Send EHLO first"); 443 continue; 444 } 445 if (s.options.require_auth and !authenticated) { 446 try s.reply(530, "5.7.0 Authentication required"); 447 continue; 448 } 449 if (transaction.from != null) { 450 try s.reply(503, "5.5.1 Nested MAIL command"); 451 continue; 452 } 453 var mail_declared_size: ?u64 = null; 454 var mail_body: ?protocol.Body = null; 455 var mail_smtputf8 = false; 456 var mail_ret: ?protocol.Ret = null; 457 var mail_envid: ?[]const u8 = null; 458 var mail_submitter: ?protocol.Submitter = null; 459 var mail_require_tls = false; 460 var params_ok = true; 461 var params = args.paramIterator(); 462 while (params.next()) |param| { 463 if (std.ascii.eqlIgnoreCase(param.keyword, "SIZE")) { 464 const size = std.fmt.parseInt(u64, param.value, 10) catch { 465 try s.reply(501, "5.5.2 Invalid SIZE parameter"); 466 params_ok = false; 467 break; 468 }; 469 if (size > s.options.max_message_size) { 470 try s.reply(552, "5.3.4 Message size exceeds fixed maximum"); 471 params_ok = false; 472 break; 473 } 474 mail_declared_size = size; 475 } else if (std.ascii.eqlIgnoreCase(param.keyword, "BODY")) { 476 mail_body = protocol.Body.parse(param.value) catch { 477 try s.reply(555, "5.5.4 Unsupported BODY value"); 478 params_ok = false; 479 break; 480 }; 481 } else if (std.ascii.eqlIgnoreCase(param.keyword, "SMTPUTF8")) { 482 if (param.value.len != 0) { 483 try s.reply(501, "5.5.4 SMTPUTF8 takes no value"); 484 params_ok = false; 485 break; 486 } 487 mail_smtputf8 = true; 488 } else if (std.ascii.eqlIgnoreCase(param.keyword, "REQUIRETLS")) { 489 // Only recognized when it was offered, which needs 490 // both the option and a TLS session; otherwise it is 491 // a parameter this server never advertised, and 555 492 // is what RFC 5321 §4.1.1.11 gives for one of those. 493 if (!s.options.requiretls or !s.secured) { 494 try s.reply(555, "5.5.4 Unrecognized parameter"); 495 params_ok = false; 496 break; 497 } 498 if (param.value.len != 0) { 499 try s.reply(501, "5.5.4 REQUIRETLS takes no value"); 500 params_ok = false; 501 break; 502 } 503 mail_require_tls = true; 504 } else if (std.ascii.eqlIgnoreCase(param.keyword, "AUTH")) { 505 // RFC 4954 §5 is explicit that a server advertising 506 // AUTH must take this parameter even from a client 507 // that has not authenticated — and then disregard 508 // what it says, which is what the check below does. 509 if (s.options.auth_mechanisms.len == 0) { 510 try s.reply(555, "5.5.4 Unrecognized parameter"); 511 params_ok = false; 512 break; 513 } 514 const decoded = arena.alloc(u8, param.value.len) catch 515 return error.OutOfMemory; 516 const asserted = protocol.Submitter.parse(decoded, param.value) catch { 517 try s.reply(501, "5.5.4 Invalid AUTH parameter"); 518 params_ok = false; 519 break; 520 }; 521 // "MUST behave as if the AUTH=<> parameter was 522 // supplied" when the client has not authenticated. 523 // The claim is still recorded as having been made, 524 // just not as having been believed. 525 mail_submitter = if (authenticated) asserted else .unknown; 526 } else if (std.ascii.eqlIgnoreCase(param.keyword, "RET")) { 527 mail_ret = protocol.Ret.parse(param.value) catch { 528 try s.reply(501, "5.5.4 Invalid RET parameter"); 529 params_ok = false; 530 break; 531 }; 532 } else if (std.ascii.eqlIgnoreCase(param.keyword, "ENVID")) { 533 // The cap is on the encoded form, which is what 534 // arrived, so it is checked before decoding. 535 if (param.value.len == 0 or param.value.len > protocol.max_envid_len) { 536 try s.reply(501, "5.5.4 Invalid ENVID parameter"); 537 params_ok = false; 538 break; 539 } 540 const decoded = arena.alloc(u8, param.value.len) catch return error.OutOfMemory; 541 mail_envid = protocol.xtextDecode(decoded, param.value) catch { 542 try s.reply(501, "5.5.4 Invalid ENVID parameter"); 543 params_ok = false; 544 break; 545 }; 546 } else { 547 try s.reply(555, "5.5.4 Unrecognized parameter"); 548 params_ok = false; 549 break; 550 } 551 } 552 if (!params_ok) continue; 553 if (!try s.validateAddress(args.path, mail_smtputf8)) continue; 554 if (s.handler.vtable.mailFrom) |callback| { 555 switch (callback(s.handler.context, args.path)) { 556 .accept => {}, 557 .reject => |r| { 558 try s.reply(r.code, r.text); 559 continue; 560 }, 561 } 562 } 563 transaction.from = try arena.dupe(u8, args.path); 564 transaction.declared_size = mail_declared_size; 565 transaction.body = mail_body; 566 transaction.smtputf8 = mail_smtputf8; 567 transaction.ret = mail_ret; 568 transaction.envid = mail_envid; 569 transaction.submitter = mail_submitter; 570 transaction.require_tls = mail_require_tls; 571 try s.replyGrouped(250, "2.1.0 Ok"); 572 }, 573 .rcpt => |args| { 574 if (transaction.from == null) { 575 try s.reply(503, "5.5.1 Need MAIL command first"); 576 continue; 577 } 578 var recipient: Recipient = .{ .address = args.path }; 579 var params_ok = true; 580 var params = args.paramIterator(); 581 while (params.next()) |param| { 582 if (std.ascii.eqlIgnoreCase(param.keyword, "NOTIFY")) { 583 recipient.notify = protocol.Notify.parse(param.value) catch { 584 try s.reply(501, "5.5.4 Invalid NOTIFY parameter"); 585 params_ok = false; 586 break; 587 }; 588 } else if (std.ascii.eqlIgnoreCase(param.keyword, "ORCPT")) { 589 if (param.value.len == 0 or param.value.len > protocol.Orcpt.max_len) { 590 try s.reply(501, "5.5.4 Invalid ORCPT parameter"); 591 params_ok = false; 592 break; 593 } 594 const decoded = arena.alloc(u8, param.value.len) catch return error.OutOfMemory; 595 recipient.orcpt = protocol.Orcpt.parse(decoded, param.value) catch { 596 try s.reply(501, "5.5.4 Invalid ORCPT parameter"); 597 params_ok = false; 598 break; 599 }; 600 } else { 601 try s.reply(555, "5.5.4 Unrecognized parameter"); 602 params_ok = false; 603 break; 604 } 605 } 606 if (!params_ok) continue; 607 if (!try s.validateAddress(args.path, transaction.smtputf8)) continue; 608 if (transaction.recipients.items.len >= s.options.max_recipients) { 609 try s.reply(452, "4.5.3 Too many recipients"); 610 continue; 611 } 612 if (s.handler.vtable.rcptTo) |callback| { 613 switch (callback(s.handler.context, recipient)) { 614 .accept => {}, 615 .reject => |r| { 616 try s.reply(r.code, r.text); 617 continue; 618 }, 619 } 620 } 621 recipient.address = try arena.dupe(u8, args.path); 622 if (recipient.orcpt) |*orcpt| orcpt.addr_type = try arena.dupe(u8, orcpt.addr_type); 623 try transaction.recipients.append(arena, recipient); 624 try s.replyGrouped(250, "2.1.5 Ok"); 625 }, 626 .data => { 627 if (transaction.recipients.items.len == 0) { 628 try s.reply(503, "5.5.1 Need RCPT command first"); 629 continue; 630 } 631 // RFC 3030 §3: binary content has no line structure, so it 632 // cannot be framed by a line holding a single dot. BDAT, 633 // which carries its length, is the only way to send it. 634 if (transaction.body == .binary_mime) { 635 try s.reply(503, "5.5.1 BINARYMIME requires BDAT"); 636 continue; 637 } 638 try s.receiveData(arena, transaction.envelope( 639 s.identity(), 640 try s.composeReceived(arena, transaction, authenticated), 641 s.greetingName(), 642 )); 643 transaction.clear(); 644 _ = arena_state.reset(.retain_capacity); 645 }, 646 .bdat => |args| { 647 if (transaction.recipients.items.len == 0) { 648 // The chunk's octets follow regardless; consume them to 649 // keep the length-framed stream in sync. 650 s.reader.discardAll64(args.size) catch |err| switch (err) { 651 error.EndOfStream => return, 652 error.ReadFailed => return error.ReadFailed, 653 }; 654 try s.reply(503, "5.5.1 Need RCPT command first"); 655 continue; 656 } 657 const outcome = try s.receiveChunked(arena, transaction.envelope( 658 s.identity(), 659 try s.composeReceived(arena, transaction, authenticated), 660 s.greetingName(), 661 ), args); 662 transaction.clear(); 663 _ = arena_state.reset(.retain_capacity); 664 switch (outcome) { 665 .done => {}, 666 .end_session => return, 667 } 668 }, 669 .rset => { 670 transaction.clear(); 671 _ = arena_state.reset(.retain_capacity); 672 try s.replyGrouped(250, "2.0.0 Ok"); 673 }, 674 .noop => try s.reply(250, "2.0.0 Ok"), 675 // 252 is the compliant answer for a server that will not check 676 // an address in advance but will take the mail: RFC 5321 §3.5.3. 677 // 500 or 502 here would put this server out of compliance, since 678 // §4.5.1 makes VRFY one of the commands it must support. 679 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"), 680 // EXPN is not required and is not implemented, and 502 says 681 // exactly that. 500 would be the reply of a server that had 682 // never heard of the command, which would not be true. 683 .expn => try s.reply(502, "5.5.1 EXPN not implemented"), 684 .help => try s.reply(214, "2.0.0 See RFC 5321"), 685 .starttls => { 686 const config = s.options.tls orelse { 687 try s.reply(502, "5.5.1 STARTTLS not supported"); 688 continue; 689 }; 690 if (config.mode != .starttls) { 691 try s.reply(502, "5.5.1 STARTTLS not supported"); 692 continue; 693 } 694 if (s.secured) { 695 try s.reply(503, "5.5.1 TLS already active"); 696 continue; 697 } 698 try s.reply(220, "2.0.0 Ready to start TLS"); 699 try s.upgradeToTls(config); 700 // RFC 3207 §4.2: both sides return to their initial state; 701 // the client must EHLO again. 702 greeted = false; 703 authenticated = false; 704 transaction.clear(); 705 _ = arena_state.reset(.retain_capacity); 706 }, 707 .quit => { 708 try s.reply(221, "2.0.0 Bye"); 709 if (s.secured) s.tls_connection.close() catch {}; 710 return; 711 }, 712 .auth => |args| { 713 if (s.options.auth_mechanisms.len == 0) { 714 try s.reply(503, "5.5.1 Authentication not enabled"); 715 continue; 716 } 717 if (!greeted) { 718 try s.reply(503, "5.5.1 Send EHLO first"); 719 continue; 720 } 721 if (authenticated) { 722 try s.reply(503, "5.5.1 Already authenticated"); 723 continue; 724 } 725 if (transaction.from != null) { 726 try s.reply(503, "5.5.1 MAIL transaction in progress"); 727 continue; 728 } 729 switch (try s.receiveAuth(args)) { 730 .authenticated => authenticated = true, 731 .rejected => {}, 732 .disconnected => return, 733 } 734 }, 735 .unknown => try s.reply(500, "5.5.2 Command not recognized"), 736 } 737 } 738} 739 740/// The smallest `Options.sasl_buffer` worth offering: enough plaintext for 741/// PLAIN, LOGIN, CRAM-MD5, EXTERNAL, ANONYMOUS and DIGEST-MD5. 742pub const sasl_buffer_min = 896; 743 744/// A `Options.sasl_buffer` size that fits everything, OAuth tokens included. 745/// See `Client.sasl_buffer_suggested`, which says where the number is from. 746pub const sasl_buffer_suggested = 7168; 747 748/// Writes the EHLO or LHLO response: the hostname, then one line per 749/// extension. The two are the same list — RFC 2033 gives LHLO the semantics 750/// of EHLO — and it requires PIPELINING and ENHANCEDSTATUSCODES of an LMTP 751/// server, both of which are here for every session anyway. 752fn greetExtended(s: *Server, authenticated: bool) error{WriteFailed}!void { 753 // Every reply carries an enhanced status code (RFC 3463), so the 754 // ENHANCEDSTATUSCODES extension (RFC 2034) is advertised. 755 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-BINARYMIME\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n", .{s.options.hostname}); 756 if (s.options.tls) |config| { 757 if (config.mode == .starttls and !s.secured) 758 try s.writer.writeAll("250-STARTTLS\r\n"); 759 } 760 // RFC 8689 §4: advertised only on a session that employs TLS, because 761 // the guarantee is meaningless without one. 762 if (s.options.requiretls and s.secured) try s.writer.writeAll("250-REQUIRETLS\r\n"); 763 if (s.options.auth_mechanisms.len != 0 and !authenticated) { 764 try s.writer.writeAll("250-AUTH"); 765 for (s.options.auth_mechanisms) |mechanism| 766 try s.writer.print(" {s}", .{mechanism.name()}); 767 try s.writer.writeAll("\r\n"); 768 } 769 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); 770 try s.writer.flush(); 771} 772 773/// Performs the server-side TLS handshake over the current transport and 774/// swaps the session onto the encrypted connection. 775fn upgradeToTls(s: *Server, config: TlsOptions) error{TlsHandshakeFailed}!void { 776 var rng_source: std.Random.IoSource = .{ .io = config.io }; 777 s.tls_connection = tls.server(s.reader, s.writer, .{ 778 .auth = config.auth, 779 .rng = rng_source.interface(), 780 .now = Io.Clock.real.now(config.io), 781 }) catch return error.TlsHandshakeFailed; 782 s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer); 783 s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer); 784 s.reader = &s.tls_reader.interface; 785 s.writer = &s.tls_writer.interface; 786 s.secured = true; 787} 788 789const AuthOutcome = enum { authenticated, rejected, disconnected }; 790 791/// Runs the challenge/response exchange for AUTH PLAIN or AUTH LOGIN 792/// (RFC 4954) and consults the handler's `authenticate` callback. Every 793/// outcome except `disconnected` has already sent its reply. 794/// Runs a SASL exchange with whichever of `Options.auth_mechanisms` the 795/// client named ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 796/// 797/// The mechanisms come from 798/// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl); what is here is the 799/// SMTP half of it — the 334 challenges, the `*` that cancels, 235, and the 800/// 504 for a name nothing answers to. 801fn receiveAuth(s: *Server, args: protocol.Command.AuthArgs) RunError!AuthOutcome { 802 const mechanism = for (s.options.auth_mechanisms) |candidate| { 803 if (std.ascii.eqlIgnoreCase(candidate.name(), args.mechanism)) break candidate; 804 } else { 805 try s.reply(504, "5.5.4 Unrecognized authentication type"); 806 return .rejected; 807 }; 808 809 // The two halves never hold anything at once: a challenge is written as 810 // plaintext and encoded into `coded`, and the client's answer decodes 811 // back over it once that has gone out. 812 if (s.options.sasl_buffer.len < sasl_buffer_min) { 813 try s.reply(454, "4.7.0 Temporary authentication failure"); 814 return .rejected; 815 } 816 const unit = s.options.sasl_buffer.len / 7; 817 const coded = s.options.sasl_buffer[0 .. unit * 4]; 818 const plain = s.options.sasl_buffer[unit * 4 ..][0 .. unit * 3]; 819 var challenge: Io.Writer = .fixed(plain); 820 821 // RFC 4954 §4: no argument at all and a single `=` are different. The 822 // first is "I have nothing to send yet", the second an initial response 823 // that happens to be empty, and mechanisms read them differently. 824 const initial: ?[]const u8 = if (args.initial.len == 0) null else decodeBase64( 825 coded, 826 args.initial, 827 ) orelse { 828 try s.reply(501, "5.5.2 Invalid base64"); 829 return .rejected; 830 }; 831 832 var step = mechanism.start(initial, &challenge) catch |err| return s.authFailed(err); 833 while (true) { 834 switch (step) { 835 .accepted => |who| { 836 s.setIdentity(who); 837 try s.reply(235, "2.7.0 Authentication successful"); 838 return .authenticated; 839 }, 840 .rejected => { 841 // No distinction between "no such user" and "wrong password" 842 // reaches the wire: that difference is worth money to 843 // somebody enumerating accounts. 844 try s.reply(535, "5.7.8 Authentication credentials invalid"); 845 return .rejected; 846 }, 847 .challenge => { 848 const encoded = std.base64.standard.Encoder.encode(coded, challenge.buffered()); 849 // A zero-length challenge is "334 " — the code, a space, and 850 // nothing after it, which `reply` produces for empty text. 851 try s.reply(334, encoded); 852 853 const line = switch (try s.takeAuthLine()) { 854 .line => |line| line, 855 .cancelled => return .rejected, 856 .disconnected => return .disconnected, 857 }; 858 const response = decodeBase64(coded, line) orelse { 859 try s.reply(501, "5.5.2 Invalid base64"); 860 return .rejected; 861 }; 862 challenge = .fixed(plain); 863 step = mechanism.respond(response, &challenge) catch |err| 864 return s.authFailed(err); 865 }, 866 } 867 } 868} 869 870/// A mechanism that could not make sense of what the client sent. Its own 871/// errors are not worth distinguishing on the wire. 872fn authFailed(s: *Server, err: sasl.Server.Error) RunError!AuthOutcome { 873 switch (err) { 874 error.OutOfMemory => return error.OutOfMemory, 875 error.WriteFailed => return error.WriteFailed, 876 error.BadResponse => { 877 try s.reply(501, "5.5.2 Malformed authentication response"); 878 return .rejected; 879 }, 880 } 881} 882 883/// The identity the client authenticated as, or null if it has not. 884pub fn identity(s: *const Server) ?[]const u8 { 885 if (s.identity_len == 0) return null; 886 return s.identity_buf[0..s.identity_len]; 887} 888 889/// Keeps the name the client greeted with, for the trace field. 890fn setGreeting(s: *Server, name: []const u8) void { 891 s.greeting_len = @min(name.len, s.greeting_buf.len); 892 @memcpy(s.greeting_buf[0..s.greeting_len], name[0..s.greeting_len]); 893} 894 895/// The name the client gave in its greeting, which is a string the peer 896/// chose and is worth exactly that. 897pub fn greetingName(s: *const Server) []const u8 { 898 return s.greeting_buf[0..s.greeting_len]; 899} 900 901/// Composes the `Received:` field for the message about to be handed over. 902/// 903/// Everything in it that came from the client — the greeting name above all 904/// — is escaped by `mime.received`, which is the point of composing it 905/// there: a `Received:` is a header written from attacker-supplied text, and 906/// a greeting carrying a line break must not become two fields. 907fn composeReceived( 908 s: *Server, 909 arena: std.mem.Allocator, 910 transaction: Transaction, 911 authenticated: bool, 912) std.mem.Allocator.Error![]const u8 { 913 const options = s.options.received orelse return ""; 914 915 const trace: mime.received.Received = .{ 916 .from = if (s.greeting_len == 0) null else s.greetingName(), 917 .from_info = options.peer, 918 .by = s.options.hostname, 919 .by_info = options.by_info, 920 .with = mime.received.protocolFor(.{ 921 .tls = s.secured, 922 .authenticated = authenticated, 923 .lmtp = s.options.protocol == .lmtp, 924 .utf8 = transaction.smtputf8, 925 }), 926 // RFC 5321 §4.4: only with exactly one recipient. More than one and 927 // the trace tells each of them who the others were, which is how a 928 // Bcc gets broken by the transport rather than by the sender. 929 .for_recipient = if (transaction.recipients.items.len == 1) 930 transaction.recipients.items[0].address 931 else 932 null, 933 .received_at = datetime.DateTime.utc(options.io), 934 }; 935 936 var field: Io.Writer.Allocating = .init(arena); 937 field.writer.writeAll("Received: ") catch return error.OutOfMemory; 938 trace.write(&field.writer) catch return error.OutOfMemory; 939 field.writer.writeAll(protocol.crlf) catch return error.OutOfMemory; 940 return field.written(); 941} 942 943/// Keeps the authenticated identity for the rest of the session. 944/// 945/// Copied because a mechanism may report a slice of the response it was 946/// handed, which lives in a buffer that does not outlive the exchange — and 947/// this has to survive every transaction that follows. 948fn setIdentity(s: *Server, who: []const u8) void { 949 s.identity_len = @min(who.len, s.identity_buf.len); 950 @memcpy(s.identity_buf[0..s.identity_len], who[0..s.identity_len]); 951} 952 953const AuthLine = union(enum) { line: []u8, cancelled, disconnected }; 954 955/// Reads one continuation line of an AUTH exchange. `cancelled` covers both 956/// an explicit "*" and an overlong line; its reply has already been sent. 957fn takeAuthLine(s: *Server) RunError!AuthLine { 958 const line = protocol.readLine(s.reader) catch |err| switch (err) { 959 error.EndOfStream => return .disconnected, 960 error.ReadFailed => return error.ReadFailed, 961 error.LineTooLong => { 962 try s.discardLine(); 963 try s.reply(501, "5.5.2 Response too long"); 964 return .cancelled; 965 }, 966 }; 967 if (std.mem.eql(u8, line, "*")) { 968 try s.reply(501, "5.7.0 Authentication cancelled"); 969 return .cancelled; 970 } 971 return .{ .line = line }; 972} 973 974/// Decodes a base64 AUTH argument; "=" denotes an empty response. 975fn decodeBase64(out: []u8, encoded: []const u8) ?[]u8 { 976 if (std.mem.eql(u8, encoded, "=")) return out[0..0]; 977 const len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return null; 978 if (len > out.len) return null; 979 std.base64.standard.Decoder.decode(out[0..len], encoded) catch return null; 980 return out[0..len]; 981} 982 983const ChunkOutcome = enum { done, end_session }; 984 985/// Receives a message sent with BDAT chunks (RFC 3030 CHUNKING), starting 986/// from the already-parsed first chunk header. Chunk data is raw: no 987/// dot-stuffing and no line-ending normalization. 988fn receiveChunked( 989 s: *Server, 990 arena: std.mem.Allocator, 991 envelope: Envelope, 992 first: protocol.Command.BdatArgs, 993) RunError!ChunkOutcome { 994 if (s.handler.vtable.messageReader) |callback| { 995 var buffer: [1024]u8 = undefined; 996 var bdat_reader: BdatReader = .{ 997 .server = s, 998 .remaining = first.size, 999 .last = first.last, 1000 .interface = .{ 1001 .buffer = &buffer, 1002 .vtable = &.{ .stream = BdatReader.stream }, 1003 .seek = 0, 1004 .end = 0, 1005 }, 1006 }; 1007 const decision = callback(s.handler.context, envelope, &bdat_reader.interface); 1008 if (bdat_reader.abort == null and !bdat_reader.finished) { 1009 // Consume whatever the callback left unread, through LAST. 1010 var discard_buf: [256]u8 = undefined; 1011 var discarding: Io.Writer.Discarding = .init(&discard_buf); 1012 _ = bdat_reader.interface.streamRemaining(&discarding.writer) catch {}; 1013 } 1014 if (bdat_reader.abort) |abort| switch (abort) { 1015 .rset, .protocol => return .done, // Replies already sent. 1016 .quit, .disconnected => return .end_session, 1017 .transport_failure => return error.ReadFailed, 1018 }; 1019 try s.replyMessage(envelope, decision); 1020 return .done; 1021 } 1022 1023 var data: std.ArrayList(u8) = .empty; 1024 var oversize = false; 1025 var size = first.size; 1026 var last = first.last; 1027 while (true) { 1028 var left = size; 1029 while (left > 0) { 1030 const available = s.reader.peekGreedy(1) catch |err| switch (err) { 1031 error.EndOfStream => return .end_session, 1032 error.ReadFailed => return error.ReadFailed, 1033 }; 1034 const n: usize = @intCast(@min(@as(u64, available.len), left)); 1035 if (!oversize) { 1036 if (data.items.len + n > s.options.max_message_size) { 1037 oversize = true; 1038 } else { 1039 try data.appendSlice(arena, available[0..n]); 1040 } 1041 } 1042 s.reader.toss(n); 1043 left -= n; 1044 } 1045 if (last) break; 1046 try s.reply(250, "2.0.0 Chunk received"); 1047 const line = protocol.readLine(s.reader) catch |err| switch (err) { 1048 error.EndOfStream => return .end_session, 1049 error.ReadFailed => return error.ReadFailed, 1050 error.LineTooLong => { 1051 try s.discardLine(); 1052 try s.reply(500, "5.5.2 Line too long"); 1053 return .done; // Transaction aborted. 1054 }, 1055 }; 1056 const command = protocol.Command.parse(line) catch { 1057 try s.reply(501, "5.5.4 Syntax error in parameters"); 1058 return .done; 1059 }; 1060 switch (command) { 1061 .bdat => |b| { 1062 size = b.size; 1063 last = b.last; 1064 }, 1065 .rset => { 1066 try s.reply(250, "2.0.0 Ok"); 1067 return .done; 1068 }, 1069 .quit => { 1070 try s.reply(221, "2.0.0 Bye"); 1071 if (s.secured) s.tls_connection.close() catch {}; 1072 return .end_session; 1073 }, 1074 else => { 1075 try s.reply(503, "5.5.1 BDAT expected"); 1076 return .done; 1077 }, 1078 } 1079 } 1080 if (oversize) { 1081 try s.reply(552, "5.3.4 Message exceeds maximum size"); 1082 return .done; 1083 } 1084 try s.replyMessage(envelope, s.handler.vtable.message.?(s.handler.context, envelope, data.items)); 1085 return .done; 1086} 1087 1088/// Adapts a BDAT chunk sequence into an `Io.Reader` of the raw message 1089/// content for `Handler.VTable.messageReader`, replying 250 between chunks 1090/// and following the chunk headers as they arrive. 1091const BdatReader = struct { 1092 server: *Server, 1093 interface: Io.Reader, 1094 remaining: u64, 1095 last: bool, 1096 finished: bool = false, 1097 abort: ?Abort = null, 1098 1099 const Abort = enum { rset, quit, protocol, disconnected, transport_failure }; 1100 1101 fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize { 1102 const br: *BdatReader = @alignCast(@fieldParentPtr("interface", io_r)); 1103 const s = br.server; 1104 while (br.remaining == 0) { 1105 if (br.last) { 1106 br.finished = true; 1107 return error.EndOfStream; 1108 } 1109 s.reply(250, "2.0.0 Chunk received") catch { 1110 br.abort = .transport_failure; 1111 return error.ReadFailed; 1112 }; 1113 const line = protocol.readLine(s.reader) catch |err| { 1114 switch (err) { 1115 error.EndOfStream => br.abort = .disconnected, 1116 error.ReadFailed => br.abort = .transport_failure, 1117 error.LineTooLong => { 1118 s.discardLine() catch {}; 1119 s.reply(500, "5.5.2 Line too long") catch {}; 1120 br.abort = .protocol; 1121 }, 1122 } 1123 return error.ReadFailed; 1124 }; 1125 const command = protocol.Command.parse(line) catch { 1126 s.reply(501, "5.5.4 Syntax error in parameters") catch {}; 1127 br.abort = .protocol; 1128 return error.ReadFailed; 1129 }; 1130 switch (command) { 1131 .bdat => |b| { 1132 br.remaining = b.size; 1133 br.last = b.last; 1134 }, 1135 .rset => { 1136 s.reply(250, "2.0.0 Ok") catch {}; 1137 br.abort = .rset; 1138 return error.ReadFailed; 1139 }, 1140 .quit => { 1141 s.reply(221, "2.0.0 Bye") catch {}; 1142 if (s.secured) s.tls_connection.close() catch {}; 1143 br.abort = .quit; 1144 return error.ReadFailed; 1145 }, 1146 else => { 1147 s.reply(503, "5.5.1 BDAT expected") catch {}; 1148 br.abort = .protocol; 1149 return error.ReadFailed; 1150 }, 1151 } 1152 } 1153 const available = s.reader.peekGreedy(1) catch |err| switch (err) { 1154 error.EndOfStream => { 1155 br.abort = .disconnected; 1156 return error.ReadFailed; 1157 }, 1158 error.ReadFailed => { 1159 br.abort = .transport_failure; 1160 return error.ReadFailed; 1161 }, 1162 }; 1163 const dest = limit.slice(try w.writableSliceGreedy(1)); 1164 const n: usize = @intCast(@min(@min(@as(u64, available.len), @as(u64, dest.len)), br.remaining)); 1165 @memcpy(dest[0..n], available[0..n]); 1166 s.reader.toss(n); 1167 br.remaining -= n; 1168 w.advance(n); 1169 return n; 1170 } 1171}; 1172 1173/// Reads message content after DATA up to the terminating ".\r\n", 1174/// un-stuffing dots, then asks the handler to accept or reject. 1175fn receiveData(s: *Server, arena: std.mem.Allocator, envelope: Envelope) RunError!void { 1176 try s.reply(354, "End data with <CR><LF>.<CR><LF>"); 1177 1178 if (s.handler.vtable.messageReader) |callback| { 1179 var buffer: [1024]u8 = undefined; 1180 var data_reader: DataReader = .{ 1181 .session_reader = s.reader, 1182 .interface = .{ 1183 .buffer = &buffer, 1184 .vtable = &.{ .stream = DataReader.stream }, 1185 .seek = 0, 1186 .end = 0, 1187 }, 1188 }; 1189 const decision = callback(s.handler.context, envelope, &data_reader.interface); 1190 // Consume whatever the callback left unread, up to and including 1191 // the terminating ".". 1192 while (!data_reader.finished) { 1193 const line = protocol.readLine(s.reader) catch |err| switch (err) { 1194 error.EndOfStream => return, // Client disconnected mid-message. 1195 error.ReadFailed => return error.ReadFailed, 1196 error.LineTooLong => { 1197 try s.discardLine(); 1198 continue; 1199 }, 1200 }; 1201 if (std.mem.eql(u8, line, ".")) break; 1202 } 1203 try s.replyMessage(envelope, decision); 1204 return; 1205 } 1206 1207 var data: std.ArrayList(u8) = .empty; 1208 var oversize = false; 1209 while (true) { 1210 const line = protocol.readLine(s.reader) catch |err| switch (err) { 1211 error.EndOfStream => return, // Client disconnected mid-message. 1212 error.ReadFailed => return error.ReadFailed, 1213 error.LineTooLong => { 1214 // Longer than our reader buffer; RFC 5321 caps text lines at 1215 // 1000 octets, so treat it as oversize but keep scanning for 1216 // the terminator. 1217 try s.discardLine(); 1218 oversize = true; 1219 continue; 1220 }, 1221 }; 1222 if (std.mem.eql(u8, line, ".")) break; 1223 const content = if (line.len > 0 and line[0] == '.') line[1..] else line; 1224 if (oversize) continue; 1225 if (data.items.len + content.len + protocol.crlf.len > s.options.max_message_size) { 1226 oversize = true; 1227 continue; 1228 } 1229 try data.appendSlice(arena, content); 1230 try data.appendSlice(arena, protocol.crlf); 1231 } 1232 if (oversize) { 1233 try s.reply(552, "5.3.4 Message exceeds maximum size"); 1234 return; 1235 } 1236 try s.replyMessage(envelope, s.handler.vtable.message.?(s.handler.context, envelope, data.items)); 1237} 1238 1239/// Adapts the session's line-based DATA phase into an `Io.Reader` of the 1240/// unstuffed message content for `Handler.VTable.messageReader`. 1241const DataReader = struct { 1242 session_reader: *Io.Reader, 1243 interface: Io.Reader, 1244 /// Unread remainder of the current line (points into the session 1245 /// reader's buffer, which only this reader touches during DATA). 1246 line: []const u8 = &.{}, 1247 line_ending: []const u8 = &.{}, 1248 finished: bool = false, 1249 1250 fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize { 1251 const dr: *DataReader = @alignCast(@fieldParentPtr("interface", io_r)); 1252 if (dr.line.len == 0 and dr.line_ending.len == 0) { 1253 if (dr.finished) return error.EndOfStream; 1254 const raw = protocol.readLine(dr.session_reader) catch return error.ReadFailed; 1255 if (std.mem.eql(u8, raw, ".")) { 1256 dr.finished = true; 1257 return error.EndOfStream; 1258 } 1259 dr.line = if (raw.len > 0 and raw[0] == '.') raw[1..] else raw; 1260 dr.line_ending = protocol.crlf; 1261 } 1262 const dest = limit.slice(try w.writableSliceGreedy(1)); 1263 const line_n = @min(dest.len, dr.line.len); 1264 @memcpy(dest[0..line_n], dr.line[0..line_n]); 1265 dr.line = dr.line[line_n..]; 1266 var n = line_n; 1267 if (dr.line.len == 0) { 1268 const ending_n = @min(dest.len - n, dr.line_ending.len); 1269 @memcpy(dest[n..][0..ending_n], dr.line_ending[0..ending_n]); 1270 dr.line_ending = dr.line_ending[ending_n..]; 1271 n += ending_n; 1272 } 1273 w.advance(n); 1274 return n; 1275 } 1276}; 1277 1278/// Enforces RFC 6531: a non-ASCII envelope address is only allowed when 1279/// the transaction requested SMTPUTF8, and must be well-formed UTF-8. 1280/// Replies and returns false on rejection. 1281fn validateAddress(s: *Server, path: []const u8, smtputf8: bool) error{WriteFailed}!bool { 1282 for (path) |byte| { 1283 if (byte >= 0x80) { 1284 if (!smtputf8) { 1285 try s.reply(553, "5.6.7 Non-ASCII address requires SMTPUTF8"); 1286 return false; 1287 } 1288 if (!std.unicode.utf8ValidateSlice(path)) { 1289 try s.reply(553, "5.6.7 Address is not valid UTF-8"); 1290 return false; 1291 } 1292 return true; 1293 } 1294 } 1295 return true; 1296} 1297 1298/// Answers a command that ends a pipelined group, which is every command 1299/// RFC 2920 §3.2 names as one whose reply must not be held back: EHLO, 1300/// DATA, VRFY, EXPN, TURN, QUIT and NOOP, and anything that went wrong. 1301fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 1302 try s.replyLine(code, text); 1303 try s.writer.flush(); 1304} 1305 1306/// Answers one of the commands that may appear anywhere in a pipelined 1307/// group — RSET, MAIL FROM and RCPT TO — by holding the reply back while 1308/// the client has already sent more for the server to read. 1309/// 1310/// RFC 2920 §3.2 asks for exactly this: keep those replies in a buffer so 1311/// they go out as a unit, and send everything pending the moment the input 1312/// is empty. The condition is what makes it safe rather than a deadlock — 1313/// a reply is only ever held while there is another command to answer, so 1314/// the client is never left waiting for something still in the buffer. 1315fn replyGrouped(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 1316 try s.replyLine(code, text); 1317 if (s.reader.bufferedLen() == 0) try s.writer.flush(); 1318} 1319 1320/// A reply without the flush, for when several are going out together. 1321fn replyLine(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 1322 try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text }); 1323} 1324 1325/// Answers a completed message. 1326/// 1327/// SMTP gets one reply. LMTP gets one for each previously successful RCPT, 1328/// in the order they were issued 1329/// ([RFC 2033 §4.2](https://datatracker.ietf.org/doc/html/rfc2033#section-4.2)) 1330/// — including a repeat for a recipient named twice, which is why this 1331/// walks the accepted list rather than a set of addresses. 1332fn replyMessage(s: *Server, envelope: Envelope, decision: Decision) error{WriteFailed}!void { 1333 if (s.options.protocol == .smtp) { 1334 try s.writeVerdict(decision); 1335 try s.writer.flush(); 1336 return; 1337 } 1338 for (envelope.recipients, 0..) |_, index| { 1339 // A rejected message is rejected for everybody; there is nothing 1340 // left to ask about an individual recipient. 1341 const verdict: Decision = switch (decision) { 1342 .reject => decision, 1343 .accept => if (s.handler.vtable.recipientResult) |callback| 1344 callback(s.handler.context, envelope, index) 1345 else 1346 .accept, 1347 }; 1348 try s.writeVerdict(verdict); 1349 } 1350 try s.writer.flush(); 1351} 1352 1353fn writeVerdict(s: *Server, decision: Decision) error{WriteFailed}!void { 1354 switch (decision) { 1355 .accept => try s.replyLine(250, "2.0.0 Ok, message accepted"), 1356 .reject => |r| try s.replyLine(r.code, r.text), 1357 } 1358} 1359 1360/// Discards input through the next newline after `error.LineTooLong`, which 1361/// leaves the reader positioned at the start of the oversized line. 1362fn discardLine(s: *Server) error{ReadFailed}!void { 1363 _ = s.reader.discardDelimiterInclusive('\n') catch |err| switch (err) { 1364 error.EndOfStream => {}, 1365 error.ReadFailed => return error.ReadFailed, 1366 }; 1367} 1368 1369const TestHandler = struct { 1370 from: std.ArrayList(u8) = .empty, 1371 recipients: std.ArrayList(u8) = .empty, 1372 data: std.ArrayList(u8) = .empty, 1373 messages_accepted: usize = 0, 1374 reject_recipient: ?[]const u8 = null, 1375 /// Accepted at RCPT time and then failed per-recipient at the end of 1376 /// the message, which only LMTP can express. 1377 fail_delivery: ?[]const u8 = null, 1378 /// Returned for the message as a whole, before any per-recipient 1379 /// verdict is asked for. 1380 reject_message: ?Decision.Rejection = null, 1381 declared_size: ?u64 = null, 1382 body: ?protocol.Body = null, 1383 smtputf8: bool = false, 1384 /// DSN parameters, kept from the last RCPT and the last message. The 1385 /// strings are copied because everything a callback is handed lives 1386 /// only for the duration of the call. 1387 last_notify: ?protocol.Notify = null, 1388 last_orcpt: bool = false, 1389 last_orcpt_type: std.ArrayList(u8) = .empty, 1390 last_orcpt_address: std.ArrayList(u8) = .empty, 1391 ret: ?protocol.Ret = null, 1392 require_tls: bool = false, 1393 received: std.ArrayList(u8) = .empty, 1394 submitter: ?protocol.Submitter = null, 1395 submitter_mailbox: std.ArrayList(u8) = .empty, 1396 identity: std.ArrayList(u8) = .empty, 1397 envid: std.ArrayList(u8) = .empty, 1398 /// When set, enables the authenticate callback accepting user "alice" 1399 /// with this password. 1400 password: ?[]const u8 = null, 1401 1402 fn deinit(h: *TestHandler) void { 1403 h.from.deinit(std.testing.allocator); 1404 h.recipients.deinit(std.testing.allocator); 1405 h.data.deinit(std.testing.allocator); 1406 h.envid.deinit(std.testing.allocator); 1407 h.received.deinit(std.testing.allocator); 1408 h.identity.deinit(std.testing.allocator); 1409 h.submitter_mailbox.deinit(std.testing.allocator); 1410 h.last_orcpt_type.deinit(std.testing.allocator); 1411 h.last_orcpt_address.deinit(std.testing.allocator); 1412 } 1413 1414 fn handler(h: *TestHandler) Handler { 1415 return .{ .context = h, .vtable = &.{ 1416 .rcptTo = onRcptTo, 1417 .message = onMessage, 1418 .recipientResult = onRecipientResult, 1419 } }; 1420 } 1421 1422 /// The credential check the SASL mechanisms are built from, accepting 1423 /// "alice" with whatever `password` holds. 1424 fn check(h: *TestHandler) sasl.Server.PasswordCheck { 1425 return .{ .context = h, .verify = verify }; 1426 } 1427 1428 fn verify( 1429 context: ?*anyopaque, 1430 authzid: []const u8, 1431 authcid: []const u8, 1432 password: []const u8, 1433 ) ?[]const u8 { 1434 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1435 if (authzid.len != 0) return null; 1436 if (!std.mem.eql(u8, authcid, "alice")) return null; 1437 if (!std.mem.eql(u8, password, h.password.?)) return null; 1438 return "alice"; 1439 } 1440 1441 /// LMTP's per-recipient verdict: everybody is fine except the one 1442 /// address `fail_delivery` names, which is the outcome that has no 1443 /// spelling in SMTP. 1444 fn onRecipientResult(context: ?*anyopaque, envelope: Envelope, index: usize) Decision { 1445 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1446 const failing = h.fail_delivery orelse return .accept; 1447 if (std.mem.eql(u8, envelope.recipients[index].address, failing)) 1448 return .{ .reject = .{ .code = 550, .text = "5.2.1 Mailbox disabled" } }; 1449 return .accept; 1450 } 1451 1452 fn onRcptTo(context: ?*anyopaque, recipient: Recipient) Decision { 1453 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1454 h.last_notify = recipient.notify; 1455 if (recipient.orcpt) |orcpt| { 1456 const gpa = std.testing.allocator; 1457 h.last_orcpt = true; 1458 h.last_orcpt_type.appendSlice(gpa, orcpt.addr_type) catch return .{ .reject = .{} }; 1459 h.last_orcpt_address.appendSlice(gpa, orcpt.address) catch return .{ .reject = .{} }; 1460 } 1461 if (h.reject_recipient) |rejected| { 1462 if (std.mem.eql(u8, recipient.address, rejected)) return .{ .reject = .{ 1463 .code = 550, 1464 .text = "5.1.1 No such user", 1465 } }; 1466 } 1467 return .accept; 1468 } 1469 1470 fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision { 1471 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 1472 if (h.reject_message) |rejection| return .{ .reject = rejection }; 1473 const gpa = std.testing.allocator; 1474 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} }; 1475 for (envelope.recipients) |recipient| { 1476 h.recipients.appendSlice(gpa, recipient.address) catch return .{ .reject = .{} }; 1477 h.recipients.append(gpa, ';') catch return .{ .reject = .{} }; 1478 } 1479 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} }; 1480 h.messages_accepted += 1; 1481 h.declared_size = envelope.declared_size; 1482 h.body = envelope.body; 1483 h.smtputf8 = envelope.smtputf8; 1484 h.ret = envelope.ret; 1485 h.submitter = envelope.submitter; 1486 h.require_tls = envelope.require_tls; 1487 h.received.appendSlice(gpa, envelope.received) catch return .{ .reject = .{} }; 1488 if (envelope.submitter) |who| switch (who) { 1489 // Copied: it points into the session arena, which is reset the 1490 // moment this transaction ends. 1491 .mailbox => |mailbox| h.submitter_mailbox.appendSlice(gpa, mailbox) catch 1492 return .{ .reject = .{} }, 1493 .unknown => {}, 1494 }; 1495 if (envelope.authenticated_as) |who| 1496 h.identity.appendSlice(gpa, who) catch return .{ .reject = .{} }; 1497 if (envelope.envid) |envid| h.envid.appendSlice(gpa, envid) catch return .{ .reject = .{} }; 1498 return .accept; 1499 } 1500}; 1501 1502/// A writer that records where its flush boundaries fell, so that a test 1503/// can tell one reply per write from several replies in one. 1504const BatchingWriter = struct { 1505 interface: Io.Writer, 1506 sink: std.ArrayList(u8) = .empty, 1507 /// The bytes handed over at each drain — one entry per effective flush. 1508 batches: std.ArrayList(usize) = .empty, 1509 1510 fn init(buffer: []u8) BatchingWriter { 1511 return .{ .interface = .{ 1512 .buffer = buffer, 1513 .vtable = &.{ .drain = drain }, 1514 .end = 0, 1515 } }; 1516 } 1517 1518 fn deinit(bw: *BatchingWriter) void { 1519 bw.sink.deinit(std.testing.allocator); 1520 bw.batches.deinit(std.testing.allocator); 1521 } 1522 1523 fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize { 1524 const bw: *BatchingWriter = @alignCast(@fieldParentPtr("interface", w)); 1525 const gpa = std.testing.allocator; 1526 var handed: usize = w.buffered().len; 1527 bw.sink.appendSlice(gpa, w.buffered()) catch return error.WriteFailed; 1528 w.end = 0; 1529 var n: usize = 0; 1530 if (chunks.len > 0) { 1531 for (chunks[0 .. chunks.len - 1]) |bytes| { 1532 bw.sink.appendSlice(gpa, bytes) catch return error.WriteFailed; 1533 n += bytes.len; 1534 } 1535 const pattern = chunks[chunks.len - 1]; 1536 for (0..splat) |_| { 1537 bw.sink.appendSlice(gpa, pattern) catch return error.WriteFailed; 1538 n += pattern.len; 1539 } 1540 } 1541 handed += n; 1542 if (handed > 0) bw.batches.append(gpa, handed) catch return error.WriteFailed; 1543 return n; 1544 } 1545}; 1546 1547test "replies to a pipelined group go out together and in order" { 1548 var h: TestHandler = .{}; 1549 defer h.deinit(); 1550 1551 // One group: MAIL, two RCPTs and DATA, which RFC 2920 §3.1 allows as 1552 // the last command of one. A fixed reader has the whole session 1553 // buffered, which is what a client that pipelines looks like. 1554 var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ 1555 "MAIL FROM:<alice@example.com>\r\n" ++ 1556 "RCPT TO:<bob@example.net>\r\n" ++ 1557 "RCPT TO:<carol@example.net>\r\n" ++ 1558 "DATA\r\nhi\r\n.\r\nQUIT\r\n"); 1559 var buffer: [4096]u8 = undefined; 1560 var bw: BatchingWriter = .init(&buffer); 1561 defer bw.deinit(); 1562 1563 var session: Server = .init(&reader, &bw.interface, h.handler(), .{ .hostname = "mx.test" }); 1564 try session.run(std.testing.allocator); 1565 1566 // Order first: every reply is there, once, in the order asked for. 1567 const out = bw.sink.items; 1568 const envelope_replies = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n" ++ 1569 "354 End data with <CR><LF>.<CR><LF>\r\n"; 1570 try std.testing.expect(std.mem.indexOf(u8, out, envelope_replies) != null); 1571 1572 // And batching: the three envelope replies were held back and left 1573 // with the 354, rather than going out one at a time. There are five 1574 // replies after the greeting and the EHLO response, and fewer writes. 1575 // And batching: eight replies left in five writes, because the three 1576 // envelope replies were held back and went out with the 354 as one. 1577 // The others are the greeting, the EHLO response, the message verdict 1578 // and the goodbye — all of which RFC 2920 §3.2 says must not be held. 1579 try std.testing.expectEqual(@as(usize, 5), bw.batches.items.len); 1580 try std.testing.expectEqual(envelope_replies.len, bw.batches.items[2]); 1581} 1582 1583test "a held reply is released as soon as there is nothing left to read" { 1584 var h: TestHandler = .{}; 1585 defer h.deinit(); 1586 1587 // MAIL alone: its reply may not be held, because nothing follows it in 1588 // the buffer and the client is waiting for it. 1589 var reader: Io.Reader = .fixed("EHLO client.example.org\r\nMAIL FROM:<a@example.com>\r\n"); 1590 var buffer: [4096]u8 = undefined; 1591 var bw: BatchingWriter = .init(&buffer); 1592 defer bw.deinit(); 1593 1594 var session: Server = .init(&reader, &bw.interface, h.handler(), .{ .hostname = "mx.test" }); 1595 try session.run(std.testing.allocator); 1596 1597 try std.testing.expect(std.mem.endsWith(u8, bw.sink.items, "250 2.1.0 Ok\r\n")); 1598} 1599 1600/// The mechanisms a test session offers, built from a `TestHandler`'s 1601/// credential check. They hold per-exchange state, so each test makes its 1602/// own rather than sharing a constant. 1603const TestMechanisms = struct { 1604 plain: sasl.PlainServer, 1605 login: sasl.LoginServer, 1606 storage: [2]sasl.Server = undefined, 1607 /// The scratch a session needs to run them, which `Options` takes from 1608 /// the caller rather than putting on the stack. 1609 buffer: [sasl_buffer_suggested]u8 = undefined, 1610 1611 fn init(h: *TestHandler) TestMechanisms { 1612 return .{ .plain = .init(h.check()), .login = .init(h.check()) }; 1613 } 1614 1615 fn list(m: *TestMechanisms) []const sasl.Server { 1616 m.storage = .{ m.plain.server(), m.login.server() }; 1617 return &m.storage; 1618 } 1619 1620 fn scratch(m: *TestMechanisms) []u8 { 1621 return &m.buffer; 1622 } 1623}; 1624 1625fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 { 1626 var reader: Io.Reader = .fixed(input); 1627 var writer: Io.Writer = .fixed(out_buf); 1628 var session: Server = .init(&reader, &writer, handler, options); 1629 try session.run(std.testing.allocator); 1630 return writer.buffered(); 1631} 1632 1633test "BINARYMIME is advertised, accepted, and refused on DATA" { 1634 var h: TestHandler = .{}; 1635 defer h.deinit(); 1636 1637 var out_buf: [4096]u8 = undefined; 1638 const out = try runScript( 1639 "EHLO client.example.org\r\n" ++ 1640 "MAIL FROM:<alice@example.com> BODY=BINARYMIME\r\n" ++ 1641 "RCPT TO:<bob@example.net>\r\n" ++ 1642 "DATA\r\n" ++ // 503: binary content cannot be framed by a dot 1643 "BDAT 5 LAST\r\n\x00\r\n.\r\nQUIT\r\n", 1644 &out_buf, 1645 h.handler(), 1646 .{ .hostname = "mx.test" }, 1647 ); 1648 1649 // RFC 3030: BINARYMIME may only be offered alongside CHUNKING. 1650 try std.testing.expect(std.mem.indexOf(u8, out, "250-BINARYMIME\r\n") != null); 1651 try std.testing.expect(std.mem.indexOf(u8, out, "250-CHUNKING\r\n") != null); 1652 try std.testing.expect(std.mem.indexOf(u8, out, "503 5.5.1 BINARYMIME requires BDAT") != null); 1653 try std.testing.expectEqual(protocol.Body.binary_mime, h.body.?); 1654 // Five octets, delivered as they were sent: a NUL, and a lone dot on a 1655 // line of its own, which over DATA would have ended the message. 1656 try std.testing.expectEqualStrings("\x00\r\n.\r", h.data.items); 1657 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1658} 1659 1660test "every octet survives a binary chunk" { 1661 var h: TestHandler = .{}; 1662 defer h.deinit(); 1663 1664 // All 256 byte values, which is the "preserve all bits in each octet" 1665 // requirement of RFC 3030 §5 stated as a test. 1666 const octets = comptime blk: { 1667 var all: [256]u8 = undefined; 1668 for (&all, 0..) |*byte, i| byte.* = @intCast(i); 1669 break :blk all; 1670 }; 1671 1672 var out_buf: [4096]u8 = undefined; 1673 _ = try runScript( 1674 "EHLO client.example.org\r\n" ++ 1675 "MAIL FROM:<alice@example.com> BODY=BINARYMIME\r\n" ++ 1676 "RCPT TO:<bob@example.net>\r\n" ++ 1677 "BDAT 256 LAST\r\n" ++ octets ++ "QUIT\r\n", 1678 &out_buf, 1679 h.handler(), 1680 .{ .hostname = "mx.test" }, 1681 ); 1682 try std.testing.expectEqualSlices(u8, &octets, h.data.items); 1683} 1684 1685test "LMTP answers once per accepted recipient" { 1686 var h: TestHandler = .{ .fail_delivery = "bad@example.net" }; 1687 defer h.deinit(); 1688 1689 var out_buf: [2048]u8 = undefined; 1690 const out = try runScript( 1691 "LHLO client.example.org\r\n" ++ 1692 "MAIL FROM:<alice@example.com>\r\n" ++ 1693 "RCPT TO:<good@example.net>\r\n" ++ 1694 "RCPT TO:<bad@example.net>\r\n" ++ 1695 // RFC 2033 §4.2 is explicit that a repeated forward-path still 1696 // gets a reply of its own. 1697 "RCPT TO:<good@example.net>\r\n" ++ 1698 "DATA\r\nhi\r\n.\r\nQUIT\r\n", 1699 &out_buf, 1700 h.handler(), 1701 .{ .protocol = .lmtp, .hostname = "mx.test" }, 1702 ); 1703 1704 const tail = out[std.mem.indexOf(u8, out, "354").?..]; 1705 try std.testing.expectEqualStrings( 1706 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 1707 "250 2.0.0 Ok, message accepted\r\n" ++ 1708 "550 5.2.1 Mailbox disabled\r\n" ++ 1709 "250 2.0.0 Ok, message accepted\r\n" ++ 1710 "221 2.0.0 Bye\r\n", 1711 tail, 1712 ); 1713} 1714 1715test "a message rejected outright is rejected for every LMTP recipient" { 1716 var h: TestHandler = .{ 1717 .reject_message = .{ .code = 452, .text = "4.3.1 Out of storage" }, 1718 }; 1719 defer h.deinit(); 1720 1721 var out_buf: [2048]u8 = undefined; 1722 const out = try runScript( 1723 "LHLO client.example.org\r\n" ++ 1724 "MAIL FROM:<alice@example.com>\r\n" ++ 1725 "RCPT TO:<a@example.net>\r\n" ++ 1726 "RCPT TO:<b@example.net>\r\n" ++ 1727 "DATA\r\nhi\r\n.\r\nQUIT\r\n", 1728 &out_buf, 1729 h.handler(), 1730 .{ .protocol = .lmtp, .hostname = "mx.test" }, 1731 ); 1732 1733 const tail = out[std.mem.indexOf(u8, out, "354").?..]; 1734 try std.testing.expectEqualStrings( 1735 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 1736 "452 4.3.1 Out of storage\r\n" ++ 1737 "452 4.3.1 Out of storage\r\n" ++ 1738 "221 2.0.0 Bye\r\n", 1739 tail, 1740 ); 1741} 1742 1743test "BDAT LAST also answers once per LMTP recipient" { 1744 var h: TestHandler = .{ .fail_delivery = "bad@example.net" }; 1745 defer h.deinit(); 1746 1747 var out_buf: [2048]u8 = undefined; 1748 const out = try runScript( 1749 "LHLO client.example.org\r\n" ++ 1750 "MAIL FROM:<alice@example.com>\r\n" ++ 1751 "RCPT TO:<good@example.net>\r\n" ++ 1752 "RCPT TO:<bad@example.net>\r\n" ++ 1753 "BDAT 4 LAST\r\nhi\r\nQUIT\r\n", 1754 &out_buf, 1755 h.handler(), 1756 .{ .protocol = .lmtp, .hostname = "mx.test" }, 1757 ); 1758 1759 const tail = out[std.mem.lastIndexOf(u8, out, "250 2.1.5 Ok\r\n").? + "250 2.1.5 Ok\r\n".len ..]; 1760 try std.testing.expectEqualStrings( 1761 "250 2.0.0 Ok, message accepted\r\n" ++ 1762 "550 5.2.1 Mailbox disabled\r\n" ++ 1763 "221 2.0.0 Bye\r\n", 1764 tail, 1765 ); 1766} 1767 1768test "each protocol refuses the other's greeting" { 1769 var h: TestHandler = .{}; 1770 defer h.deinit(); 1771 1772 var out_buf: [2048]u8 = undefined; 1773 // RFC 2033 §4: an LMTP server must not answer HELO or EHLO positively. 1774 const lmtp = try runScript( 1775 "EHLO client.example.org\r\nHELO client.example.org\r\nQUIT\r\n", 1776 &out_buf, 1777 h.handler(), 1778 .{ .protocol = .lmtp, .hostname = "mx.test" }, 1779 ); 1780 try std.testing.expectEqualStrings( 1781 "220 mx.test ESMTP ready\r\n" ++ 1782 "500 5.5.1 This is LMTP, use LHLO\r\n" ++ 1783 "500 5.5.1 This is LMTP, use LHLO\r\n" ++ 1784 "221 2.0.0 Bye\r\n", 1785 lmtp, 1786 ); 1787 1788 var smtp_buf: [2048]u8 = undefined; 1789 const smtp = try runScript( 1790 "LHLO client.example.org\r\nQUIT\r\n", 1791 &smtp_buf, 1792 h.handler(), 1793 .{ .hostname = "mx.test" }, 1794 ); 1795 try std.testing.expectEqualStrings( 1796 "220 mx.test ESMTP ready\r\n" ++ 1797 "500 5.5.2 Command not recognized\r\n" ++ 1798 "221 2.0.0 Bye\r\n", 1799 smtp, 1800 ); 1801} 1802 1803test "LHLO advertises what LMTP requires" { 1804 var h: TestHandler = .{}; 1805 defer h.deinit(); 1806 1807 var out_buf: [2048]u8 = undefined; 1808 const out = try runScript( 1809 "LHLO client.example.org\r\nQUIT\r\n", 1810 &out_buf, 1811 h.handler(), 1812 .{ .protocol = .lmtp, .hostname = "mx.test" }, 1813 ); 1814 // RFC 2033 §5 requires both of these of an LMTP server. 1815 try std.testing.expect(std.mem.indexOf(u8, out, "250-PIPELINING\r\n") != null); 1816 try std.testing.expect(std.mem.indexOf(u8, out, "250-ENHANCEDSTATUSCODES\r\n") != null); 1817} 1818 1819test "DSN parameters reach the handler" { 1820 var h: TestHandler = .{}; 1821 defer h.deinit(); 1822 1823 var out_buf: [2048]u8 = undefined; 1824 const out = try runScript( 1825 "EHLO client.example.org\r\n" ++ 1826 "MAIL FROM:<alice@example.com> RET=HDRS ENVID=batch+207\r\n" ++ 1827 "RCPT TO:<bob@example.net> NOTIFY=SUCCESS,FAILURE ORCPT=rfc822;team@example.net\r\n" ++ 1828 "DATA\r\nhi\r\n.\r\nQUIT\r\n", 1829 &out_buf, 1830 h.handler(), 1831 .{ .hostname = "mx.test" }, 1832 ); 1833 1834 // Nothing in the session was refused. 1835 try std.testing.expect(std.mem.indexOf(u8, out, "\r\n5") == null); 1836 try std.testing.expectEqual(protocol.Ret.hdrs, h.ret.?); 1837 // The ENVID arrives xtext-decoded: "batch+207" carried a space. 1838 try std.testing.expectEqualStrings("batch 7", h.envid.items); 1839 const notify = h.last_notify.?; 1840 try std.testing.expect(notify.on.success and notify.on.failure and !notify.on.delay); 1841 try std.testing.expect(h.last_orcpt); 1842 try std.testing.expectEqualStrings("rfc822", h.last_orcpt_type.items); 1843 try std.testing.expectEqualStrings("team@example.net", h.last_orcpt_address.items); 1844} 1845 1846test "the DSN extension is advertised and its parameters are validated" { 1847 var h: TestHandler = .{}; 1848 defer h.deinit(); 1849 1850 var out_buf: [2048]u8 = undefined; 1851 const out = try runScript( 1852 "EHLO client.example.org\r\n" ++ 1853 "MAIL FROM:<a@example.com> RET=PARTIAL\r\n" ++ // 501: not FULL or HDRS 1854 "MAIL FROM:<a@example.com> ENVID=bad+ZZ\r\n" ++ // 501: not xtext 1855 "MAIL FROM:<a@example.com> ENVID=" ++ ("x" ** 101) ++ "\r\n" ++ // 501: too long 1856 "MAIL FROM:<a@example.com>\r\n" ++ 1857 "RCPT TO:<b@example.net> NOTIFY=NEVER,SUCCESS\r\n" ++ // 501: NEVER stands alone 1858 "RCPT TO:<b@example.net> NOTIFY=SOMETIMES\r\n" ++ // 501: not a keyword 1859 "RCPT TO:<b@example.net> ORCPT=team@example.net\r\n" ++ // 501: no addr-type 1860 "RCPT TO:<b@example.net> FROB=1\r\n" ++ // 555: still unrecognized 1861 "QUIT\r\n", 1862 &out_buf, 1863 h.handler(), 1864 .{ .hostname = "mx.test" }, 1865 ); 1866 1867 try std.testing.expect(std.mem.indexOf(u8, out, "250-DSN\r\n") != null); 1868 var replies = std.mem.splitSequence(u8, out, "\r\n"); 1869 var codes: std.ArrayList([]const u8) = .empty; 1870 defer codes.deinit(std.testing.allocator); 1871 while (replies.next()) |line| { 1872 if (line.len >= 4 and line[3] == ' ') try codes.append(std.testing.allocator, line[0..3]); 1873 } 1874 // 220 greeting, 250 EHLO, then the parameter verdicts, then 221. 1875 try std.testing.expectEqualStrings("220", codes.items[0]); 1876 try std.testing.expectEqualStrings("250", codes.items[1]); 1877 try std.testing.expectEqualStrings("501", codes.items[2]); 1878 try std.testing.expectEqualStrings("501", codes.items[3]); 1879 try std.testing.expectEqualStrings("501", codes.items[4]); 1880 try std.testing.expectEqualStrings("250", codes.items[5]); 1881 try std.testing.expectEqualStrings("501", codes.items[6]); 1882 try std.testing.expectEqualStrings("501", codes.items[7]); 1883 try std.testing.expectEqualStrings("501", codes.items[8]); 1884 try std.testing.expectEqualStrings("555", codes.items[9]); 1885 try std.testing.expectEqualStrings("221", codes.items[10]); 1886} 1887 1888test run { 1889 var h: TestHandler = .{}; 1890 defer h.deinit(); 1891 1892 var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ 1893 "MAIL FROM:<alice@example.com>\r\n" ++ 1894 "RCPT TO:<bob@example.net>\r\n" ++ 1895 "RCPT TO:<carol@example.net>\r\n" ++ 1896 "DATA\r\n" ++ 1897 "Subject: hi\r\n" ++ 1898 "\r\n" ++ 1899 "..stuffed line\r\n" ++ 1900 "body\r\n" ++ 1901 ".\r\n" ++ 1902 "QUIT\r\n"); 1903 var out_buf: [1024]u8 = undefined; 1904 var writer: Io.Writer = .fixed(&out_buf); 1905 1906 var session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" }); 1907 try session.run(std.testing.allocator); 1908 const output = writer.buffered(); 1909 1910 try std.testing.expectEqualStrings("alice@example.com", h.from.items); 1911 try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items); 1912 try std.testing.expectEqualStrings("Subject: hi\r\n\r\n.stuffed line\r\nbody\r\n", h.data.items); 1913 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1914 1915 try std.testing.expectEqualStrings( 1916 "220 mx.test ESMTP ready\r\n" ++ 1917 "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-BINARYMIME\r\n250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n250 SIZE 16777216\r\n" ++ 1918 "250 2.1.0 Ok\r\n" ++ 1919 "250 2.1.5 Ok\r\n" ++ 1920 "250 2.1.5 Ok\r\n" ++ 1921 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 1922 "250 2.0.0 Ok, message accepted\r\n" ++ 1923 "221 2.0.0 Bye\r\n", 1924 output, 1925 ); 1926} 1927 1928test "command sequencing is enforced" { 1929 var h: TestHandler = .{}; 1930 defer h.deinit(); 1931 1932 var out_buf: [1024]u8 = undefined; 1933 const output = try runScript( 1934 "MAIL FROM:<early@example.com>\r\n" ++ 1935 "EHLO client.example.org\r\n" ++ 1936 "RCPT TO:<bob@example.net>\r\n" ++ 1937 "DATA\r\n" ++ 1938 "QUIT\r\n", 1939 &out_buf, 1940 h.handler(), 1941 .{}, 1942 ); 1943 1944 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 1945 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Send EHLO first") != null); 1946 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null); 1947 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null); 1948} 1949 1950test "handler can reject a recipient" { 1951 var h: TestHandler = .{ .reject_recipient = "nobody@example.net" }; 1952 defer h.deinit(); 1953 1954 var out_buf: [1024]u8 = undefined; 1955 const output = try runScript( 1956 "EHLO client.example.org\r\n" ++ 1957 "MAIL FROM:<alice@example.com>\r\n" ++ 1958 "RCPT TO:<nobody@example.net>\r\n" ++ 1959 "RCPT TO:<bob@example.net>\r\n" ++ 1960 "DATA\r\n" ++ 1961 "hello\r\n" ++ 1962 ".\r\n" ++ 1963 "QUIT\r\n", 1964 &out_buf, 1965 h.handler(), 1966 .{}, 1967 ); 1968 1969 try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null); 1970 try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items); 1971 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1972} 1973 1974test "AUTH PLAIN with initial response" { 1975 var h: TestHandler = .{ .password = "secret" }; 1976 defer h.deinit(); 1977 var mechanisms: TestMechanisms = .init(&h); 1978 1979 var out_buf: [1024]u8 = undefined; 1980 // base64("\x00alice\x00secret") 1981 const output = try runScript( 1982 "EHLO client.example.org\r\n" ++ 1983 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ 1984 "MAIL FROM:<alice@example.com>\r\n" ++ 1985 "RCPT TO:<bob@example.net>\r\n" ++ 1986 "DATA\r\nauthed mail\r\n.\r\n" ++ 1987 "QUIT\r\n", 1988 &out_buf, 1989 h.handler(), 1990 .{ .require_auth = true, .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 1991 ); 1992 1993 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); 1994 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 1995 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 1996} 1997 1998test "AUTH LOGIN challenge exchange" { 1999 var h: TestHandler = .{ .password = "secret" }; 2000 defer h.deinit(); 2001 var mechanisms: TestMechanisms = .init(&h); 2002 2003 var out_buf: [1024]u8 = undefined; 2004 // base64("alice"), base64("secret") 2005 const output = try runScript( 2006 "EHLO client.example.org\r\n" ++ 2007 "AUTH LOGIN\r\n" ++ 2008 "YWxpY2U=\r\n" ++ 2009 "c2VjcmV0\r\n" ++ 2010 "QUIT\r\n", 2011 &out_buf, 2012 h.handler(), 2013 .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 2014 ); 2015 2016 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null); 2017 try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null); 2018 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 2019} 2020 2021test "REQUIRETLS is offered and honoured on a TLS session" { 2022 var h: TestHandler = .{}; 2023 defer h.deinit(); 2024 2025 var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ 2026 "MAIL FROM:<a@example.com> REQUIRETLS\r\n" ++ 2027 "RCPT TO:<b@example.net>\r\n" ++ 2028 "DATA\r\nsecret\r\n.\r\n" ++ 2029 "MAIL FROM:<a@example.com> REQUIRETLS=yes\r\n"); // 501: it takes no value 2030 // No QUIT: `secured` here is a stand-in for a handshake that never 2031 // happened, and QUIT would close a TLS connection that was never opened. 2032 var out_buf: [4096]u8 = undefined; 2033 var writer: Io.Writer = .fixed(&out_buf); 2034 var session: Server = .init(&reader, &writer, h.handler(), .{ .requiretls = true }); 2035 // Stand in for a completed handshake: what the advertising rule turns on 2036 // is that the session employs TLS, not how it came to. 2037 session.secured = true; 2038 try session.run(std.testing.allocator); 2039 const output = writer.buffered(); 2040 2041 try std.testing.expect(std.mem.indexOf(u8, output, "250-REQUIRETLS\r\n") != null); 2042 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.4 REQUIRETLS takes no value") != null); 2043 // And the sender's requirement reached the handler, which is the only 2044 // place it can be acted on: this library does not relay. 2045 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2046 try std.testing.expect(h.require_tls); 2047} 2048 2049test "the requirement does not leak into the next transaction" { 2050 var h: TestHandler = .{}; 2051 defer h.deinit(); 2052 2053 var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ 2054 "MAIL FROM:<a@example.com> REQUIRETLS\r\n" ++ 2055 "RSET\r\n" ++ 2056 "MAIL FROM:<a@example.com>\r\n" ++ 2057 "RCPT TO:<b@example.net>\r\n" ++ 2058 "DATA\r\nordinary\r\n.\r\n"); // no QUIT; see the test above 2059 var out_buf: [4096]u8 = undefined; 2060 var writer: Io.Writer = .fixed(&out_buf); 2061 var session: Server = .init(&reader, &writer, h.handler(), .{ .requiretls = true }); 2062 session.secured = true; 2063 try session.run(std.testing.allocator); 2064 2065 // A requirement asserted for one message says nothing about the next, 2066 // and carrying it over would be a promise nobody made. 2067 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2068 try std.testing.expect(!h.require_tls); 2069} 2070 2071test "the Received field says what the session was" { 2072 var h: TestHandler = .{}; 2073 defer h.deinit(); 2074 2075 var out_buf: [4096]u8 = undefined; 2076 _ = try runScript( 2077 "EHLO client.example.org\r\n" ++ 2078 "MAIL FROM:<a@example.com>\r\n" ++ 2079 "RCPT TO:<b@example.net>\r\n" ++ 2080 "DATA\r\nbody\r\n.\r\nQUIT\r\n", 2081 &out_buf, 2082 h.handler(), 2083 .{ 2084 .hostname = "mx.test", 2085 .received = .{ 2086 .io = std.testing.io, 2087 .peer = "client.example.org [192.0.2.1]", 2088 .by_info = "zig-smtp", 2089 }, 2090 }, 2091 ); 2092 2093 const field = h.received.items; 2094 try std.testing.expect(std.mem.startsWith(u8, field, "Received: from client.example.org")); 2095 // What the peer said, then what this server observed about it -- the 2096 // second being the half worth believing. 2097 try std.testing.expect(std.mem.indexOf(u8, field, "(client.example.org [192.0.2.1])") != null); 2098 try std.testing.expect(std.mem.indexOf(u8, field, "by mx.test (zig-smtp)") != null); 2099 // Plain ESMTP: no TLS, no AUTH, not LMTP. Getting this wrong is the 2100 // thing `protocolFor` exists to prevent. 2101 // Followed by the fold before `for`, not a space -- and the exact 2102 // string matters: ESMTPS, ESMTPA and ESMTPSA would all contain "ESMTP". 2103 try std.testing.expect(std.mem.indexOf(u8, field, "with ESMTP\r\n") != null); 2104 // Exactly one recipient, so RFC 5321 §4.4 permits naming it. 2105 try std.testing.expect(std.mem.indexOf(u8, field, "for <b@example.net>") != null); 2106 try std.testing.expect(std.mem.endsWith(u8, field, "\r\n")); 2107} 2108 2109test "a greeting that would forge a header is escaped, not trusted" { 2110 var h: TestHandler = .{}; 2111 defer h.deinit(); 2112 2113 // The greeting name is a string the peer chose, and it goes into a 2114 // header. A client that ends the field early could append fields of its 2115 // own -- a Bcc, a Return-Path -- to every message it sends. 2116 var out_buf: [4096]u8 = undefined; 2117 _ = try runScript( 2118 "EHLO evil\tBcc:victim@example.net\r\n" ++ 2119 "MAIL FROM:<a@example.com>\r\n" ++ 2120 "RCPT TO:<b@example.net>\r\n" ++ 2121 "DATA\r\nbody\r\n.\r\nQUIT\r\n", 2122 &out_buf, 2123 h.handler(), 2124 .{ .hostname = "mx.test", .received = .{ .io = std.testing.io } }, 2125 ); 2126 2127 const field = h.received.items; 2128 // Exactly one line ending, at the end: the field is one field. 2129 try std.testing.expectEqual( 2130 @as(usize, 1), 2131 std.mem.count(u8, field, protocol.crlf) - std.mem.count(u8, field, "\r\n\t"), 2132 ); 2133 try std.testing.expect(std.mem.indexOf(u8, field, "\r\nBcc:") == null); 2134} 2135 2136test "several recipients mean no for clause" { 2137 var h: TestHandler = .{}; 2138 defer h.deinit(); 2139 2140 var out_buf: [4096]u8 = undefined; 2141 _ = try runScript( 2142 "EHLO client.example.org\r\n" ++ 2143 "MAIL FROM:<a@example.com>\r\n" ++ 2144 "RCPT TO:<b@example.net>\r\n" ++ 2145 "RCPT TO:<c@example.net>\r\n" ++ 2146 "DATA\r\nbody\r\n.\r\nQUIT\r\n", 2147 &out_buf, 2148 h.handler(), 2149 .{ .hostname = "mx.test", .received = .{ .io = std.testing.io } }, 2150 ); 2151 // RFC 5321 §4.4 allows `for` only with exactly one recipient, because 2152 // naming several tells each of them who the others were. 2153 try std.testing.expect(std.mem.indexOf(u8, h.received.items, "for <") == null); 2154} 2155 2156test "no ReceivedOptions means nothing is stamped" { 2157 var h: TestHandler = .{}; 2158 defer h.deinit(); 2159 2160 var out_buf: [4096]u8 = undefined; 2161 _ = try runScript( 2162 "EHLO client.example.org\r\nMAIL FROM:<a@example.com>\r\n" ++ 2163 "RCPT TO:<b@example.net>\r\nDATA\r\nbody\r\n.\r\nQUIT\r\n", 2164 &out_buf, 2165 h.handler(), 2166 .{}, 2167 ); 2168 try std.testing.expectEqualStrings("", h.received.items); 2169} 2170 2171test "REQUIRETLS is not offered without TLS, and not taken without being offered" { 2172 var h: TestHandler = .{}; 2173 defer h.deinit(); 2174 2175 var out_buf: [2048]u8 = undefined; 2176 const output = try runScript( 2177 "EHLO client.example.org\r\n" ++ 2178 "MAIL FROM:<a@example.com> REQUIRETLS\r\n" ++ 2179 "QUIT\r\n", 2180 &out_buf, 2181 h.handler(), 2182 // The option is on; the session is not TLS. 2183 .{ .requiretls = true }, 2184 ); 2185 2186 // RFC 8689 §4 ties the keyword to a session that employs TLS, so this 2187 // one must not offer it... 2188 try std.testing.expect(std.mem.indexOf(u8, output, "250-REQUIRETLS\r\n") == null); 2189 // ...and a parameter that was never advertised is 555, per RFC 5321 2190 // §4.1.1.11, rather than being taken and quietly not honoured. Silently 2191 // accepting it would turn a sender's refusal to be downgraded into a 2192 // downgrade. 2193 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); 2194 try std.testing.expect(!h.require_tls); 2195} 2196 2197test "a server that does not promise REQUIRETLS does not advertise it" { 2198 var h: TestHandler = .{}; 2199 defer h.deinit(); 2200 2201 var out_buf: [2048]u8 = undefined; 2202 const output = try runScript( 2203 "EHLO client.example.org\r\nQUIT\r\n", 2204 &out_buf, 2205 h.handler(), 2206 .{}, // requiretls defaults false: the promise is opt-in 2207 ); 2208 try std.testing.expect(std.mem.indexOf(u8, output, "REQUIRETLS") == null); 2209} 2210 2211test "EXPN is declined rather than disowned" { 2212 var h: TestHandler = .{}; 2213 defer h.deinit(); 2214 2215 var out_buf: [2048]u8 = undefined; 2216 const output = try runScript( 2217 "EHLO client.example.org\r\n" ++ 2218 "EXPN staff\r\n" ++ // 502: known, not implemented 2219 "VRFY somebody@example.net\r\n" ++ // 252: will not check, will take 2220 "EXPN\r\n" ++ // 501: the argument is not optional 2221 "VRFY\r\n" ++ // 501: likewise 2222 "FROB list\r\n" ++ // 500: genuinely never heard of 2223 "QUIT\r\n", 2224 &out_buf, 2225 h.handler(), 2226 .{}, 2227 ); 2228 2229 var replies = std.mem.splitSequence(u8, output, "\r\n"); 2230 var codes: std.ArrayList([]const u8) = .empty; 2231 defer codes.deinit(std.testing.allocator); 2232 while (replies.next()) |line| { 2233 if (line.len >= 4 and line[3] == ' ') try codes.append(std.testing.allocator, line[0..3]); 2234 } 2235 // 220 greeting, 250 EHLO, then the verdicts, then 221. 2236 try std.testing.expectEqualStrings("502", codes.items[2]); 2237 try std.testing.expectEqualStrings("252", codes.items[3]); 2238 try std.testing.expectEqualStrings("501", codes.items[4]); 2239 try std.testing.expectEqualStrings("501", codes.items[5]); 2240 // The distinction that matters: 500 is "I have never heard of that", 2241 // 502 is "I know it and will not do it", and only one of them is true 2242 // of EXPN here. 2243 try std.testing.expectEqualStrings("500", codes.items[6]); 2244 try std.testing.expect(std.mem.indexOf(u8, output, "502 5.5.1 EXPN not implemented") != null); 2245} 2246 2247test "every reply that should carry an enhanced status code does" { 2248 // RFC 2034 §4: a server implementing the extension prefaces the text of 2249 // every 2xx, 4xx and 5xx reply with a status code whose class agrees -- 2250 // except the greeting, the response to HELO or EHLO, and any 3xx. This 2251 // walks a session that touches most of the command table and checks the 2252 // whole transcript against that rule rather than reply by reply. 2253 var h: TestHandler = .{ .password = "secret" }; 2254 defer h.deinit(); 2255 var mechanisms: TestMechanisms = .init(&h); 2256 2257 var out_buf: [8192]u8 = undefined; 2258 const output = try runScript( 2259 "EHLO client.example.org\r\n" ++ 2260 "NOOP\r\n" ++ 2261 "VRFY somebody\r\n" ++ 2262 "EXPN staff\r\n" ++ // 502 2263 "HELP\r\n" ++ 2264 "WHAT\r\n" ++ // 500 2265 "MAIL FROM:<a@example.com> FROB=1\r\n" ++ // 555 2266 "MAIL FROM:<a@example.com> SIZE=99999999\r\n" ++ // 552 2267 "RCPT TO:<b@example.net>\r\n" ++ // 503, no MAIL yet 2268 "AUTH GSSAPI\r\n" ++ // 504 2269 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // 535 2270 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // 235 2271 "MAIL FROM:<a@example.com>\r\n" ++ 2272 "RCPT TO:<b@example.net>\r\n" ++ 2273 "DATA\r\nbody\r\n.\r\n" ++ 2274 "RSET\r\n" ++ 2275 "QUIT\r\n", 2276 &out_buf, 2277 h.handler(), 2278 .{ 2279 .max_message_size = 1024, 2280 .auth_mechanisms = mechanisms.list(), 2281 .sasl_buffer = mechanisms.scratch(), 2282 }, 2283 ); 2284 2285 var checked: usize = 0; 2286 var greeting = true; 2287 var in_ehlo = false; 2288 var lines = std.mem.splitSequence(u8, output, "\r\n"); 2289 while (lines.next()) |line| { 2290 if (line.len < 4) continue; 2291 const code = std.fmt.parseInt(u16, line[0..3], 10) catch continue; 2292 const continued = line[3] == '-'; 2293 const text = line[4..]; 2294 2295 // The exclusions, in the order a session meets them. 2296 if (greeting) { 2297 greeting = false; 2298 continue; 2299 } 2300 if (in_ehlo or (code == 250 and continued)) { 2301 in_ehlo = continued; 2302 continue; 2303 } 2304 if (code / 100 == 3) { 2305 // 354 and the 334 challenges, which RFC 2034 leaves out. 2306 try std.testing.expectEqual(@as(?protocol.Enhanced, null), protocol.Enhanced.parse(text)); 2307 continue; 2308 } 2309 2310 const status = protocol.Enhanced.parse(text) orelse { 2311 std.debug.print("no enhanced status code: {s}\n", .{line}); 2312 return error.TestUnexpectedResult; 2313 }; 2314 if (!status.agrees(code)) { 2315 std.debug.print("class disagrees with the reply code: {s}\n", .{line}); 2316 return error.TestUnexpectedResult; 2317 } 2318 checked += 1; 2319 } 2320 // Enough of them to mean the walk actually walked. 2321 try std.testing.expect(checked >= 14); 2322} 2323 2324test "an authenticated client's AUTH= assertion reaches the handler" { 2325 var h: TestHandler = .{ .password = "secret" }; 2326 defer h.deinit(); 2327 var mechanisms: TestMechanisms = .init(&h); 2328 2329 var out_buf: [2048]u8 = undefined; 2330 _ = try runScript( 2331 "EHLO client.example.org\r\n" ++ 2332 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ 2333 // xtext: "e=mc2@example.com", the '=' escaped as +3D. 2334 "MAIL FROM:<relay@example.com> AUTH=e+3Dmc2@example.com\r\n" ++ 2335 "RCPT TO:<bob@example.net>\r\n" ++ 2336 "DATA\r\nrelayed\r\n.\r\nQUIT\r\n", 2337 &out_buf, 2338 h.handler(), 2339 .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 2340 ); 2341 2342 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2343 try std.testing.expectEqualStrings("e=mc2@example.com", h.submitter_mailbox.items); 2344 // And who did the asserting, which is the other half of judging it. 2345 try std.testing.expectEqualStrings("alice", h.identity.items); 2346} 2347 2348test "an unauthenticated client's AUTH= is taken and disbelieved" { 2349 var h: TestHandler = .{ .password = "secret" }; 2350 defer h.deinit(); 2351 var mechanisms: TestMechanisms = .init(&h); 2352 2353 var out_buf: [2048]u8 = undefined; 2354 const output = try runScript( 2355 "EHLO client.example.org\r\n" ++ 2356 "MAIL FROM:<relay@example.com> AUTH=alice@example.com\r\n" ++ 2357 "RCPT TO:<bob@example.net>\r\n" ++ 2358 "DATA\r\nrelayed\r\n.\r\nQUIT\r\n", 2359 &out_buf, 2360 h.handler(), 2361 .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 2362 ); 2363 2364 // RFC 4954 §5: a server advertising AUTH must accept the parameter even 2365 // from a client that has not authenticated -- so this is not a 501 -- 2366 // and must then behave as though `<>` had been sent. 2367 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null); 2368 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2369 try std.testing.expectEqual(protocol.Submitter.unknown, h.submitter.?); 2370 try std.testing.expectEqualStrings("", h.submitter_mailbox.items); 2371} 2372 2373test "AUTH= is rejected outright by a server that offers no AUTH at all" { 2374 var h: TestHandler = .{}; 2375 defer h.deinit(); 2376 2377 var out_buf: [2048]u8 = undefined; 2378 const output = try runScript( 2379 "EHLO client.example.org\r\n" ++ 2380 "MAIL FROM:<relay@example.com> AUTH=alice@example.com\r\n" ++ 2381 "QUIT\r\n", 2382 &out_buf, 2383 h.handler(), 2384 .{}, 2385 ); 2386 // The obligation to take it belongs to a server that advertises the 2387 // extension; one that does not is seeing a parameter it never offered. 2388 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); 2389} 2390 2391test "AUTH=<> says the peer considered the question and does not know" { 2392 var h: TestHandler = .{ .password = "secret" }; 2393 defer h.deinit(); 2394 var mechanisms: TestMechanisms = .init(&h); 2395 2396 var out_buf: [2048]u8 = undefined; 2397 const output = try runScript( 2398 "EHLO client.example.org\r\n" ++ 2399 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ 2400 "MAIL FROM:<relay@example.com> AUTH=<>\r\n" ++ 2401 "RSET\r\n" ++ 2402 // `+` must introduce two hex digits; "ZZ" are not. 2403 "MAIL FROM:<relay@example.com> AUTH=bad+ZZ\r\n" ++ // 501 2404 "QUIT\r\n", 2405 &out_buf, 2406 h.handler(), 2407 .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 2408 ); 2409 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.4 Invalid AUTH parameter") != null); 2410} 2411 2412test "the server can now offer CRAM-MD5, which it never could before" { 2413 var h: TestHandler = .{ .password = "secret" }; 2414 defer h.deinit(); 2415 2416 // The challenge is the server's to choose; a real one would not repeat. 2417 const challenge = "<1896.697170952@postoffice.reston.mci.net>"; 2418 const Lookup = struct { 2419 fn lookup(context: ?*anyopaque, username: []const u8) ?[]const u8 { 2420 const handler: *TestHandler = @ptrCast(@alignCast(context.?)); 2421 if (!std.mem.eql(u8, username, "tim")) return null; 2422 _ = handler; 2423 return "tanstaaftanstaaf"; 2424 } 2425 }; 2426 var cram: sasl.CramMd5Server = .init(challenge, .{ 2427 .context = &h, 2428 .lookup = Lookup.lookup, 2429 }); 2430 const mechanisms: []const sasl.Server = &.{cram.server()}; 2431 var sasl_scratch: [sasl_buffer_suggested]u8 = undefined; 2432 2433 var out_buf: [2048]u8 = undefined; 2434 const output = try runScript( 2435 "EHLO client.example.org\r\n" ++ 2436 "AUTH CRAM-MD5\r\n" ++ 2437 // base64("tim b913a602c7eda7a495b4e6e7334d3890"), the response 2438 // RFC 2195 publishes for this challenge and account. 2439 "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n" ++ 2440 "MAIL FROM:<tim@example.com>\r\n" ++ 2441 "RCPT TO:<bob@example.net>\r\n" ++ 2442 "DATA\r\nbody\r\n.\r\nQUIT\r\n", 2443 &out_buf, 2444 h.handler(), 2445 .{ .require_auth = true, .auth_mechanisms = mechanisms, .sasl_buffer = &sasl_scratch }, 2446 ); 2447 2448 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH CRAM-MD5\r\n") != null); 2449 // The challenge went out base64'd, and the login was accepted. 2450 try std.testing.expect(std.mem.indexOf(u8, output, "334 PDE4OTYuNjk3") != null); 2451 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 2452 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2453 // And the identity the mechanism reported reached the envelope, which is 2454 // what a handler deciding whether to relay actually needs. 2455 try std.testing.expectEqualStrings("tim", h.identity.items); 2456} 2457 2458test "the advertised mechanisms are the ones offered, in order" { 2459 var h: TestHandler = .{ .password = "secret" }; 2460 defer h.deinit(); 2461 var mechanisms: TestMechanisms = .init(&h); 2462 2463 var out_buf: [2048]u8 = undefined; 2464 const output = try runScript( 2465 "EHLO client.example.org\r\nAUTH SCRAM-SHA-256\r\nQUIT\r\n", 2466 &out_buf, 2467 h.handler(), 2468 .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 2469 ); 2470 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); 2471 // A name nothing answers to is 504, not 535: the credentials were never 2472 // in question. 2473 try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null); 2474} 2475 2476test "a session with no mechanisms does not advertise AUTH at all" { 2477 var h: TestHandler = .{}; 2478 defer h.deinit(); 2479 2480 var out_buf: [2048]u8 = undefined; 2481 const output = try runScript( 2482 "EHLO client.example.org\r\nAUTH PLAIN AGFsaWNlAHNlY3JldA==\r\nQUIT\r\n", 2483 &out_buf, 2484 h.handler(), 2485 .{}, 2486 ); 2487 try std.testing.expect(std.mem.indexOf(u8, output, "AUTH") == null or 2488 std.mem.indexOf(u8, output, "250-AUTH") == null); 2489 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null); 2490} 2491 2492test "AUTH failures and sequencing" { 2493 var h: TestHandler = .{ .password = "secret" }; 2494 defer h.deinit(); 2495 var mechanisms: TestMechanisms = .init(&h); 2496 2497 var out_buf: [2048]u8 = undefined; 2498 const output = try runScript( 2499 "EHLO client.example.org\r\n" ++ 2500 "MAIL FROM:<alice@example.com>\r\n" ++ // before auth: 530 2501 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // wrong password: 535 2502 "AUTH GSSAPI\r\n" ++ // unsupported: 504 2503 "AUTH PLAIN not!base64\r\n" ++ // 501 2504 "AUTH LOGIN\r\n" ++ 2505 "*\r\n" ++ // cancelled: 501 2506 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // correct: 235 2507 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // again: 503 2508 "QUIT\r\n", 2509 &out_buf, 2510 h.handler(), 2511 .{ .require_auth = true, .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, 2512 ); 2513 2514 try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null); 2515 try std.testing.expect(std.mem.indexOf(u8, output, "535 5.7.8") != null); 2516 try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null); 2517 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid base64") != null); 2518 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.7.0 Authentication cancelled") != null); 2519 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); 2520 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Already authenticated") != null); 2521} 2522 2523test "AUTH without a handler is refused" { 2524 var h: TestHandler = .{}; 2525 defer h.deinit(); 2526 2527 var out_buf: [1024]u8 = undefined; 2528 const output = try runScript( 2529 "EHLO client.example.org\r\nAUTH PLAIN AGEAYg==\r\nQUIT\r\n", 2530 &out_buf, 2531 h.handler(), 2532 .{}, 2533 ); 2534 2535 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH") == null); 2536 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null); 2537} 2538 2539test "oversize message is rejected but session continues" { 2540 var h: TestHandler = .{}; 2541 defer h.deinit(); 2542 2543 var out_buf: [1024]u8 = undefined; 2544 const output = try runScript( 2545 "EHLO client.example.org\r\n" ++ 2546 "MAIL FROM:<alice@example.com>\r\n" ++ 2547 "RCPT TO:<bob@example.net>\r\n" ++ 2548 "DATA\r\n" ++ 2549 "0123456789012345678901234567890123456789\r\n" ++ 2550 ".\r\n" ++ 2551 "NOOP\r\n" ++ 2552 "QUIT\r\n", 2553 &out_buf, 2554 h.handler(), 2555 .{ .max_message_size = 16 }, 2556 ); 2557 2558 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 2559 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 2560 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 2561} 2562 2563const StreamTestHandler = struct { 2564 collected: std.ArrayList(u8) = .empty, 2565 take_only: ?usize = null, 2566 2567 fn handler(h: *StreamTestHandler) Handler { 2568 return .{ .context = h, .vtable = &.{ 2569 .messageReader = onMessageReader, 2570 } }; 2571 } 2572 2573 fn onMessageReader(context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision { 2574 const h: *StreamTestHandler = @ptrCast(@alignCast(context.?)); 2575 _ = envelope; 2576 const gpa = std.testing.allocator; 2577 if (h.take_only) |n| { 2578 const bytes = message.take(n) catch return .{ .reject = .{} }; 2579 h.collected.appendSlice(gpa, bytes) catch return .{ .reject = .{} }; 2580 return .accept; 2581 } 2582 message.appendRemaining(gpa, &h.collected, .unlimited) catch return .{ .reject = .{} }; 2583 return .accept; 2584 } 2585}; 2586 2587test "streaming message handler receives unstuffed content" { 2588 var h: StreamTestHandler = .{}; 2589 defer h.collected.deinit(std.testing.allocator); 2590 2591 var out_buf: [1024]u8 = undefined; 2592 const output = try runScript( 2593 "EHLO client.example.org\r\n" ++ 2594 "MAIL FROM:<alice@example.com>\r\n" ++ 2595 "RCPT TO:<bob@example.net>\r\n" ++ 2596 "DATA\r\n" ++ 2597 "Subject: streamed\r\n" ++ 2598 "\r\n" ++ 2599 "..dot line\r\n" ++ 2600 "body\r\n" ++ 2601 ".\r\n" ++ 2602 "QUIT\r\n", 2603 &out_buf, 2604 h.handler(), 2605 .{}, 2606 ); 2607 2608 try std.testing.expectEqualStrings( 2609 "Subject: streamed\r\n\r\n.dot line\r\nbody\r\n", 2610 h.collected.items, 2611 ); 2612 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 2613} 2614 2615test "session drains what a streaming handler leaves unread" { 2616 var h: StreamTestHandler = .{ .take_only = 7 }; 2617 defer h.collected.deinit(std.testing.allocator); 2618 2619 var out_buf: [1024]u8 = undefined; 2620 const output = try runScript( 2621 "EHLO client.example.org\r\n" ++ 2622 "MAIL FROM:<alice@example.com>\r\n" ++ 2623 "RCPT TO:<bob@example.net>\r\n" ++ 2624 "DATA\r\n" ++ 2625 "Subject: mostly unread\r\n" ++ 2626 "lots of body\r\n" ++ 2627 ".\r\n" ++ 2628 "NOOP\r\n" ++ 2629 "QUIT\r\n", 2630 &out_buf, 2631 h.handler(), 2632 .{}, 2633 ); 2634 2635 try std.testing.expectEqualStrings("Subject", h.collected.items); 2636 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 2637 // The NOOP after DATA proves the terminator was consumed. 2638 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 2639} 2640 2641test "fuzz session with arbitrary client input" { 2642 try std.testing.fuzz({}, fuzzSession, .{}); 2643} 2644 2645fn fuzzSession(context: void, smith: *std.testing.Smith) !void { 2646 _ = context; 2647 var input_buf: [2048]u8 = undefined; 2648 const input = input_buf[0..smith.value(u11)]; 2649 smith.bytes(input); 2650 2651 var h: TestHandler = .{ .password = "secret" }; 2652 defer h.deinit(); 2653 2654 var reader: Io.Reader = .fixed(input); 2655 var discarding: Io.Writer.Discarding = .init(&.{}); 2656 var session: Server = .init(&reader, &discarding.writer, h.handler(), .{ 2657 .max_message_size = 512, 2658 .max_recipients = 4, 2659 }); 2660 // Whatever the "client" sends, the session must fail cleanly, never crash. 2661 session.run(std.testing.allocator) catch {}; 2662} 2663 2664test "fuzz collecting and streaming DATA agree" { 2665 try std.testing.fuzz({}, fuzzDataEquivalence, .{}); 2666} 2667 2668fn fuzzDataEquivalence(context: void, smith: *std.testing.Smith) !void { 2669 _ = context; 2670 var body_buf: [1024]u8 = undefined; 2671 const body = body_buf[0..smith.value(u10)]; 2672 smith.bytes(body); 2673 2674 var script_buf: [1200]u8 = undefined; 2675 const script = std.fmt.bufPrint( 2676 &script_buf, 2677 "EHLO fuzz.example.org\r\n" ++ 2678 "MAIL FROM:<a@example.com>\r\n" ++ 2679 "RCPT TO:<b@example.net>\r\n" ++ 2680 "DATA\r\n{s}\r\n.\r\nQUIT\r\n", 2681 .{body}, 2682 ) catch unreachable; 2683 2684 var collecting: TestHandler = .{}; 2685 defer collecting.deinit(); 2686 var out_buf: [4096]u8 = undefined; 2687 _ = runScript(script, &out_buf, collecting.handler(), .{}) catch {}; 2688 2689 var streaming: StreamTestHandler = .{}; 2690 defer streaming.collected.deinit(std.testing.allocator); 2691 _ = runScript(script, &out_buf, streaming.handler(), .{}) catch {}; 2692 2693 try std.testing.expectEqualSlices(u8, collecting.data.items, streaming.collected.items); 2694} 2695 2696test "MAIL parameters SIZE and BODY are honored" { 2697 var h: TestHandler = .{}; 2698 defer h.deinit(); 2699 2700 var out_buf: [1024]u8 = undefined; 2701 const output = try runScript( 2702 "EHLO client.example.org\r\n" ++ 2703 "MAIL FROM:<alice@example.com> SIZE=42 BODY=8BITMIME\r\n" ++ 2704 "RCPT TO:<bob@example.net>\r\n" ++ 2705 "DATA\r\nsized body\r\n.\r\n" ++ 2706 "QUIT\r\n", 2707 &out_buf, 2708 h.handler(), 2709 .{ .max_message_size = 1024 }, 2710 ); 2711 2712 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2713 try std.testing.expectEqual(@as(?u64, 42), h.declared_size); 2714 try std.testing.expectEqual(protocol.Body.eight_bit_mime, h.body.?); 2715 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null); 2716} 2717 2718test "invalid MAIL and RCPT parameters are rejected" { 2719 var h: TestHandler = .{}; 2720 defer h.deinit(); 2721 2722 var out_buf: [2048]u8 = undefined; 2723 const output = try runScript( 2724 "EHLO client.example.org\r\n" ++ 2725 "MAIL FROM:<a@example.com> SIZE=9999\r\n" ++ // over the maximum: 552 2726 "RCPT TO:<b@example.net>\r\n" ++ // that MAIL never started: 503 2727 "MAIL FROM:<a@example.com> SIZE=banana\r\n" ++ // 501 2728 "MAIL FROM:<a@example.com> BODY=BINARY\r\n" ++ // 555: not a body-value 2729 "MAIL FROM:<a@example.com> FUTURE=yes\r\n" ++ // 555 2730 "MAIL FROM:<a@example.com> BODY=7bit\r\n" ++ // ok 2731 "RCPT TO:<b@example.net> NOTIFY=SUCCESS\r\n" ++ // no RCPT params: 555 2732 "RCPT TO:<b@example.net>\r\n" ++ 2733 "DATA\r\nbody\r\n.\r\nQUIT\r\n", 2734 &out_buf, 2735 h.handler(), 2736 .{ .max_message_size = 1024 }, 2737 ); 2738 2739 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 2740 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null); 2741 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid SIZE parameter") != null); 2742 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unsupported BODY value") != null); 2743 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); 2744 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2745 try std.testing.expectEqual(protocol.Body.seven_bit, h.body.?); 2746 try std.testing.expectEqual(@as(?u64, null), h.declared_size); 2747} 2748 2749test init { 2750 var reader: Io.Reader = .fixed(""); 2751 var out_buf: [16]u8 = undefined; 2752 var writer: Io.Writer = .fixed(&out_buf); 2753 var h: TestHandler = .{}; 2754 const session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" }); 2755 try std.testing.expectEqualStrings("mx.test", session.options.hostname); 2756 try std.testing.expect(!session.secured); 2757} 2758 2759test Options { 2760 const options: Options = .{}; 2761 try std.testing.expectEqualStrings("localhost", options.hostname); 2762 try std.testing.expect(options.tls == null); 2763 try std.testing.expect(!options.require_auth); 2764} 2765 2766test Decision { 2767 const ok: Decision = .accept; 2768 try std.testing.expectEqual(Decision.accept, ok); 2769 2770 const no: Decision = .{ .reject = .{ .code = 451, .text = "4.3.0 Try again later" } }; 2771 try std.testing.expectEqual(@as(u16, 451), no.reject.code); 2772} 2773 2774test Envelope { 2775 const envelope: Envelope = .{ .from = "", .recipients = &.{.{ .address = "a@example.com" }} }; 2776 try std.testing.expectEqual(@as(usize, 1), envelope.recipients.len); 2777 try std.testing.expectEqual(@as(?u64, null), envelope.declared_size); 2778 try std.testing.expectEqual(@as(?protocol.Body, null), envelope.body); 2779} 2780 2781test Handler { 2782 const Callbacks = struct { 2783 fn onMessage(context: ?*anyopaque, envelope: Envelope, message_data: []const u8) Decision { 2784 _ = context; 2785 _ = envelope; 2786 _ = message_data; 2787 return .accept; 2788 } 2789 }; 2790 const handler: Handler = .{ .vtable = &.{ .message = Callbacks.onMessage } }; 2791 const envelope: Envelope = .{ .from = "", .recipients = &.{} }; 2792 try std.testing.expectEqual(Decision.accept, handler.vtable.message.?(null, envelope, "")); 2793} 2794 2795// SPDX-SnippetBegin 2796// SPDX-SnippetCopyrightText: © The Exim Maintainers 2797// SPDX-SnippetCopyrightText: © University of Cambridge 2798// SPDX-SnippetCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2799// SPDX-License-Identifier: GPL-2.0-or-later 2800// 2801// The command dialogue and message lines below are adapted from exim's 2802// test suite (test/scripts/0000-Basic); the reply expectations are ours. 2803test "protocol gauntlet adapted from exim's test suite" { 2804 // Command sequences and dot-stuffing cases distilled from exim's 2805 // test/scripts/0000-Basic (notably 0019's SMTP syntax-error dialogue 2806 // and 0008/0100's dotted message lines), verified against this server 2807 // with exim's own scriptable test client. 2808 var h: TestHandler = .{}; 2809 defer h.deinit(); 2810 2811 var out_buf: [4096]u8 = undefined; 2812 const output = try runScript( 2813 "NOOP\r\n" ++ 2814 "rhubarb\r\n" ++ 2815 "mail from:<x@y>\r\n" ++ 2816 "rcpt to:<a@b>\r\n" ++ 2817 "ehlo test.client\r\n" ++ 2818 "mail\r\n" ++ 2819 "mail from:\r\n" ++ 2820 "mail from:<>\r\n" ++ 2821 "mail from:<x@y>\r\n" ++ 2822 "rcpt to:\r\n" ++ 2823 "data\r\n" ++ 2824 "rset\r\n" ++ 2825 "etrn abc\r\n" ++ 2826 "vrfy userx\r\n" ++ 2827 "help\r\n" ++ 2828 "mail from:<ok@test1> SIZE=100 BODY=8BITMIME\r\n" ++ 2829 "rcpt to:<userx@test.ex>\r\n" ++ 2830 "rcpt to:<@relay.example:route@test.ex>\r\n" ++ 2831 "data\r\n" ++ 2832 "..that line started with a dot\r\n" ++ 2833 ".. and one starting with two dots\r\n" ++ 2834 "Message body\r\n" ++ 2835 ".\r\n" ++ 2836 "mail from:<a@b> SIZE=99999999\r\n" ++ 2837 "mail from:<a@b> BODY=BINARY\r\n" ++ 2838 "mail from:<a@b> FOO=bar\r\n" ++ 2839 "mail from:<a@b> SIZE=nan\r\n" ++ 2840 "starttls\r\n" ++ 2841 "mail from:<böb@test.ex>\r\n" ++ 2842 "mail from:<a@b> SMTPUTF8=YES\r\n" ++ 2843 "mail from:<böb@test.ex> SMTPUTF8\r\n" ++ 2844 "rset\r\n" ++ 2845 "BDAT 5\r\n" ++ 2846 "abc\r\n" ++ 2847 "mail from:<chunky@test.ex>\r\n" ++ 2848 "rcpt to:<userx@test.ex>\r\n" ++ 2849 "BDAT 7\r\n" ++ 2850 "hello\r\n" ++ 2851 "BDAT 23 LAST\r\n" ++ 2852 "world of chunked mail\r\n" ++ 2853 "quit\r\n", 2854 &out_buf, 2855 h.handler(), 2856 .{}, 2857 ); 2858 2859 try std.testing.expectEqualStrings( 2860 "220 localhost ESMTP ready\r\n" ++ 2861 "250 2.0.0 Ok\r\n" ++ 2862 "500 5.5.2 Command not recognized\r\n" ++ 2863 "503 5.5.1 Send EHLO first\r\n" ++ 2864 "503 5.5.1 Need MAIL command first\r\n" ++ 2865 "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-BINARYMIME\r\n" ++ 2866 "250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n250 SIZE 16777216\r\n" ++ 2867 "501 5.5.4 Syntax error in parameters\r\n" ++ 2868 "501 5.5.4 Syntax error in parameters\r\n" ++ 2869 "250 2.1.0 Ok\r\n" ++ 2870 "503 5.5.1 Nested MAIL command\r\n" ++ 2871 "501 5.5.4 Syntax error in parameters\r\n" ++ 2872 "503 5.5.1 Need RCPT command first\r\n" ++ 2873 "250 2.0.0 Ok\r\n" ++ 2874 "500 5.5.2 Command not recognized\r\n" ++ 2875 "252 2.5.2 Cannot VRFY user\r\n" ++ 2876 "214 2.0.0 See RFC 5321\r\n" ++ 2877 "250 2.1.0 Ok\r\n" ++ 2878 "250 2.1.5 Ok\r\n" ++ 2879 "250 2.1.5 Ok\r\n" ++ 2880 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 2881 "250 2.0.0 Ok, message accepted\r\n" ++ 2882 "552 5.3.4 Message size exceeds fixed maximum\r\n" ++ 2883 "555 5.5.4 Unsupported BODY value\r\n" ++ 2884 "555 5.5.4 Unrecognized parameter\r\n" ++ 2885 "501 5.5.2 Invalid SIZE parameter\r\n" ++ 2886 "502 5.5.1 STARTTLS not supported\r\n" ++ 2887 "553 5.6.7 Non-ASCII address requires SMTPUTF8\r\n" ++ 2888 "501 5.5.4 SMTPUTF8 takes no value\r\n" ++ 2889 "250 2.1.0 Ok\r\n" ++ 2890 "250 2.0.0 Ok\r\n" ++ 2891 "503 5.5.1 Need RCPT command first\r\n" ++ 2892 "250 2.1.0 Ok\r\n" ++ 2893 "250 2.1.5 Ok\r\n" ++ 2894 "250 2.0.0 Chunk received\r\n" ++ 2895 "250 2.0.0 Ok, message accepted\r\n" ++ 2896 "221 2.0.0 Bye\r\n", 2897 output, 2898 ); 2899 try std.testing.expectEqual(@as(usize, 2), h.messages_accepted); 2900 try std.testing.expectEqualStrings("ok@test1chunky@test.ex", h.from.items); 2901 try std.testing.expectEqualStrings( 2902 "userx@test.ex;route@test.ex;userx@test.ex;", 2903 h.recipients.items, 2904 ); 2905 try std.testing.expectEqualStrings( 2906 ".that line started with a dot\r\n. and one starting with two dots\r\nMessage body\r\n" ++ 2907 "hello\r\nworld of chunked mail\r\n", 2908 h.data.items, 2909 ); 2910} 2911// SPDX-SnippetEnd 2912 2913test "BDAT chunks are reassembled without unstuffing" { 2914 var h: TestHandler = .{}; 2915 defer h.deinit(); 2916 2917 var out_buf: [1024]u8 = undefined; 2918 const output = try runScript( 2919 "EHLO client.example.org\r\n" ++ 2920 "MAIL FROM:<alice@example.com>\r\n" ++ 2921 "RCPT TO:<bob@example.net>\r\n" ++ 2922 "BDAT 20\r\n" ++ 2923 "Subject: chunked\r\n\r\n" ++ // exactly 20 raw octets 2924 "BDAT 18\r\n" ++ 2925 ".dots stay\nas-is\r\n" ++ // 18 raw octets, no unstuffing 2926 "BDAT 0 LAST\r\n" ++ 2927 "QUIT\r\n", 2928 &out_buf, 2929 h.handler(), 2930 .{}, 2931 ); 2932 2933 try std.testing.expectEqualStrings( 2934 "Subject: chunked\r\n\r\n.dots stay\nas-is\r\n", 2935 h.data.items, 2936 ); 2937 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2938 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Chunk received") != null); 2939 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 2940} 2941 2942test "BDAT framing is length-based, not content-based" { 2943 var h: TestHandler = .{}; 2944 defer h.deinit(); 2945 2946 var out_buf: [1024]u8 = undefined; 2947 const output = try runScript( 2948 "EHLO client.example.org\r\n" ++ 2949 // Without a transaction the chunk must still be consumed, or the 2950 // embedded commands would be executed. 2951 "BDAT 12\r\n" ++ 2952 "QUIT\r\nRSET\r\n" ++ 2953 "MAIL FROM:<alice@example.com>\r\n" ++ 2954 "RCPT TO:<bob@example.net>\r\n" ++ 2955 // A chunk whose payload looks like commands is still just data. 2956 "BDAT 23 LAST\r\n" ++ 2957 "QUIT\r\nMAIL FROM:<x@y>\r\n" ++ 2958 "QUIT\r\n", 2959 &out_buf, 2960 h.handler(), 2961 .{}, 2962 ); 2963 2964 try std.testing.expectEqualStrings("QUIT\r\nMAIL FROM:<x@y>\r\n", h.data.items); 2965 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null); 2966 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 2967 try std.testing.expect(std.mem.indexOf(u8, output, "221 2.0.0 Bye") != null); 2968} 2969 2970test "RSET between BDAT chunks aborts the message" { 2971 var h: TestHandler = .{}; 2972 defer h.deinit(); 2973 2974 var out_buf: [1024]u8 = undefined; 2975 const output = try runScript( 2976 "EHLO client.example.org\r\n" ++ 2977 "MAIL FROM:<alice@example.com>\r\n" ++ 2978 "RCPT TO:<bob@example.net>\r\n" ++ 2979 "BDAT 5\r\n" ++ 2980 "abc\r\n" ++ 2981 "RSET\r\n" ++ 2982 "NOOP\r\n" ++ 2983 "QUIT\r\n", 2984 &out_buf, 2985 h.handler(), 2986 .{}, 2987 ); 2988 2989 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 2990 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Chunk received") != null); 2991 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n250 2.0.0 Ok\r\n221") != null); 2992} 2993 2994test "oversize BDAT message is rejected" { 2995 var h: TestHandler = .{}; 2996 defer h.deinit(); 2997 2998 var out_buf: [1024]u8 = undefined; 2999 const output = try runScript( 3000 "EHLO client.example.org\r\n" ++ 3001 "MAIL FROM:<alice@example.com>\r\n" ++ 3002 "RCPT TO:<bob@example.net>\r\n" ++ 3003 "BDAT 40 LAST\r\n" ++ 3004 "0123456789012345678901234567890123456789" ++ 3005 "NOOP\r\n" ++ 3006 "QUIT\r\n", 3007 &out_buf, 3008 h.handler(), 3009 .{ .max_message_size = 16 }, 3010 ); 3011 3012 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 3013 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 3014 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 3015} 3016 3017test "streaming handler receives BDAT chunks" { 3018 var h: StreamTestHandler = .{}; 3019 defer h.collected.deinit(std.testing.allocator); 3020 3021 var out_buf: [1024]u8 = undefined; 3022 const output = try runScript( 3023 "EHLO client.example.org\r\n" ++ 3024 "MAIL FROM:<alice@example.com>\r\n" ++ 3025 "RCPT TO:<bob@example.net>\r\n" ++ 3026 "BDAT 6\r\n" ++ 3027 "part1\n" ++ 3028 "BDAT 8 LAST\r\n" ++ 3029 ".part2\r\n" ++ 3030 "QUIT\r\n", 3031 &out_buf, 3032 h.handler(), 3033 .{}, 3034 ); 3035 3036 try std.testing.expectEqualStrings("part1\n.part2\r\n", h.collected.items); 3037 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 3038} 3039 3040test "session drains BDAT chunks a streaming handler leaves unread" { 3041 var h: StreamTestHandler = .{ .take_only = 4 }; 3042 defer h.collected.deinit(std.testing.allocator); 3043 3044 var out_buf: [1024]u8 = undefined; 3045 const output = try runScript( 3046 "EHLO client.example.org\r\n" ++ 3047 "MAIL FROM:<alice@example.com>\r\n" ++ 3048 "RCPT TO:<bob@example.net>\r\n" ++ 3049 "BDAT 10\r\n" ++ 3050 "0123456789" ++ 3051 "BDAT 10 LAST\r\n" ++ 3052 "abcdefghij" ++ 3053 "NOOP\r\n" ++ 3054 "QUIT\r\n", 3055 &out_buf, 3056 h.handler(), 3057 .{}, 3058 ); 3059 3060 try std.testing.expectEqualStrings("0123", h.collected.items); 3061 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); 3062 // The NOOP after the final chunk proves the stream stayed in sync. 3063 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 3064} 3065 3066test "SMTPUTF8 transactions and non-ASCII address enforcement" { 3067 var h: TestHandler = .{}; 3068 defer h.deinit(); 3069 3070 var out_buf: [2048]u8 = undefined; 3071 const output = try runScript( 3072 "EHLO client.example.org\r\n" ++ 3073 // Non-ASCII without the parameter: rejected. 3074 "MAIL FROM:<böb@example.com>\r\n" ++ 3075 "MAIL FROM:<alice@example.com>\r\n" ++ 3076 "RCPT TO:<jürgen@example.net>\r\n" ++ 3077 "RSET\r\n" ++ 3078 // The parameter takes no value. 3079 "MAIL FROM:<a@example.com> SMTPUTF8=YES\r\n" ++ 3080 // Invalid UTF-8 bytes even with the parameter: rejected. 3081 "MAIL FROM:<b\xff\xfeb@example.com> SMTPUTF8\r\n" ++ 3082 // Proper internationalized transaction. 3083 "MAIL FROM:<böb@example.com> SMTPUTF8\r\n" ++ 3084 "RCPT TO:<jürgen@example.net>\r\n" ++ 3085 "DATA\r\nSubject: ünïcode\r\n\r\nhello\r\n.\r\n" ++ 3086 "QUIT\r\n", 3087 &out_buf, 3088 h.handler(), 3089 .{}, 3090 ); 3091 3092 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 3093 try std.testing.expect(h.smtputf8); 3094 try std.testing.expectEqualStrings("böb@example.com", h.from.items); 3095 try std.testing.expectEqualStrings("jürgen@example.net;", h.recipients.items); 3096 try std.testing.expect(std.mem.indexOf(u8, output, "250-SMTPUTF8\r\n") != null); 3097 try std.testing.expect(std.mem.indexOf(u8, output, "553 5.6.7 Non-ASCII address requires SMTPUTF8") != null); 3098 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.4 SMTPUTF8 takes no value") != null); 3099 try std.testing.expect(std.mem.indexOf(u8, output, "553 5.6.7 Address is not valid UTF-8") != null); 3100}