// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! A single-connection SMTP server session. Like the client, it runs over //! any `Io.Reader`/`Io.Writer` pair; accept a TCP connection and hand its //! stream reader/writer to `run`. Accepting connections, concurrency, and //! message storage are left to the caller — the session just speaks the //! protocol and forwards decisions to a `Handler`. //! //! Typical use: //! ``` //! var session: Server = .init(&stream_reader, &stream_writer, handler, .{ //! .hostname = "mx.example.com", //! }); //! try session.run(gpa); //! ``` const Server = @This(); const std = @import("std"); const Io = std.Io; const tls = @import("tls"); const protocol = @import("protocol.zig"); const sasl = @import("sasl"); reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options, /// True once a STARTTLS handshake has completed for this session. secured: bool = false, /// The identity the client authenticated as, kept for the life of the /// session and reported on every `Envelope`. identity_buf: [255]u8 = undefined, identity_len: usize = 0, tls_connection: tls.Connection = undefined, tls_reader: tls.Connection.Reader = undefined, tls_writer: tls.Connection.Writer = undefined, tls_read_buffer: [4096]u8 = undefined, tls_write_buffer: [4096]u8 = undefined, pub const Options = struct { /// Which protocol the session speaks. See `Protocol`. protocol: Protocol = .smtp, /// Hostname announced in the greeting and the EHLO response. hostname: []const u8 = "localhost", /// Advertised via the SIZE extension and enforced during DATA. max_message_size: usize = 16 * 1024 * 1024, max_recipients: usize = 100, /// When set, the session speaks TLS (see `TlsOptions.mode`). The /// underlying stream reader/writer handed to `init` must then have /// buffers of at least `tls.input_buffer_len` and /// `tls.output_buffer_len` bytes, since the handshake and TLS records /// run over them. tls: ?TlsOptions = null, /// The SASL mechanisms this session offers, from /// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl) — `sasl.PlainServer` /// and the rest. Advertised by name in the EHLO response, in this order. /// /// **They hold per-exchange state, so each session needs its own.** A set /// shared between two connections would have them overwrite each other's /// challenges. `Server.init` is called per connection anyway, so building /// them alongside it is the natural place. auth_mechanisms: []const sasl.Server = &.{}, /// Scratch for the AUTH exchange, needed only when `auth_mechanisms` is /// not empty. /// /// It is the caller's for the same reason the stream buffers are: how /// much room a mechanism needs is the caller's to know, and the /// difference is large — the classic mechanisms want a few hundred /// bytes, an OAuth token several kilobytes. `sasl_buffer_suggested` fits /// everything short of an unusually fat token, and `sasl_buffer_min` is /// the floor. /// /// Split four-to-three between base64 and plaintext, which is the ratio /// base64 expands by, so the usable message is about three sevenths of /// what is given. sasl_buffer: []u8 = &.{}, /// Reject MAIL with 530 until the client has authenticated. Requires at /// least one entry in `auth_mechanisms`. require_auth: bool = false, }; /// SMTP, or its local-delivery sibling LMTP /// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)). pub const Protocol = enum { smtp, /// LMTP differs from SMTP in two ways that matter here: the greeting is /// `LHLO` and `HELO`/`EHLO` are refused, and the end of a message is /// answered with one reply per accepted recipient instead of one for /// the message. It exists so that a delivery agent can report a /// different outcome for each mailbox, which SMTP gives no way to say. /// /// RFC 2033 §5 forbids running it on TCP port 25 and advises against /// wide-area use at all: it is for the hop between a queueing MTA and /// the thing that writes to mailboxes. lmtp, }; pub const TlsOptions = struct { io: Io, /// Server certificate chain and private key presented to clients. auth: *tls.config.CertKeyPair, mode: Mode = .starttls, pub const Mode = enum { /// Advertise and accept the STARTTLS command /// ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)). starttls, /// Perform the TLS handshake before the greeting (implicit TLS / /// SMTPS, port 465 style; [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314)). implicit, }; }; /// A handler's verdict on an envelope step or a complete message. pub const Decision = union(enum) { accept, reject: Rejection, pub const Rejection = struct { /// Use 4xx for "try again later", 5xx for permanent rejection. code: u16 = 550, /// By convention prefixed with an enhanced status code /// ([RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463)). text: []const u8 = "5.7.1 Rejected", }; }; /// One accepted recipient, with whatever the client attached to it. pub const Recipient = struct { /// The forward-path from RCPT TO. address: []const u8, /// Value of the RCPT `NOTIFY=` parameter /// ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)), if the /// client sent one. Absent means the client did not say, which RFC 3461 /// lets a reporting MTA read as either `FAILURE` or `FAILURE,DELAY`. notify: ?protocol.Notify = null, /// Value of the RCPT `ORCPT=` parameter, xtext-decoded: the address the /// message was originally addressed to, before whatever aliasing led /// here. orcpt: ?protocol.Orcpt = null, }; pub const Envelope = struct { /// Empty for the null reverse-path (`MAIL FROM:<>`). from: []const u8, recipients: []const Recipient, /// Value of the MAIL SIZE= parameter /// ([RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870)), if the client /// declared one. Already validated against `Options.max_message_size`. declared_size: ?u64 = null, /// Value of the MAIL `BODY=` parameter, if the client declared one. /// `.binary_mime` ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) /// means the content is arbitrary octets and arrived by BDAT, so the /// handler must keep every bit of it: there is no line structure to /// normalize and nothing was unstuffed. body: ?protocol.Body = null, /// True when the client requested the SMTPUTF8 extension /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)); the /// envelope addresses and message headers may then contain UTF-8. smtputf8: bool = false, /// Value of the MAIL `RET=` parameter /// ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)): how much /// of the message the sender wants carried back in a failure DSN. /// Absent leaves the choice to whoever reports. ret: ?protocol.Ret = null, /// Value of the MAIL `ENVID=` parameter, xtext-decoded: an identifier /// the sender wants quoted back in any DSN for this message. envid: ?[]const u8 = null, /// Value of the MAIL `AUTH=` parameter /// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): /// who the client says originally submitted this message, for a relay /// carrying it on behalf of somebody else. /// /// Null when the parameter was absent. `.unknown` when it said `<>` — /// and also when it named a mailbox that this session has no business /// asserting, because RFC 4954 requires a server to behave as though /// `<>` had been sent whenever the client has not authenticated. A /// `.mailbox` here therefore means an authenticated peer asserted it; /// whether *that* peer is entitled to is the handler's to judge, and /// `authenticated_as` says who is doing the asserting. submitter: ?protocol.Submitter = null, /// The identity the client authenticated as, or null if it did not. /// /// This is what the mechanism reported, which is not always the username /// the client typed: PLAIN carries an authorization identity as well, so /// a mechanism that honours one reports the identity being acted as. A /// handler deciding whether to relay wants this rather than the envelope /// sender, which anybody can write. authenticated_as: ?[]const u8 = null, }; /// What a mail transaction accumulates between MAIL and the end of the /// message. Kept together so that resetting it cannot forget a field — /// RSET, a completed message and a new (L)HLO all discard the lot. const Transaction = struct { from: ?[]const u8 = null, recipients: std.ArrayList(Recipient) = .empty, declared_size: ?u64 = null, body: ?protocol.Body = null, smtputf8: bool = false, ret: ?protocol.Ret = null, envid: ?[]const u8 = null, submitter: ?protocol.Submitter = null, /// The memory all of this points into is the session arena, which the /// caller resets alongside. fn clear(t: *Transaction) void { t.* = .{}; } fn envelope(t: Transaction, authenticated_as: ?[]const u8) Envelope { return .{ .from = t.from.?, .authenticated_as = authenticated_as, .submitter = t.submitter, .recipients = t.recipients.items, .declared_size = t.declared_size, .body = t.body, .smtputf8 = t.smtputf8, .ret = t.ret, .envid = t.envid, }; } }; /// Callbacks invoked during a session. All slices passed to callbacks are /// only valid for the duration of the call. pub const Handler = struct { context: ?*anyopaque = null, vtable: *const VTable, pub const VTable = struct { /// Called for MAIL FROM. Null accepts every sender. mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null, /// Called for each RCPT TO, with the address and any DSN /// parameters that came with it. Null accepts every recipient. rcptTo: ?*const fn (context: ?*anyopaque, recipient: Recipient) Decision = null, /// Called once the complete message has been received. The data has /// CRLF line endings and dot-stuffing already removed. Exactly one /// of `message` and `messageReader` must be set. message: ?*const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision = null, /// LMTP only: the verdict for one recipient of the message just /// received, `envelope.recipients[index]`, called once per accepted /// recipient after `message` or `messageReader` has returned /// `.accept`. This is what LMTP exists for — one mailbox can be /// full while another is fine — so a `.lmtp` session without it /// answers every recipient identically and gains nothing over SMTP. /// /// Not called when the message itself was rejected: that verdict /// applies to every recipient and is sent for each of them. recipientResult: ?*const fn (context: ?*anyopaque, envelope: Envelope, index: usize) Decision = null, /// Streaming alternative to `message`: called after DATA with a /// reader that yields the message content (dot-stuffing removed, /// line endings normalized to CRLF) until end of stream. Anything /// the callback leaves unread is drained by the session, so /// returning early is fine. `Options.max_message_size` is not /// enforced in this mode; individual message lines must fit the /// session's stream reader buffer. messageReader: ?*const fn (context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision = null, }; }; pub fn init(reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options) Server { return .{ .reader = reader, .writer = writer, .handler = handler, .options = options }; } pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory, TlsHandshakeFailed }; /// Serves the session until the client sends QUIT or disconnects. `gpa` /// backs per-transaction storage (envelope and message data); everything is /// freed on return. pub fn run(s: *Server, gpa: std.mem.Allocator) RunError!void { var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); std.debug.assert(!s.options.require_auth or s.options.auth_mechanisms.len != 0); // A session that offers mechanisms and no room to run them would answer // every AUTH with a temporary failure, which is worth catching here. std.debug.assert(s.options.auth_mechanisms.len == 0 or s.options.sasl_buffer.len >= sasl_buffer_min); std.debug.assert((s.handler.vtable.message == null) != (s.handler.vtable.messageReader == null)); if (s.options.tls) |config| { if (config.mode == .implicit and !s.secured) try s.upgradeToTls(config); } var greeted = false; var authenticated = false; var transaction: Transaction = .{}; try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname}); try s.writer.flush(); while (true) { const line = protocol.readLine(s.reader) catch |err| switch (err) { error.EndOfStream => return, // Client disconnected. error.ReadFailed => return error.ReadFailed, error.LineTooLong => { try s.discardLine(); try s.reply(500, "5.5.2 Line too long"); continue; }, }; const command = protocol.Command.parse(line) catch { try s.reply(501, "5.5.4 Syntax error in parameters"); continue; }; switch (command) { .helo => { // RFC 2033 §4: an LMTP server must not answer HELO or EHLO // with a positive completion, and 500 is what it suggests. if (s.options.protocol == .lmtp) { try s.reply(500, "5.5.1 This is LMTP, use LHLO"); continue; } greeted = true; transaction.clear(); _ = arena_state.reset(.retain_capacity); try s.reply(250, s.options.hostname); }, .ehlo => { if (s.options.protocol == .lmtp) { try s.reply(500, "5.5.1 This is LMTP, use LHLO"); continue; } greeted = true; transaction.clear(); _ = arena_state.reset(.retain_capacity); try s.greetExtended(authenticated); }, .lhlo => { if (s.options.protocol == .smtp) { try s.reply(500, "5.5.2 Command not recognized"); continue; } greeted = true; transaction.clear(); _ = arena_state.reset(.retain_capacity); try s.greetExtended(authenticated); }, .mail => |args| { if (!greeted) { try s.reply(503, "5.5.1 Send EHLO first"); continue; } if (s.options.require_auth and !authenticated) { try s.reply(530, "5.7.0 Authentication required"); continue; } if (transaction.from != null) { try s.reply(503, "5.5.1 Nested MAIL command"); continue; } var mail_declared_size: ?u64 = null; var mail_body: ?protocol.Body = null; var mail_smtputf8 = false; var mail_ret: ?protocol.Ret = null; var mail_envid: ?[]const u8 = null; var mail_submitter: ?protocol.Submitter = null; var params_ok = true; var params = args.paramIterator(); while (params.next()) |param| { if (std.ascii.eqlIgnoreCase(param.keyword, "SIZE")) { const size = std.fmt.parseInt(u64, param.value, 10) catch { try s.reply(501, "5.5.2 Invalid SIZE parameter"); params_ok = false; break; }; if (size > s.options.max_message_size) { try s.reply(552, "5.3.4 Message size exceeds fixed maximum"); params_ok = false; break; } mail_declared_size = size; } else if (std.ascii.eqlIgnoreCase(param.keyword, "BODY")) { mail_body = protocol.Body.parse(param.value) catch { try s.reply(555, "5.5.4 Unsupported BODY value"); params_ok = false; break; }; } else if (std.ascii.eqlIgnoreCase(param.keyword, "SMTPUTF8")) { if (param.value.len != 0) { try s.reply(501, "5.5.4 SMTPUTF8 takes no value"); params_ok = false; break; } mail_smtputf8 = true; } else if (std.ascii.eqlIgnoreCase(param.keyword, "AUTH")) { // RFC 4954 §5 is explicit that a server advertising // AUTH must take this parameter even from a client // that has not authenticated — and then disregard // what it says, which is what the check below does. if (s.options.auth_mechanisms.len == 0) { try s.reply(555, "5.5.4 Unrecognized parameter"); params_ok = false; break; } const decoded = arena.alloc(u8, param.value.len) catch return error.OutOfMemory; const asserted = protocol.Submitter.parse(decoded, param.value) catch { try s.reply(501, "5.5.4 Invalid AUTH parameter"); params_ok = false; break; }; // "MUST behave as if the AUTH=<> parameter was // supplied" when the client has not authenticated. // The claim is still recorded as having been made, // just not as having been believed. mail_submitter = if (authenticated) asserted else .unknown; } else if (std.ascii.eqlIgnoreCase(param.keyword, "RET")) { mail_ret = protocol.Ret.parse(param.value) catch { try s.reply(501, "5.5.4 Invalid RET parameter"); params_ok = false; break; }; } else if (std.ascii.eqlIgnoreCase(param.keyword, "ENVID")) { // The cap is on the encoded form, which is what // arrived, so it is checked before decoding. if (param.value.len == 0 or param.value.len > protocol.max_envid_len) { try s.reply(501, "5.5.4 Invalid ENVID parameter"); params_ok = false; break; } const decoded = arena.alloc(u8, param.value.len) catch return error.OutOfMemory; mail_envid = protocol.xtextDecode(decoded, param.value) catch { try s.reply(501, "5.5.4 Invalid ENVID parameter"); params_ok = false; break; }; } else { try s.reply(555, "5.5.4 Unrecognized parameter"); params_ok = false; break; } } if (!params_ok) continue; if (!try s.validateAddress(args.path, mail_smtputf8)) continue; if (s.handler.vtable.mailFrom) |callback| { switch (callback(s.handler.context, args.path)) { .accept => {}, .reject => |r| { try s.reply(r.code, r.text); continue; }, } } transaction.from = try arena.dupe(u8, args.path); transaction.declared_size = mail_declared_size; transaction.body = mail_body; transaction.smtputf8 = mail_smtputf8; transaction.ret = mail_ret; transaction.envid = mail_envid; transaction.submitter = mail_submitter; try s.replyGrouped(250, "2.1.0 Ok"); }, .rcpt => |args| { if (transaction.from == null) { try s.reply(503, "5.5.1 Need MAIL command first"); continue; } var recipient: Recipient = .{ .address = args.path }; var params_ok = true; var params = args.paramIterator(); while (params.next()) |param| { if (std.ascii.eqlIgnoreCase(param.keyword, "NOTIFY")) { recipient.notify = protocol.Notify.parse(param.value) catch { try s.reply(501, "5.5.4 Invalid NOTIFY parameter"); params_ok = false; break; }; } else if (std.ascii.eqlIgnoreCase(param.keyword, "ORCPT")) { if (param.value.len == 0 or param.value.len > protocol.Orcpt.max_len) { try s.reply(501, "5.5.4 Invalid ORCPT parameter"); params_ok = false; break; } const decoded = arena.alloc(u8, param.value.len) catch return error.OutOfMemory; recipient.orcpt = protocol.Orcpt.parse(decoded, param.value) catch { try s.reply(501, "5.5.4 Invalid ORCPT parameter"); params_ok = false; break; }; } else { try s.reply(555, "5.5.4 Unrecognized parameter"); params_ok = false; break; } } if (!params_ok) continue; if (!try s.validateAddress(args.path, transaction.smtputf8)) continue; if (transaction.recipients.items.len >= s.options.max_recipients) { try s.reply(452, "4.5.3 Too many recipients"); continue; } if (s.handler.vtable.rcptTo) |callback| { switch (callback(s.handler.context, recipient)) { .accept => {}, .reject => |r| { try s.reply(r.code, r.text); continue; }, } } recipient.address = try arena.dupe(u8, args.path); if (recipient.orcpt) |*orcpt| orcpt.addr_type = try arena.dupe(u8, orcpt.addr_type); try transaction.recipients.append(arena, recipient); try s.replyGrouped(250, "2.1.5 Ok"); }, .data => { if (transaction.recipients.items.len == 0) { try s.reply(503, "5.5.1 Need RCPT command first"); continue; } // RFC 3030 §3: binary content has no line structure, so it // cannot be framed by a line holding a single dot. BDAT, // which carries its length, is the only way to send it. if (transaction.body == .binary_mime) { try s.reply(503, "5.5.1 BINARYMIME requires BDAT"); continue; } try s.receiveData(arena, transaction.envelope(s.identity())); transaction.clear(); _ = arena_state.reset(.retain_capacity); }, .bdat => |args| { if (transaction.recipients.items.len == 0) { // The chunk's octets follow regardless; consume them to // keep the length-framed stream in sync. s.reader.discardAll64(args.size) catch |err| switch (err) { error.EndOfStream => return, error.ReadFailed => return error.ReadFailed, }; try s.reply(503, "5.5.1 Need RCPT command first"); continue; } const outcome = try s.receiveChunked(arena, transaction.envelope(s.identity()), args); transaction.clear(); _ = arena_state.reset(.retain_capacity); switch (outcome) { .done => {}, .end_session => return, } }, .rset => { transaction.clear(); _ = arena_state.reset(.retain_capacity); try s.replyGrouped(250, "2.0.0 Ok"); }, .noop => try s.reply(250, "2.0.0 Ok"), .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"), .help => try s.reply(214, "2.0.0 See RFC 5321"), .starttls => { const config = s.options.tls orelse { try s.reply(502, "5.5.1 STARTTLS not supported"); continue; }; if (config.mode != .starttls) { try s.reply(502, "5.5.1 STARTTLS not supported"); continue; } if (s.secured) { try s.reply(503, "5.5.1 TLS already active"); continue; } try s.reply(220, "2.0.0 Ready to start TLS"); try s.upgradeToTls(config); // RFC 3207 §4.2: both sides return to their initial state; // the client must EHLO again. greeted = false; authenticated = false; transaction.clear(); _ = arena_state.reset(.retain_capacity); }, .quit => { try s.reply(221, "2.0.0 Bye"); if (s.secured) s.tls_connection.close() catch {}; return; }, .auth => |args| { if (s.options.auth_mechanisms.len == 0) { try s.reply(503, "5.5.1 Authentication not enabled"); continue; } if (!greeted) { try s.reply(503, "5.5.1 Send EHLO first"); continue; } if (authenticated) { try s.reply(503, "5.5.1 Already authenticated"); continue; } if (transaction.from != null) { try s.reply(503, "5.5.1 MAIL transaction in progress"); continue; } switch (try s.receiveAuth(args)) { .authenticated => authenticated = true, .rejected => {}, .disconnected => return, } }, .unknown => try s.reply(500, "5.5.2 Command not recognized"), } } } /// The smallest `Options.sasl_buffer` worth offering: enough plaintext for /// PLAIN, LOGIN, CRAM-MD5, EXTERNAL, ANONYMOUS and DIGEST-MD5. pub const sasl_buffer_min = 896; /// A `Options.sasl_buffer` size that fits everything, OAuth tokens included. /// See `Client.sasl_buffer_suggested`, which says where the number is from. pub const sasl_buffer_suggested = 7168; /// Writes the EHLO or LHLO response: the hostname, then one line per /// extension. The two are the same list — RFC 2033 gives LHLO the semantics /// of EHLO — and it requires PIPELINING and ENHANCEDSTATUSCODES of an LMTP /// server, both of which are here for every session anyway. fn greetExtended(s: *Server, authenticated: bool) error{WriteFailed}!void { // Every reply carries an enhanced status code (RFC 3463), so the // ENHANCEDSTATUSCODES extension (RFC 2034) is advertised. 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}); if (s.options.tls) |config| { if (config.mode == .starttls and !s.secured) try s.writer.writeAll("250-STARTTLS\r\n"); } if (s.options.auth_mechanisms.len != 0 and !authenticated) { try s.writer.writeAll("250-AUTH"); for (s.options.auth_mechanisms) |mechanism| try s.writer.print(" {s}", .{mechanism.name()}); try s.writer.writeAll("\r\n"); } try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); try s.writer.flush(); } /// Performs the server-side TLS handshake over the current transport and /// swaps the session onto the encrypted connection. fn upgradeToTls(s: *Server, config: TlsOptions) error{TlsHandshakeFailed}!void { var rng_source: std.Random.IoSource = .{ .io = config.io }; s.tls_connection = tls.server(s.reader, s.writer, .{ .auth = config.auth, .rng = rng_source.interface(), .now = Io.Clock.real.now(config.io), }) catch return error.TlsHandshakeFailed; s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer); s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer); s.reader = &s.tls_reader.interface; s.writer = &s.tls_writer.interface; s.secured = true; } const AuthOutcome = enum { authenticated, rejected, disconnected }; /// Runs the challenge/response exchange for AUTH PLAIN or AUTH LOGIN /// (RFC 4954) and consults the handler's `authenticate` callback. Every /// outcome except `disconnected` has already sent its reply. /// Runs a SASL exchange with whichever of `Options.auth_mechanisms` the /// client named ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). /// /// The mechanisms come from /// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl); what is here is the /// SMTP half of it — the 334 challenges, the `*` that cancels, 235, and the /// 504 for a name nothing answers to. fn receiveAuth(s: *Server, args: protocol.Command.AuthArgs) RunError!AuthOutcome { const mechanism = for (s.options.auth_mechanisms) |candidate| { if (std.ascii.eqlIgnoreCase(candidate.name(), args.mechanism)) break candidate; } else { try s.reply(504, "5.5.4 Unrecognized authentication type"); return .rejected; }; // The two halves never hold anything at once: a challenge is written as // plaintext and encoded into `coded`, and the client's answer decodes // back over it once that has gone out. if (s.options.sasl_buffer.len < sasl_buffer_min) { try s.reply(454, "4.7.0 Temporary authentication failure"); return .rejected; } const unit = s.options.sasl_buffer.len / 7; const coded = s.options.sasl_buffer[0 .. unit * 4]; const plain = s.options.sasl_buffer[unit * 4 ..][0 .. unit * 3]; var challenge: Io.Writer = .fixed(plain); // RFC 4954 §4: no argument at all and a single `=` are different. The // first is "I have nothing to send yet", the second an initial response // that happens to be empty, and mechanisms read them differently. const initial: ?[]const u8 = if (args.initial.len == 0) null else decodeBase64( coded, args.initial, ) orelse { try s.reply(501, "5.5.2 Invalid base64"); return .rejected; }; var step = mechanism.start(initial, &challenge) catch |err| return s.authFailed(err); while (true) { switch (step) { .accepted => |who| { s.setIdentity(who); try s.reply(235, "2.7.0 Authentication successful"); return .authenticated; }, .rejected => { // No distinction between "no such user" and "wrong password" // reaches the wire: that difference is worth money to // somebody enumerating accounts. try s.reply(535, "5.7.8 Authentication credentials invalid"); return .rejected; }, .challenge => { const encoded = std.base64.standard.Encoder.encode(coded, challenge.buffered()); // A zero-length challenge is "334 " — the code, a space, and // nothing after it, which `reply` produces for empty text. try s.reply(334, encoded); const line = switch (try s.takeAuthLine()) { .line => |line| line, .cancelled => return .rejected, .disconnected => return .disconnected, }; const response = decodeBase64(coded, line) orelse { try s.reply(501, "5.5.2 Invalid base64"); return .rejected; }; challenge = .fixed(plain); step = mechanism.respond(response, &challenge) catch |err| return s.authFailed(err); }, } } } /// A mechanism that could not make sense of what the client sent. Its own /// errors are not worth distinguishing on the wire. fn authFailed(s: *Server, err: sasl.Server.Error) RunError!AuthOutcome { switch (err) { error.OutOfMemory => return error.OutOfMemory, error.WriteFailed => return error.WriteFailed, error.BadResponse => { try s.reply(501, "5.5.2 Malformed authentication response"); return .rejected; }, } } /// The identity the client authenticated as, or null if it has not. pub fn identity(s: *const Server) ?[]const u8 { if (s.identity_len == 0) return null; return s.identity_buf[0..s.identity_len]; } /// Keeps the authenticated identity for the rest of the session. /// /// Copied because a mechanism may report a slice of the response it was /// handed, which lives in a buffer that does not outlive the exchange — and /// this has to survive every transaction that follows. fn setIdentity(s: *Server, who: []const u8) void { s.identity_len = @min(who.len, s.identity_buf.len); @memcpy(s.identity_buf[0..s.identity_len], who[0..s.identity_len]); } const AuthLine = union(enum) { line: []u8, cancelled, disconnected }; /// Reads one continuation line of an AUTH exchange. `cancelled` covers both /// an explicit "*" and an overlong line; its reply has already been sent. fn takeAuthLine(s: *Server) RunError!AuthLine { const line = protocol.readLine(s.reader) catch |err| switch (err) { error.EndOfStream => return .disconnected, error.ReadFailed => return error.ReadFailed, error.LineTooLong => { try s.discardLine(); try s.reply(501, "5.5.2 Response too long"); return .cancelled; }, }; if (std.mem.eql(u8, line, "*")) { try s.reply(501, "5.7.0 Authentication cancelled"); return .cancelled; } return .{ .line = line }; } /// Decodes a base64 AUTH argument; "=" denotes an empty response. fn decodeBase64(out: []u8, encoded: []const u8) ?[]u8 { if (std.mem.eql(u8, encoded, "=")) return out[0..0]; const len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return null; if (len > out.len) return null; std.base64.standard.Decoder.decode(out[0..len], encoded) catch return null; return out[0..len]; } const ChunkOutcome = enum { done, end_session }; /// Receives a message sent with BDAT chunks (RFC 3030 CHUNKING), starting /// from the already-parsed first chunk header. Chunk data is raw: no /// dot-stuffing and no line-ending normalization. fn receiveChunked( s: *Server, arena: std.mem.Allocator, envelope: Envelope, first: protocol.Command.BdatArgs, ) RunError!ChunkOutcome { if (s.handler.vtable.messageReader) |callback| { var buffer: [1024]u8 = undefined; var bdat_reader: BdatReader = .{ .server = s, .remaining = first.size, .last = first.last, .interface = .{ .buffer = &buffer, .vtable = &.{ .stream = BdatReader.stream }, .seek = 0, .end = 0, }, }; const decision = callback(s.handler.context, envelope, &bdat_reader.interface); if (bdat_reader.abort == null and !bdat_reader.finished) { // Consume whatever the callback left unread, through LAST. var discard_buf: [256]u8 = undefined; var discarding: Io.Writer.Discarding = .init(&discard_buf); _ = bdat_reader.interface.streamRemaining(&discarding.writer) catch {}; } if (bdat_reader.abort) |abort| switch (abort) { .rset, .protocol => return .done, // Replies already sent. .quit, .disconnected => return .end_session, .transport_failure => return error.ReadFailed, }; try s.replyMessage(envelope, decision); return .done; } var data: std.ArrayList(u8) = .empty; var oversize = false; var size = first.size; var last = first.last; while (true) { var left = size; while (left > 0) { const available = s.reader.peekGreedy(1) catch |err| switch (err) { error.EndOfStream => return .end_session, error.ReadFailed => return error.ReadFailed, }; const n: usize = @intCast(@min(@as(u64, available.len), left)); if (!oversize) { if (data.items.len + n > s.options.max_message_size) { oversize = true; } else { try data.appendSlice(arena, available[0..n]); } } s.reader.toss(n); left -= n; } if (last) break; try s.reply(250, "2.0.0 Chunk received"); const line = protocol.readLine(s.reader) catch |err| switch (err) { error.EndOfStream => return .end_session, error.ReadFailed => return error.ReadFailed, error.LineTooLong => { try s.discardLine(); try s.reply(500, "5.5.2 Line too long"); return .done; // Transaction aborted. }, }; const command = protocol.Command.parse(line) catch { try s.reply(501, "5.5.4 Syntax error in parameters"); return .done; }; switch (command) { .bdat => |b| { size = b.size; last = b.last; }, .rset => { try s.reply(250, "2.0.0 Ok"); return .done; }, .quit => { try s.reply(221, "2.0.0 Bye"); if (s.secured) s.tls_connection.close() catch {}; return .end_session; }, else => { try s.reply(503, "5.5.1 BDAT expected"); return .done; }, } } if (oversize) { try s.reply(552, "5.3.4 Message exceeds maximum size"); return .done; } try s.replyMessage(envelope, s.handler.vtable.message.?(s.handler.context, envelope, data.items)); return .done; } /// Adapts a BDAT chunk sequence into an `Io.Reader` of the raw message /// content for `Handler.VTable.messageReader`, replying 250 between chunks /// and following the chunk headers as they arrive. const BdatReader = struct { server: *Server, interface: Io.Reader, remaining: u64, last: bool, finished: bool = false, abort: ?Abort = null, const Abort = enum { rset, quit, protocol, disconnected, transport_failure }; fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize { const br: *BdatReader = @alignCast(@fieldParentPtr("interface", io_r)); const s = br.server; while (br.remaining == 0) { if (br.last) { br.finished = true; return error.EndOfStream; } s.reply(250, "2.0.0 Chunk received") catch { br.abort = .transport_failure; return error.ReadFailed; }; const line = protocol.readLine(s.reader) catch |err| { switch (err) { error.EndOfStream => br.abort = .disconnected, error.ReadFailed => br.abort = .transport_failure, error.LineTooLong => { s.discardLine() catch {}; s.reply(500, "5.5.2 Line too long") catch {}; br.abort = .protocol; }, } return error.ReadFailed; }; const command = protocol.Command.parse(line) catch { s.reply(501, "5.5.4 Syntax error in parameters") catch {}; br.abort = .protocol; return error.ReadFailed; }; switch (command) { .bdat => |b| { br.remaining = b.size; br.last = b.last; }, .rset => { s.reply(250, "2.0.0 Ok") catch {}; br.abort = .rset; return error.ReadFailed; }, .quit => { s.reply(221, "2.0.0 Bye") catch {}; if (s.secured) s.tls_connection.close() catch {}; br.abort = .quit; return error.ReadFailed; }, else => { s.reply(503, "5.5.1 BDAT expected") catch {}; br.abort = .protocol; return error.ReadFailed; }, } } const available = s.reader.peekGreedy(1) catch |err| switch (err) { error.EndOfStream => { br.abort = .disconnected; return error.ReadFailed; }, error.ReadFailed => { br.abort = .transport_failure; return error.ReadFailed; }, }; const dest = limit.slice(try w.writableSliceGreedy(1)); const n: usize = @intCast(@min(@min(@as(u64, available.len), @as(u64, dest.len)), br.remaining)); @memcpy(dest[0..n], available[0..n]); s.reader.toss(n); br.remaining -= n; w.advance(n); return n; } }; /// Reads message content after DATA up to the terminating ".\r\n", /// un-stuffing dots, then asks the handler to accept or reject. fn receiveData(s: *Server, arena: std.mem.Allocator, envelope: Envelope) RunError!void { try s.reply(354, "End data with ."); if (s.handler.vtable.messageReader) |callback| { var buffer: [1024]u8 = undefined; var data_reader: DataReader = .{ .session_reader = s.reader, .interface = .{ .buffer = &buffer, .vtable = &.{ .stream = DataReader.stream }, .seek = 0, .end = 0, }, }; const decision = callback(s.handler.context, envelope, &data_reader.interface); // Consume whatever the callback left unread, up to and including // the terminating ".". while (!data_reader.finished) { const line = protocol.readLine(s.reader) catch |err| switch (err) { error.EndOfStream => return, // Client disconnected mid-message. error.ReadFailed => return error.ReadFailed, error.LineTooLong => { try s.discardLine(); continue; }, }; if (std.mem.eql(u8, line, ".")) break; } try s.replyMessage(envelope, decision); return; } var data: std.ArrayList(u8) = .empty; var oversize = false; while (true) { const line = protocol.readLine(s.reader) catch |err| switch (err) { error.EndOfStream => return, // Client disconnected mid-message. error.ReadFailed => return error.ReadFailed, error.LineTooLong => { // Longer than our reader buffer; RFC 5321 caps text lines at // 1000 octets, so treat it as oversize but keep scanning for // the terminator. try s.discardLine(); oversize = true; continue; }, }; if (std.mem.eql(u8, line, ".")) break; const content = if (line.len > 0 and line[0] == '.') line[1..] else line; if (oversize) continue; if (data.items.len + content.len + protocol.crlf.len > s.options.max_message_size) { oversize = true; continue; } try data.appendSlice(arena, content); try data.appendSlice(arena, protocol.crlf); } if (oversize) { try s.reply(552, "5.3.4 Message exceeds maximum size"); return; } try s.replyMessage(envelope, s.handler.vtable.message.?(s.handler.context, envelope, data.items)); } /// Adapts the session's line-based DATA phase into an `Io.Reader` of the /// unstuffed message content for `Handler.VTable.messageReader`. const DataReader = struct { session_reader: *Io.Reader, interface: Io.Reader, /// Unread remainder of the current line (points into the session /// reader's buffer, which only this reader touches during DATA). line: []const u8 = &.{}, line_ending: []const u8 = &.{}, finished: bool = false, fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize { const dr: *DataReader = @alignCast(@fieldParentPtr("interface", io_r)); if (dr.line.len == 0 and dr.line_ending.len == 0) { if (dr.finished) return error.EndOfStream; const raw = protocol.readLine(dr.session_reader) catch return error.ReadFailed; if (std.mem.eql(u8, raw, ".")) { dr.finished = true; return error.EndOfStream; } dr.line = if (raw.len > 0 and raw[0] == '.') raw[1..] else raw; dr.line_ending = protocol.crlf; } const dest = limit.slice(try w.writableSliceGreedy(1)); const line_n = @min(dest.len, dr.line.len); @memcpy(dest[0..line_n], dr.line[0..line_n]); dr.line = dr.line[line_n..]; var n = line_n; if (dr.line.len == 0) { const ending_n = @min(dest.len - n, dr.line_ending.len); @memcpy(dest[n..][0..ending_n], dr.line_ending[0..ending_n]); dr.line_ending = dr.line_ending[ending_n..]; n += ending_n; } w.advance(n); return n; } }; /// Enforces RFC 6531: a non-ASCII envelope address is only allowed when /// the transaction requested SMTPUTF8, and must be well-formed UTF-8. /// Replies and returns false on rejection. fn validateAddress(s: *Server, path: []const u8, smtputf8: bool) error{WriteFailed}!bool { for (path) |byte| { if (byte >= 0x80) { if (!smtputf8) { try s.reply(553, "5.6.7 Non-ASCII address requires SMTPUTF8"); return false; } if (!std.unicode.utf8ValidateSlice(path)) { try s.reply(553, "5.6.7 Address is not valid UTF-8"); return false; } return true; } } return true; } /// Answers a command that ends a pipelined group, which is every command /// RFC 2920 §3.2 names as one whose reply must not be held back: EHLO, /// DATA, VRFY, EXPN, TURN, QUIT and NOOP, and anything that went wrong. fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { try s.replyLine(code, text); try s.writer.flush(); } /// Answers one of the commands that may appear anywhere in a pipelined /// group — RSET, MAIL FROM and RCPT TO — by holding the reply back while /// the client has already sent more for the server to read. /// /// RFC 2920 §3.2 asks for exactly this: keep those replies in a buffer so /// they go out as a unit, and send everything pending the moment the input /// is empty. The condition is what makes it safe rather than a deadlock — /// a reply is only ever held while there is another command to answer, so /// the client is never left waiting for something still in the buffer. fn replyGrouped(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { try s.replyLine(code, text); if (s.reader.bufferedLen() == 0) try s.writer.flush(); } /// A reply without the flush, for when several are going out together. fn replyLine(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text }); } /// Answers a completed message. /// /// SMTP gets one reply. LMTP gets one for each previously successful RCPT, /// in the order they were issued /// ([RFC 2033 §4.2](https://datatracker.ietf.org/doc/html/rfc2033#section-4.2)) /// — including a repeat for a recipient named twice, which is why this /// walks the accepted list rather than a set of addresses. fn replyMessage(s: *Server, envelope: Envelope, decision: Decision) error{WriteFailed}!void { if (s.options.protocol == .smtp) { try s.writeVerdict(decision); try s.writer.flush(); return; } for (envelope.recipients, 0..) |_, index| { // A rejected message is rejected for everybody; there is nothing // left to ask about an individual recipient. const verdict: Decision = switch (decision) { .reject => decision, .accept => if (s.handler.vtable.recipientResult) |callback| callback(s.handler.context, envelope, index) else .accept, }; try s.writeVerdict(verdict); } try s.writer.flush(); } fn writeVerdict(s: *Server, decision: Decision) error{WriteFailed}!void { switch (decision) { .accept => try s.replyLine(250, "2.0.0 Ok, message accepted"), .reject => |r| try s.replyLine(r.code, r.text), } } /// Discards input through the next newline after `error.LineTooLong`, which /// leaves the reader positioned at the start of the oversized line. fn discardLine(s: *Server) error{ReadFailed}!void { _ = s.reader.discardDelimiterInclusive('\n') catch |err| switch (err) { error.EndOfStream => {}, error.ReadFailed => return error.ReadFailed, }; } const TestHandler = struct { from: std.ArrayList(u8) = .empty, recipients: std.ArrayList(u8) = .empty, data: std.ArrayList(u8) = .empty, messages_accepted: usize = 0, reject_recipient: ?[]const u8 = null, /// Accepted at RCPT time and then failed per-recipient at the end of /// the message, which only LMTP can express. fail_delivery: ?[]const u8 = null, /// Returned for the message as a whole, before any per-recipient /// verdict is asked for. reject_message: ?Decision.Rejection = null, declared_size: ?u64 = null, body: ?protocol.Body = null, smtputf8: bool = false, /// DSN parameters, kept from the last RCPT and the last message. The /// strings are copied because everything a callback is handed lives /// only for the duration of the call. last_notify: ?protocol.Notify = null, last_orcpt: bool = false, last_orcpt_type: std.ArrayList(u8) = .empty, last_orcpt_address: std.ArrayList(u8) = .empty, ret: ?protocol.Ret = null, submitter: ?protocol.Submitter = null, submitter_mailbox: std.ArrayList(u8) = .empty, identity: std.ArrayList(u8) = .empty, envid: std.ArrayList(u8) = .empty, /// When set, enables the authenticate callback accepting user "alice" /// with this password. password: ?[]const u8 = null, fn deinit(h: *TestHandler) void { h.from.deinit(std.testing.allocator); h.recipients.deinit(std.testing.allocator); h.data.deinit(std.testing.allocator); h.envid.deinit(std.testing.allocator); h.identity.deinit(std.testing.allocator); h.submitter_mailbox.deinit(std.testing.allocator); h.last_orcpt_type.deinit(std.testing.allocator); h.last_orcpt_address.deinit(std.testing.allocator); } fn handler(h: *TestHandler) Handler { return .{ .context = h, .vtable = &.{ .rcptTo = onRcptTo, .message = onMessage, .recipientResult = onRecipientResult, } }; } /// The credential check the SASL mechanisms are built from, accepting /// "alice" with whatever `password` holds. fn check(h: *TestHandler) sasl.Server.PasswordCheck { return .{ .context = h, .verify = verify }; } fn verify( context: ?*anyopaque, authzid: []const u8, authcid: []const u8, password: []const u8, ) ?[]const u8 { const h: *TestHandler = @ptrCast(@alignCast(context.?)); if (authzid.len != 0) return null; if (!std.mem.eql(u8, authcid, "alice")) return null; if (!std.mem.eql(u8, password, h.password.?)) return null; return "alice"; } /// LMTP's per-recipient verdict: everybody is fine except the one /// address `fail_delivery` names, which is the outcome that has no /// spelling in SMTP. fn onRecipientResult(context: ?*anyopaque, envelope: Envelope, index: usize) Decision { const h: *TestHandler = @ptrCast(@alignCast(context.?)); const failing = h.fail_delivery orelse return .accept; if (std.mem.eql(u8, envelope.recipients[index].address, failing)) return .{ .reject = .{ .code = 550, .text = "5.2.1 Mailbox disabled" } }; return .accept; } fn onRcptTo(context: ?*anyopaque, recipient: Recipient) Decision { const h: *TestHandler = @ptrCast(@alignCast(context.?)); h.last_notify = recipient.notify; if (recipient.orcpt) |orcpt| { const gpa = std.testing.allocator; h.last_orcpt = true; h.last_orcpt_type.appendSlice(gpa, orcpt.addr_type) catch return .{ .reject = .{} }; h.last_orcpt_address.appendSlice(gpa, orcpt.address) catch return .{ .reject = .{} }; } if (h.reject_recipient) |rejected| { if (std.mem.eql(u8, recipient.address, rejected)) return .{ .reject = .{ .code = 550, .text = "5.1.1 No such user", } }; } return .accept; } fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision { const h: *TestHandler = @ptrCast(@alignCast(context.?)); if (h.reject_message) |rejection| return .{ .reject = rejection }; const gpa = std.testing.allocator; h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} }; for (envelope.recipients) |recipient| { h.recipients.appendSlice(gpa, recipient.address) catch return .{ .reject = .{} }; h.recipients.append(gpa, ';') catch return .{ .reject = .{} }; } h.data.appendSlice(gpa, data) catch return .{ .reject = .{} }; h.messages_accepted += 1; h.declared_size = envelope.declared_size; h.body = envelope.body; h.smtputf8 = envelope.smtputf8; h.ret = envelope.ret; h.submitter = envelope.submitter; if (envelope.submitter) |who| switch (who) { // Copied: it points into the session arena, which is reset the // moment this transaction ends. .mailbox => |mailbox| h.submitter_mailbox.appendSlice(gpa, mailbox) catch return .{ .reject = .{} }, .unknown => {}, }; if (envelope.authenticated_as) |who| h.identity.appendSlice(gpa, who) catch return .{ .reject = .{} }; if (envelope.envid) |envid| h.envid.appendSlice(gpa, envid) catch return .{ .reject = .{} }; return .accept; } }; /// A writer that records where its flush boundaries fell, so that a test /// can tell one reply per write from several replies in one. const BatchingWriter = struct { interface: Io.Writer, sink: std.ArrayList(u8) = .empty, /// The bytes handed over at each drain — one entry per effective flush. batches: std.ArrayList(usize) = .empty, fn init(buffer: []u8) BatchingWriter { return .{ .interface = .{ .buffer = buffer, .vtable = &.{ .drain = drain }, .end = 0, } }; } fn deinit(bw: *BatchingWriter) void { bw.sink.deinit(std.testing.allocator); bw.batches.deinit(std.testing.allocator); } fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize { const bw: *BatchingWriter = @alignCast(@fieldParentPtr("interface", w)); const gpa = std.testing.allocator; var handed: usize = w.buffered().len; bw.sink.appendSlice(gpa, w.buffered()) catch return error.WriteFailed; w.end = 0; var n: usize = 0; if (chunks.len > 0) { for (chunks[0 .. chunks.len - 1]) |bytes| { bw.sink.appendSlice(gpa, bytes) catch return error.WriteFailed; n += bytes.len; } const pattern = chunks[chunks.len - 1]; for (0..splat) |_| { bw.sink.appendSlice(gpa, pattern) catch return error.WriteFailed; n += pattern.len; } } handed += n; if (handed > 0) bw.batches.append(gpa, handed) catch return error.WriteFailed; return n; } }; test "replies to a pipelined group go out together and in order" { var h: TestHandler = .{}; defer h.deinit(); // One group: MAIL, two RCPTs and DATA, which RFC 2920 §3.1 allows as // the last command of one. A fixed reader has the whole session // buffered, which is what a client that pipelines looks like. var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\nhi\r\n.\r\nQUIT\r\n"); var buffer: [4096]u8 = undefined; var bw: BatchingWriter = .init(&buffer); defer bw.deinit(); var session: Server = .init(&reader, &bw.interface, h.handler(), .{ .hostname = "mx.test" }); try session.run(std.testing.allocator); // Order first: every reply is there, once, in the order asked for. const out = bw.sink.items; const envelope_replies = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n" ++ "354 End data with .\r\n"; try std.testing.expect(std.mem.indexOf(u8, out, envelope_replies) != null); // And batching: the three envelope replies were held back and left // with the 354, rather than going out one at a time. There are five // replies after the greeting and the EHLO response, and fewer writes. // And batching: eight replies left in five writes, because the three // envelope replies were held back and went out with the 354 as one. // The others are the greeting, the EHLO response, the message verdict // and the goodbye — all of which RFC 2920 §3.2 says must not be held. try std.testing.expectEqual(@as(usize, 5), bw.batches.items.len); try std.testing.expectEqual(envelope_replies.len, bw.batches.items[2]); } test "a held reply is released as soon as there is nothing left to read" { var h: TestHandler = .{}; defer h.deinit(); // MAIL alone: its reply may not be held, because nothing follows it in // the buffer and the client is waiting for it. var reader: Io.Reader = .fixed("EHLO client.example.org\r\nMAIL FROM:\r\n"); var buffer: [4096]u8 = undefined; var bw: BatchingWriter = .init(&buffer); defer bw.deinit(); var session: Server = .init(&reader, &bw.interface, h.handler(), .{ .hostname = "mx.test" }); try session.run(std.testing.allocator); try std.testing.expect(std.mem.endsWith(u8, bw.sink.items, "250 2.1.0 Ok\r\n")); } /// The mechanisms a test session offers, built from a `TestHandler`'s /// credential check. They hold per-exchange state, so each test makes its /// own rather than sharing a constant. const TestMechanisms = struct { plain: sasl.PlainServer, login: sasl.LoginServer, storage: [2]sasl.Server = undefined, /// The scratch a session needs to run them, which `Options` takes from /// the caller rather than putting on the stack. buffer: [sasl_buffer_suggested]u8 = undefined, fn init(h: *TestHandler) TestMechanisms { return .{ .plain = .init(h.check()), .login = .init(h.check()) }; } fn list(m: *TestMechanisms) []const sasl.Server { m.storage = .{ m.plain.server(), m.login.server() }; return &m.storage; } fn scratch(m: *TestMechanisms) []u8 { return &m.buffer; } }; fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 { var reader: Io.Reader = .fixed(input); var writer: Io.Writer = .fixed(out_buf); var session: Server = .init(&reader, &writer, handler, options); try session.run(std.testing.allocator); return writer.buffered(); } test "BINARYMIME is advertised, accepted, and refused on DATA" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [4096]u8 = undefined; const out = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM: BODY=BINARYMIME\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\n" ++ // 503: binary content cannot be framed by a dot "BDAT 5 LAST\r\n\x00\r\n.\r\nQUIT\r\n", &out_buf, h.handler(), .{ .hostname = "mx.test" }, ); // RFC 3030: BINARYMIME may only be offered alongside CHUNKING. try std.testing.expect(std.mem.indexOf(u8, out, "250-BINARYMIME\r\n") != null); try std.testing.expect(std.mem.indexOf(u8, out, "250-CHUNKING\r\n") != null); try std.testing.expect(std.mem.indexOf(u8, out, "503 5.5.1 BINARYMIME requires BDAT") != null); try std.testing.expectEqual(protocol.Body.binary_mime, h.body.?); // Five octets, delivered as they were sent: a NUL, and a lone dot on a // line of its own, which over DATA would have ended the message. try std.testing.expectEqualStrings("\x00\r\n.\r", h.data.items); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); } test "every octet survives a binary chunk" { var h: TestHandler = .{}; defer h.deinit(); // All 256 byte values, which is the "preserve all bits in each octet" // requirement of RFC 3030 §5 stated as a test. const octets = comptime blk: { var all: [256]u8 = undefined; for (&all, 0..) |*byte, i| byte.* = @intCast(i); break :blk all; }; var out_buf: [4096]u8 = undefined; _ = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM: BODY=BINARYMIME\r\n" ++ "RCPT TO:\r\n" ++ "BDAT 256 LAST\r\n" ++ octets ++ "QUIT\r\n", &out_buf, h.handler(), .{ .hostname = "mx.test" }, ); try std.testing.expectEqualSlices(u8, &octets, h.data.items); } test "LMTP answers once per accepted recipient" { var h: TestHandler = .{ .fail_delivery = "bad@example.net" }; defer h.deinit(); var out_buf: [2048]u8 = undefined; const out = try runScript( "LHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "RCPT TO:\r\n" ++ // RFC 2033 §4.2 is explicit that a repeated forward-path still // gets a reply of its own. "RCPT TO:\r\n" ++ "DATA\r\nhi\r\n.\r\nQUIT\r\n", &out_buf, h.handler(), .{ .protocol = .lmtp, .hostname = "mx.test" }, ); const tail = out[std.mem.indexOf(u8, out, "354").?..]; try std.testing.expectEqualStrings( "354 End data with .\r\n" ++ "250 2.0.0 Ok, message accepted\r\n" ++ "550 5.2.1 Mailbox disabled\r\n" ++ "250 2.0.0 Ok, message accepted\r\n" ++ "221 2.0.0 Bye\r\n", tail, ); } test "a message rejected outright is rejected for every LMTP recipient" { var h: TestHandler = .{ .reject_message = .{ .code = 452, .text = "4.3.1 Out of storage" }, }; defer h.deinit(); var out_buf: [2048]u8 = undefined; const out = try runScript( "LHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\nhi\r\n.\r\nQUIT\r\n", &out_buf, h.handler(), .{ .protocol = .lmtp, .hostname = "mx.test" }, ); const tail = out[std.mem.indexOf(u8, out, "354").?..]; try std.testing.expectEqualStrings( "354 End data with .\r\n" ++ "452 4.3.1 Out of storage\r\n" ++ "452 4.3.1 Out of storage\r\n" ++ "221 2.0.0 Bye\r\n", tail, ); } test "BDAT LAST also answers once per LMTP recipient" { var h: TestHandler = .{ .fail_delivery = "bad@example.net" }; defer h.deinit(); var out_buf: [2048]u8 = undefined; const out = try runScript( "LHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "RCPT TO:\r\n" ++ "BDAT 4 LAST\r\nhi\r\nQUIT\r\n", &out_buf, h.handler(), .{ .protocol = .lmtp, .hostname = "mx.test" }, ); const tail = out[std.mem.lastIndexOf(u8, out, "250 2.1.5 Ok\r\n").? + "250 2.1.5 Ok\r\n".len ..]; try std.testing.expectEqualStrings( "250 2.0.0 Ok, message accepted\r\n" ++ "550 5.2.1 Mailbox disabled\r\n" ++ "221 2.0.0 Bye\r\n", tail, ); } test "each protocol refuses the other's greeting" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [2048]u8 = undefined; // RFC 2033 §4: an LMTP server must not answer HELO or EHLO positively. const lmtp = try runScript( "EHLO client.example.org\r\nHELO client.example.org\r\nQUIT\r\n", &out_buf, h.handler(), .{ .protocol = .lmtp, .hostname = "mx.test" }, ); try std.testing.expectEqualStrings( "220 mx.test ESMTP ready\r\n" ++ "500 5.5.1 This is LMTP, use LHLO\r\n" ++ "500 5.5.1 This is LMTP, use LHLO\r\n" ++ "221 2.0.0 Bye\r\n", lmtp, ); var smtp_buf: [2048]u8 = undefined; const smtp = try runScript( "LHLO client.example.org\r\nQUIT\r\n", &smtp_buf, h.handler(), .{ .hostname = "mx.test" }, ); try std.testing.expectEqualStrings( "220 mx.test ESMTP ready\r\n" ++ "500 5.5.2 Command not recognized\r\n" ++ "221 2.0.0 Bye\r\n", smtp, ); } test "LHLO advertises what LMTP requires" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [2048]u8 = undefined; const out = try runScript( "LHLO client.example.org\r\nQUIT\r\n", &out_buf, h.handler(), .{ .protocol = .lmtp, .hostname = "mx.test" }, ); // RFC 2033 §5 requires both of these of an LMTP server. try std.testing.expect(std.mem.indexOf(u8, out, "250-PIPELINING\r\n") != null); try std.testing.expect(std.mem.indexOf(u8, out, "250-ENHANCEDSTATUSCODES\r\n") != null); } test "DSN parameters reach the handler" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [2048]u8 = undefined; const out = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM: RET=HDRS ENVID=batch+207\r\n" ++ "RCPT TO: NOTIFY=SUCCESS,FAILURE ORCPT=rfc822;team@example.net\r\n" ++ "DATA\r\nhi\r\n.\r\nQUIT\r\n", &out_buf, h.handler(), .{ .hostname = "mx.test" }, ); // Nothing in the session was refused. try std.testing.expect(std.mem.indexOf(u8, out, "\r\n5") == null); try std.testing.expectEqual(protocol.Ret.hdrs, h.ret.?); // The ENVID arrives xtext-decoded: "batch+207" carried a space. try std.testing.expectEqualStrings("batch 7", h.envid.items); const notify = h.last_notify.?; try std.testing.expect(notify.on.success and notify.on.failure and !notify.on.delay); try std.testing.expect(h.last_orcpt); try std.testing.expectEqualStrings("rfc822", h.last_orcpt_type.items); try std.testing.expectEqualStrings("team@example.net", h.last_orcpt_address.items); } test "the DSN extension is advertised and its parameters are validated" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [2048]u8 = undefined; const out = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM: RET=PARTIAL\r\n" ++ // 501: not FULL or HDRS "MAIL FROM: ENVID=bad+ZZ\r\n" ++ // 501: not xtext "MAIL FROM: ENVID=" ++ ("x" ** 101) ++ "\r\n" ++ // 501: too long "MAIL FROM:\r\n" ++ "RCPT TO: NOTIFY=NEVER,SUCCESS\r\n" ++ // 501: NEVER stands alone "RCPT TO: NOTIFY=SOMETIMES\r\n" ++ // 501: not a keyword "RCPT TO: ORCPT=team@example.net\r\n" ++ // 501: no addr-type "RCPT TO: FROB=1\r\n" ++ // 555: still unrecognized "QUIT\r\n", &out_buf, h.handler(), .{ .hostname = "mx.test" }, ); try std.testing.expect(std.mem.indexOf(u8, out, "250-DSN\r\n") != null); var replies = std.mem.splitSequence(u8, out, "\r\n"); var codes: std.ArrayList([]const u8) = .empty; defer codes.deinit(std.testing.allocator); while (replies.next()) |line| { if (line.len >= 4 and line[3] == ' ') try codes.append(std.testing.allocator, line[0..3]); } // 220 greeting, 250 EHLO, then the parameter verdicts, then 221. try std.testing.expectEqualStrings("220", codes.items[0]); try std.testing.expectEqualStrings("250", codes.items[1]); try std.testing.expectEqualStrings("501", codes.items[2]); try std.testing.expectEqualStrings("501", codes.items[3]); try std.testing.expectEqualStrings("501", codes.items[4]); try std.testing.expectEqualStrings("250", codes.items[5]); try std.testing.expectEqualStrings("501", codes.items[6]); try std.testing.expectEqualStrings("501", codes.items[7]); try std.testing.expectEqualStrings("501", codes.items[8]); try std.testing.expectEqualStrings("555", codes.items[9]); try std.testing.expectEqualStrings("221", codes.items[10]); } test run { var h: TestHandler = .{}; defer h.deinit(); var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\n" ++ "Subject: hi\r\n" ++ "\r\n" ++ "..stuffed line\r\n" ++ "body\r\n" ++ ".\r\n" ++ "QUIT\r\n"); var out_buf: [1024]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" }); try session.run(std.testing.allocator); const output = writer.buffered(); try std.testing.expectEqualStrings("alice@example.com", h.from.items); try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items); try std.testing.expectEqualStrings("Subject: hi\r\n\r\n.stuffed line\r\nbody\r\n", h.data.items); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); try std.testing.expectEqualStrings( "220 mx.test ESMTP ready\r\n" ++ "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" ++ "250 2.1.0 Ok\r\n" ++ "250 2.1.5 Ok\r\n" ++ "250 2.1.5 Ok\r\n" ++ "354 End data with .\r\n" ++ "250 2.0.0 Ok, message accepted\r\n" ++ "221 2.0.0 Bye\r\n", output, ); } test "command sequencing is enforced" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [1024]u8 = undefined; const output = try runScript( "MAIL FROM:\r\n" ++ "EHLO client.example.org\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Send EHLO first") != null); try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null); try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null); } test "handler can reject a recipient" { var h: TestHandler = .{ .reject_recipient = "nobody@example.net" }; defer h.deinit(); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\n" ++ "hello\r\n" ++ ".\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null); try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); } test "AUTH PLAIN with initial response" { var h: TestHandler = .{ .password = "secret" }; defer h.deinit(); var mechanisms: TestMechanisms = .init(&h); var out_buf: [1024]u8 = undefined; // base64("\x00alice\x00secret") const output = try runScript( "EHLO client.example.org\r\n" ++ "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\nauthed mail\r\n.\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{ .require_auth = true, .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, ); try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); } test "AUTH LOGIN challenge exchange" { var h: TestHandler = .{ .password = "secret" }; defer h.deinit(); var mechanisms: TestMechanisms = .init(&h); var out_buf: [1024]u8 = undefined; // base64("alice"), base64("secret") const output = try runScript( "EHLO client.example.org\r\n" ++ "AUTH LOGIN\r\n" ++ "YWxpY2U=\r\n" ++ "c2VjcmV0\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, ); try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null); try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null); try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); } test "every reply that should carry an enhanced status code does" { // RFC 2034 §4: a server implementing the extension prefaces the text of // every 2xx, 4xx and 5xx reply with a status code whose class agrees -- // except the greeting, the response to HELO or EHLO, and any 3xx. This // walks a session that touches most of the command table and checks the // whole transcript against that rule rather than reply by reply. var h: TestHandler = .{ .password = "secret" }; defer h.deinit(); var mechanisms: TestMechanisms = .init(&h); var out_buf: [8192]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "NOOP\r\n" ++ "VRFY somebody\r\n" ++ "HELP\r\n" ++ "WHAT\r\n" ++ // 500 "MAIL FROM: FROB=1\r\n" ++ // 555 "MAIL FROM: SIZE=99999999\r\n" ++ // 552 "RCPT TO:\r\n" ++ // 503, no MAIL yet "AUTH GSSAPI\r\n" ++ // 504 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // 535 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // 235 "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\nbody\r\n.\r\n" ++ "RSET\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{ .max_message_size = 1024, .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch(), }, ); var checked: usize = 0; var greeting = true; var in_ehlo = false; var lines = std.mem.splitSequence(u8, output, "\r\n"); while (lines.next()) |line| { if (line.len < 4) continue; const code = std.fmt.parseInt(u16, line[0..3], 10) catch continue; const continued = line[3] == '-'; const text = line[4..]; // The exclusions, in the order a session meets them. if (greeting) { greeting = false; continue; } if (in_ehlo or (code == 250 and continued)) { in_ehlo = continued; continue; } if (code / 100 == 3) { // 354 and the 334 challenges, which RFC 2034 leaves out. try std.testing.expectEqual(@as(?protocol.Enhanced, null), protocol.Enhanced.parse(text)); continue; } const status = protocol.Enhanced.parse(text) orelse { std.debug.print("no enhanced status code: {s}\n", .{line}); return error.TestUnexpectedResult; }; if (!status.agrees(code)) { std.debug.print("class disagrees with the reply code: {s}\n", .{line}); return error.TestUnexpectedResult; } checked += 1; } // Enough of them to mean the walk actually walked. try std.testing.expect(checked >= 14); } test "an authenticated client's AUTH= assertion reaches the handler" { var h: TestHandler = .{ .password = "secret" }; defer h.deinit(); var mechanisms: TestMechanisms = .init(&h); var out_buf: [2048]u8 = undefined; _ = try runScript( "EHLO client.example.org\r\n" ++ "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // xtext: "e=mc2@example.com", the '=' escaped as +3D. "MAIL FROM: AUTH=e+3Dmc2@example.com\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\nrelayed\r\n.\r\nQUIT\r\n", &out_buf, h.handler(), .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, ); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); try std.testing.expectEqualStrings("e=mc2@example.com", h.submitter_mailbox.items); // And who did the asserting, which is the other half of judging it. try std.testing.expectEqualStrings("alice", h.identity.items); } test "an unauthenticated client's AUTH= is taken and disbelieved" { var h: TestHandler = .{ .password = "secret" }; defer h.deinit(); var mechanisms: TestMechanisms = .init(&h); var out_buf: [2048]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM: AUTH=alice@example.com\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\nrelayed\r\n.\r\nQUIT\r\n", &out_buf, h.handler(), .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, ); // RFC 4954 §5: a server advertising AUTH must accept the parameter even // from a client that has not authenticated -- so this is not a 501 -- // and must then behave as though `<>` had been sent. try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); try std.testing.expectEqual(protocol.Submitter.unknown, h.submitter.?); try std.testing.expectEqualStrings("", h.submitter_mailbox.items); } test "AUTH= is rejected outright by a server that offers no AUTH at all" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [2048]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM: AUTH=alice@example.com\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{}, ); // The obligation to take it belongs to a server that advertises the // extension; one that does not is seeing a parameter it never offered. try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); } test "AUTH=<> says the peer considered the question and does not know" { var h: TestHandler = .{ .password = "secret" }; defer h.deinit(); var mechanisms: TestMechanisms = .init(&h); var out_buf: [2048]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ "MAIL FROM: AUTH=<>\r\n" ++ "RSET\r\n" ++ // `+` must introduce two hex digits; "ZZ" are not. "MAIL FROM: AUTH=bad+ZZ\r\n" ++ // 501 "QUIT\r\n", &out_buf, h.handler(), .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, ); try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.4 Invalid AUTH parameter") != null); } test "the server can now offer CRAM-MD5, which it never could before" { var h: TestHandler = .{ .password = "secret" }; defer h.deinit(); // The challenge is the server's to choose; a real one would not repeat. const challenge = "<1896.697170952@postoffice.reston.mci.net>"; const Lookup = struct { fn lookup(context: ?*anyopaque, username: []const u8) ?[]const u8 { const handler: *TestHandler = @ptrCast(@alignCast(context.?)); if (!std.mem.eql(u8, username, "tim")) return null; _ = handler; return "tanstaaftanstaaf"; } }; var cram: sasl.CramMd5Server = .init(challenge, .{ .context = &h, .lookup = Lookup.lookup, }); const mechanisms: []const sasl.Server = &.{cram.server()}; var sasl_scratch: [sasl_buffer_suggested]u8 = undefined; var out_buf: [2048]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "AUTH CRAM-MD5\r\n" ++ // base64("tim b913a602c7eda7a495b4e6e7334d3890"), the response // RFC 2195 publishes for this challenge and account. "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\nbody\r\n.\r\nQUIT\r\n", &out_buf, h.handler(), .{ .require_auth = true, .auth_mechanisms = mechanisms, .sasl_buffer = &sasl_scratch }, ); try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH CRAM-MD5\r\n") != null); // The challenge went out base64'd, and the login was accepted. try std.testing.expect(std.mem.indexOf(u8, output, "334 PDE4OTYuNjk3") != null); try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); // And the identity the mechanism reported reached the envelope, which is // what a handler deciding whether to relay actually needs. try std.testing.expectEqualStrings("tim", h.identity.items); } test "the advertised mechanisms are the ones offered, in order" { var h: TestHandler = .{ .password = "secret" }; defer h.deinit(); var mechanisms: TestMechanisms = .init(&h); var out_buf: [2048]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\nAUTH SCRAM-SHA-256\r\nQUIT\r\n", &out_buf, h.handler(), .{ .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, ); try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null); // A name nothing answers to is 504, not 535: the credentials were never // in question. try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null); } test "a session with no mechanisms does not advertise AUTH at all" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [2048]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\nAUTH PLAIN AGFsaWNlAHNlY3JldA==\r\nQUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expect(std.mem.indexOf(u8, output, "AUTH") == null or std.mem.indexOf(u8, output, "250-AUTH") == null); try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null); } test "AUTH failures and sequencing" { var h: TestHandler = .{ .password = "secret" }; defer h.deinit(); var mechanisms: TestMechanisms = .init(&h); var out_buf: [2048]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ // before auth: 530 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // wrong password: 535 "AUTH GSSAPI\r\n" ++ // unsupported: 504 "AUTH PLAIN not!base64\r\n" ++ // 501 "AUTH LOGIN\r\n" ++ "*\r\n" ++ // cancelled: 501 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // correct: 235 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // again: 503 "QUIT\r\n", &out_buf, h.handler(), .{ .require_auth = true, .auth_mechanisms = mechanisms.list(), .sasl_buffer = mechanisms.scratch() }, ); try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null); try std.testing.expect(std.mem.indexOf(u8, output, "535 5.7.8") != null); try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null); try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid base64") != null); try std.testing.expect(std.mem.indexOf(u8, output, "501 5.7.0 Authentication cancelled") != null); try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null); try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Already authenticated") != null); } test "AUTH without a handler is refused" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\nAUTH PLAIN AGEAYg==\r\nQUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH") == null); try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null); } test "oversize message is rejected but session continues" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\n" ++ "0123456789012345678901234567890123456789\r\n" ++ ".\r\n" ++ "NOOP\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{ .max_message_size = 16 }, ); try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); } const StreamTestHandler = struct { collected: std.ArrayList(u8) = .empty, take_only: ?usize = null, fn handler(h: *StreamTestHandler) Handler { return .{ .context = h, .vtable = &.{ .messageReader = onMessageReader, } }; } fn onMessageReader(context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision { const h: *StreamTestHandler = @ptrCast(@alignCast(context.?)); _ = envelope; const gpa = std.testing.allocator; if (h.take_only) |n| { const bytes = message.take(n) catch return .{ .reject = .{} }; h.collected.appendSlice(gpa, bytes) catch return .{ .reject = .{} }; return .accept; } message.appendRemaining(gpa, &h.collected, .unlimited) catch return .{ .reject = .{} }; return .accept; } }; test "streaming message handler receives unstuffed content" { var h: StreamTestHandler = .{}; defer h.collected.deinit(std.testing.allocator); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\n" ++ "Subject: streamed\r\n" ++ "\r\n" ++ "..dot line\r\n" ++ "body\r\n" ++ ".\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expectEqualStrings( "Subject: streamed\r\n\r\n.dot line\r\nbody\r\n", h.collected.items, ); try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); } test "session drains what a streaming handler leaves unread" { var h: StreamTestHandler = .{ .take_only = 7 }; defer h.collected.deinit(std.testing.allocator); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\n" ++ "Subject: mostly unread\r\n" ++ "lots of body\r\n" ++ ".\r\n" ++ "NOOP\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expectEqualStrings("Subject", h.collected.items); try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); // The NOOP after DATA proves the terminator was consumed. try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); } test "fuzz session with arbitrary client input" { try std.testing.fuzz({}, fuzzSession, .{}); } fn fuzzSession(context: void, smith: *std.testing.Smith) !void { _ = context; var input_buf: [2048]u8 = undefined; const input = input_buf[0..smith.value(u11)]; smith.bytes(input); var h: TestHandler = .{ .password = "secret" }; defer h.deinit(); var reader: Io.Reader = .fixed(input); var discarding: Io.Writer.Discarding = .init(&.{}); var session: Server = .init(&reader, &discarding.writer, h.handler(), .{ .max_message_size = 512, .max_recipients = 4, }); // Whatever the "client" sends, the session must fail cleanly, never crash. session.run(std.testing.allocator) catch {}; } test "fuzz collecting and streaming DATA agree" { try std.testing.fuzz({}, fuzzDataEquivalence, .{}); } fn fuzzDataEquivalence(context: void, smith: *std.testing.Smith) !void { _ = context; var body_buf: [1024]u8 = undefined; const body = body_buf[0..smith.value(u10)]; smith.bytes(body); var script_buf: [1200]u8 = undefined; const script = std.fmt.bufPrint( &script_buf, "EHLO fuzz.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\n{s}\r\n.\r\nQUIT\r\n", .{body}, ) catch unreachable; var collecting: TestHandler = .{}; defer collecting.deinit(); var out_buf: [4096]u8 = undefined; _ = runScript(script, &out_buf, collecting.handler(), .{}) catch {}; var streaming: StreamTestHandler = .{}; defer streaming.collected.deinit(std.testing.allocator); _ = runScript(script, &out_buf, streaming.handler(), .{}) catch {}; try std.testing.expectEqualSlices(u8, collecting.data.items, streaming.collected.items); } test "MAIL parameters SIZE and BODY are honored" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM: SIZE=42 BODY=8BITMIME\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\nsized body\r\n.\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{ .max_message_size = 1024 }, ); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); try std.testing.expectEqual(@as(?u64, 42), h.declared_size); try std.testing.expectEqual(protocol.Body.eight_bit_mime, h.body.?); try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null); } test "invalid MAIL and RCPT parameters are rejected" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [2048]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM: SIZE=9999\r\n" ++ // over the maximum: 552 "RCPT TO:\r\n" ++ // that MAIL never started: 503 "MAIL FROM: SIZE=banana\r\n" ++ // 501 "MAIL FROM: BODY=BINARY\r\n" ++ // 555: not a body-value "MAIL FROM: FUTURE=yes\r\n" ++ // 555 "MAIL FROM: BODY=7bit\r\n" ++ // ok "RCPT TO: NOTIFY=SUCCESS\r\n" ++ // no RCPT params: 555 "RCPT TO:\r\n" ++ "DATA\r\nbody\r\n.\r\nQUIT\r\n", &out_buf, h.handler(), .{ .max_message_size = 1024 }, ); try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null); try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid SIZE parameter") != null); try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unsupported BODY value") != null); try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); try std.testing.expectEqual(protocol.Body.seven_bit, h.body.?); try std.testing.expectEqual(@as(?u64, null), h.declared_size); } test init { var reader: Io.Reader = .fixed(""); var out_buf: [16]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var h: TestHandler = .{}; const session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" }); try std.testing.expectEqualStrings("mx.test", session.options.hostname); try std.testing.expect(!session.secured); } test Options { const options: Options = .{}; try std.testing.expectEqualStrings("localhost", options.hostname); try std.testing.expect(options.tls == null); try std.testing.expect(!options.require_auth); } test Decision { const ok: Decision = .accept; try std.testing.expectEqual(Decision.accept, ok); const no: Decision = .{ .reject = .{ .code = 451, .text = "4.3.0 Try again later" } }; try std.testing.expectEqual(@as(u16, 451), no.reject.code); } test Envelope { const envelope: Envelope = .{ .from = "", .recipients = &.{.{ .address = "a@example.com" }} }; try std.testing.expectEqual(@as(usize, 1), envelope.recipients.len); try std.testing.expectEqual(@as(?u64, null), envelope.declared_size); try std.testing.expectEqual(@as(?protocol.Body, null), envelope.body); } test Handler { const Callbacks = struct { fn onMessage(context: ?*anyopaque, envelope: Envelope, message_data: []const u8) Decision { _ = context; _ = envelope; _ = message_data; return .accept; } }; const handler: Handler = .{ .vtable = &.{ .message = Callbacks.onMessage } }; const envelope: Envelope = .{ .from = "", .recipients = &.{} }; try std.testing.expectEqual(Decision.accept, handler.vtable.message.?(null, envelope, "")); } // SPDX-SnippetBegin // SPDX-SnippetCopyrightText: © The Exim Maintainers // SPDX-SnippetCopyrightText: © University of Cambridge // SPDX-SnippetCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: GPL-2.0-or-later // // The command dialogue and message lines below are adapted from exim's // test suite (test/scripts/0000-Basic); the reply expectations are ours. test "protocol gauntlet adapted from exim's test suite" { // Command sequences and dot-stuffing cases distilled from exim's // test/scripts/0000-Basic (notably 0019's SMTP syntax-error dialogue // and 0008/0100's dotted message lines), verified against this server // with exim's own scriptable test client. var h: TestHandler = .{}; defer h.deinit(); var out_buf: [4096]u8 = undefined; const output = try runScript( "NOOP\r\n" ++ "rhubarb\r\n" ++ "mail from:\r\n" ++ "rcpt to:\r\n" ++ "ehlo test.client\r\n" ++ "mail\r\n" ++ "mail from:\r\n" ++ "mail from:<>\r\n" ++ "mail from:\r\n" ++ "rcpt to:\r\n" ++ "data\r\n" ++ "rset\r\n" ++ "etrn abc\r\n" ++ "vrfy userx\r\n" ++ "help\r\n" ++ "mail from: SIZE=100 BODY=8BITMIME\r\n" ++ "rcpt to:\r\n" ++ "rcpt to:<@relay.example:route@test.ex>\r\n" ++ "data\r\n" ++ "..that line started with a dot\r\n" ++ ".. and one starting with two dots\r\n" ++ "Message body\r\n" ++ ".\r\n" ++ "mail from: SIZE=99999999\r\n" ++ "mail from: BODY=BINARY\r\n" ++ "mail from: FOO=bar\r\n" ++ "mail from: SIZE=nan\r\n" ++ "starttls\r\n" ++ "mail from:\r\n" ++ "mail from: SMTPUTF8=YES\r\n" ++ "mail from: SMTPUTF8\r\n" ++ "rset\r\n" ++ "BDAT 5\r\n" ++ "abc\r\n" ++ "mail from:\r\n" ++ "rcpt to:\r\n" ++ "BDAT 7\r\n" ++ "hello\r\n" ++ "BDAT 23 LAST\r\n" ++ "world of chunked mail\r\n" ++ "quit\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expectEqualStrings( "220 localhost ESMTP ready\r\n" ++ "250 2.0.0 Ok\r\n" ++ "500 5.5.2 Command not recognized\r\n" ++ "503 5.5.1 Send EHLO first\r\n" ++ "503 5.5.1 Need MAIL command first\r\n" ++ "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-BINARYMIME\r\n" ++ "250-SMTPUTF8\r\n250-ENHANCEDSTATUSCODES\r\n250-DSN\r\n250 SIZE 16777216\r\n" ++ "501 5.5.4 Syntax error in parameters\r\n" ++ "501 5.5.4 Syntax error in parameters\r\n" ++ "250 2.1.0 Ok\r\n" ++ "503 5.5.1 Nested MAIL command\r\n" ++ "501 5.5.4 Syntax error in parameters\r\n" ++ "503 5.5.1 Need RCPT command first\r\n" ++ "250 2.0.0 Ok\r\n" ++ "500 5.5.2 Command not recognized\r\n" ++ "252 2.5.2 Cannot VRFY user\r\n" ++ "214 2.0.0 See RFC 5321\r\n" ++ "250 2.1.0 Ok\r\n" ++ "250 2.1.5 Ok\r\n" ++ "250 2.1.5 Ok\r\n" ++ "354 End data with .\r\n" ++ "250 2.0.0 Ok, message accepted\r\n" ++ "552 5.3.4 Message size exceeds fixed maximum\r\n" ++ "555 5.5.4 Unsupported BODY value\r\n" ++ "555 5.5.4 Unrecognized parameter\r\n" ++ "501 5.5.2 Invalid SIZE parameter\r\n" ++ "502 5.5.1 STARTTLS not supported\r\n" ++ "553 5.6.7 Non-ASCII address requires SMTPUTF8\r\n" ++ "501 5.5.4 SMTPUTF8 takes no value\r\n" ++ "250 2.1.0 Ok\r\n" ++ "250 2.0.0 Ok\r\n" ++ "503 5.5.1 Need RCPT command first\r\n" ++ "250 2.1.0 Ok\r\n" ++ "250 2.1.5 Ok\r\n" ++ "250 2.0.0 Chunk received\r\n" ++ "250 2.0.0 Ok, message accepted\r\n" ++ "221 2.0.0 Bye\r\n", output, ); try std.testing.expectEqual(@as(usize, 2), h.messages_accepted); try std.testing.expectEqualStrings("ok@test1chunky@test.ex", h.from.items); try std.testing.expectEqualStrings( "userx@test.ex;route@test.ex;userx@test.ex;", h.recipients.items, ); try std.testing.expectEqualStrings( ".that line started with a dot\r\n. and one starting with two dots\r\nMessage body\r\n" ++ "hello\r\nworld of chunked mail\r\n", h.data.items, ); } // SPDX-SnippetEnd test "BDAT chunks are reassembled without unstuffing" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "BDAT 20\r\n" ++ "Subject: chunked\r\n\r\n" ++ // exactly 20 raw octets "BDAT 18\r\n" ++ ".dots stay\nas-is\r\n" ++ // 18 raw octets, no unstuffing "BDAT 0 LAST\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expectEqualStrings( "Subject: chunked\r\n\r\n.dots stay\nas-is\r\n", h.data.items, ); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Chunk received") != null); try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); } test "BDAT framing is length-based, not content-based" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ // Without a transaction the chunk must still be consumed, or the // embedded commands would be executed. "BDAT 12\r\n" ++ "QUIT\r\nRSET\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ // A chunk whose payload looks like commands is still just data. "BDAT 23 LAST\r\n" ++ "QUIT\r\nMAIL FROM:\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expectEqualStrings("QUIT\r\nMAIL FROM:\r\n", h.data.items); try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); try std.testing.expect(std.mem.indexOf(u8, output, "221 2.0.0 Bye") != null); } test "RSET between BDAT chunks aborts the message" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "BDAT 5\r\n" ++ "abc\r\n" ++ "RSET\r\n" ++ "NOOP\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Chunk received") != null); try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n250 2.0.0 Ok\r\n221") != null); } test "oversize BDAT message is rejected" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "BDAT 40 LAST\r\n" ++ "0123456789012345678901234567890123456789" ++ "NOOP\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{ .max_message_size = 16 }, ); try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); } test "streaming handler receives BDAT chunks" { var h: StreamTestHandler = .{}; defer h.collected.deinit(std.testing.allocator); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "BDAT 6\r\n" ++ "part1\n" ++ "BDAT 8 LAST\r\n" ++ ".part2\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expectEqualStrings("part1\n.part2\r\n", h.collected.items); try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); } test "session drains BDAT chunks a streaming handler leaves unread" { var h: StreamTestHandler = .{ .take_only = 4 }; defer h.collected.deinit(std.testing.allocator); var out_buf: [1024]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "BDAT 10\r\n" ++ "0123456789" ++ "BDAT 10 LAST\r\n" ++ "abcdefghij" ++ "NOOP\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expectEqualStrings("0123", h.collected.items); try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null); // The NOOP after the final chunk proves the stream stayed in sync. try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); } test "SMTPUTF8 transactions and non-ASCII address enforcement" { var h: TestHandler = .{}; defer h.deinit(); var out_buf: [2048]u8 = undefined; const output = try runScript( "EHLO client.example.org\r\n" ++ // Non-ASCII without the parameter: rejected. "MAIL FROM:\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "RSET\r\n" ++ // The parameter takes no value. "MAIL FROM: SMTPUTF8=YES\r\n" ++ // Invalid UTF-8 bytes even with the parameter: rejected. "MAIL FROM: SMTPUTF8\r\n" ++ // Proper internationalized transaction. "MAIL FROM: SMTPUTF8\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\nSubject: ünïcode\r\n\r\nhello\r\n.\r\n" ++ "QUIT\r\n", &out_buf, h.handler(), .{}, ); try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); try std.testing.expect(h.smtputf8); try std.testing.expectEqualStrings("böb@example.com", h.from.items); try std.testing.expectEqualStrings("jürgen@example.net;", h.recipients.items); try std.testing.expect(std.mem.indexOf(u8, output, "250-SMTPUTF8\r\n") != null); try std.testing.expect(std.mem.indexOf(u8, output, "553 5.6.7 Non-ASCII address requires SMTPUTF8") != null); try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.4 SMTPUTF8 takes no value") != null); try std.testing.expect(std.mem.indexOf(u8, output, "553 5.6.7 Address is not valid UTF-8") != null); }