// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! An SMTP client session over any `Io.Reader`/`Io.Writer` pair, which keeps //! it transport-agnostic: wrap a TCP stream for real use, or fixed buffers //! for testing. TLS can be layered in the same way once the transport //! supports it. //! //! Typical use: //! ``` //! var client: Client = .init(&stream_reader, &stream_writer, &reply_buf); //! _ = try client.greet(); //! _ = try client.hello("my-host.example.com"); //! try client.sendMail("me@example.com", &.{"you@example.net"}, message); //! try client.quit(); //! ``` const Client = @This(); const std = @import("std"); const Io = std.Io; const protocol = @import("protocol.zig"); const sasl = @import("sasl"); const Reply = protocol.Reply; reader: *Io.Reader, writer: *Io.Writer, /// Backing storage for reply text; `last_reply.text` points into it. reply_buffer: []u8, /// Scratch for the AUTH exchange, needed only by `authenticate` — a client /// that never authenticates may leave it empty. /// /// It is the caller's for the same reason `reply_buffer` is: 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 bearer token can be /// several kilobytes on its own. `sasl_buffer_suggested` is a size that fits /// everything short of an unusually fat token. /// /// It is 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 = &.{}, /// The most recent reply read from the server. Useful for reporting the /// server's actual response after an `error.UnexpectedReply`. last_reply: ?Reply = null, /// Whether the transport is encrypted. This library cannot tell on its own /// — it is handed a reader and a writer and has no idea what is under them /// — so it assumes the worst and the caller says otherwise. /// /// `setTransport` takes the answer as an argument, which covers a STARTTLS /// upgrade. A session that speaks TLS from the first byte (port 465) hands /// `init` an already-encrypted transport, and sets this itself. security: Security = .plaintext, /// Whether the server advertised PIPELINING /// ([RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920)), which /// `envelope` uses to send a whole envelope in one round trip. Set by /// `hello` from the EHLO response, and cleared by a HELO fallback, since /// RFC 2920 §3.1 lets a client pipeline only against a server that said it /// could take it. pipelining: bool = false, /// Which protocol to speak. Set before `hello`; see `Protocol`. (Spelled /// `mode` rather than `protocol` only because this file's `protocol` /// module import already holds that name in this scope; the server's /// equivalent is `Server.Options.protocol`.) mode: Protocol = .smtp, /// Recipients the server has accepted since the last MAIL, which in LMTP /// is how many replies the end of the message will draw. accepted_recipients: usize = 0, /// Whether the current transaction was opened with `BODY=BINARYMIME`, in /// which case its content can only go out by BDAT. binary: bool = false, /// Permits `authenticate`, `authPlain` and `authLogin` to send credentials /// over a `.plaintext` transport, which they otherwise refuse with /// `error.InsecureTransport`. /// /// The honest use is a connection protected by something outside this /// library's view — a unix socket, an SSH tunnel, a loopback test — where /// setting `security` to `.encrypted` would be a lie. Anything else is /// handing the password to the network. allow_cleartext_auth: bool = false, /// Whether the transport encrypts what is written to it. pub const Security = enum { plaintext, encrypted }; /// Which protocol this session speaks. `.lmtp` sends `LHLO` in place of /// `EHLO` and expects one reply per accepted recipient at the end of a /// message instead of one for the message /// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)); everything /// else is the same. Set it before `hello`. pub const Protocol = enum { smtp, lmtp }; pub const Error = error{ WriteFailed, ReadFailed, EndOfStream, LineTooLong, InvalidReply, ReplyTooLong, /// The server answered with an unexpected code; see `last_reply`. UnexpectedReply, /// The transaction was opened with `BODY=BINARYMIME`, whose content /// can only be sent with `bdat`. A server would answer DATA with 503 /// (RFC 3030 §3); this is the same refusal, made before the round trip. BinaryRequiresChunking, /// LMTP only: at least one recipient's verdict at the end of the /// message was not a 2xx. /// /// It is a separate error from `UnexpectedReply` because `last_reply` /// cannot answer "which one": the replies arrive one after another into /// a single buffer, so reading the next overwrites the previous, and by /// the time the last has been read the failing one's text is gone. Use /// `DataWriter.endResults` to see each verdict as it arrives. RecipientRejected, }; pub const ArgumentError = error{ /// The transport is not encrypted and what was asked for needs it — /// a mechanism that would put a reusable credential on the wire, or a /// REQUIRETLS guarantee that would mean nothing without one. /// /// Upgrade the session with `starttls`, or for the credential case set /// `allow_cleartext_auth` if the connection is protected by something /// this library cannot see. InsecureTransport, /// An argument contained CR, LF or NUL and was not sent. See /// `protocol.isSafeArgument` for why those three bytes and no others. UnsafeArgument, /// An ESMTP parameter value exceeded the length its RFC allows — /// `ENVID` past 100 characters or `ORCPT` past 500, measured on the /// xtext-encoded form that would go on the wire. ArgumentTooLong, }; /// Extensions advertised in the server's EHLO response. pub const Extensions = struct { pipelining: bool = false, eight_bit_mime: bool = false, starttls: bool = false, smtputf8: bool = false, chunking: bool = false, /// The server takes `BODY=BINARYMIME` /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). Always /// accompanied by `chunking`, since binary content can only be sent /// with BDAT — a server advertising one without the other is broken, /// and sending binary to it anyway is what RFC 3030 forbids outright. binary_mime: bool = false, enhanced_status_codes: bool = false, /// The server offers REQUIRETLS /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)). Only /// ever seen on a TLS-protected session, since that is the only kind it /// may be advertised on. requiretls: bool = false, /// The server accepts the DSN parameters of /// [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — `RET` and /// `ENVID` on MAIL, `NOTIFY` and `ORCPT` on RCPT. dsn: bool = false, /// The mechanism names from the server's `AUTH` keyword, space-separated /// exactly as it sent them, for `sasl.Client.selectFromList`. /// /// A slice into the client's reply buffer, so it is valid until the next /// reply is read — which for the usual `hello` then `authenticate` /// sequence is long enough, since nothing is read in between. auth: []const u8 = "", /// Value of the SIZE extension, if advertised with a value. max_size: ?u64 = null, fn parse(reply: Reply) Extensions { var ext: Extensions = .{}; var it = reply.lines(); _ = it.next(); // The first line is the server's greeting, not a keyword. while (it.next()) |line| { const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; const kw = line[0..kw_end]; const arg = if (kw_end < line.len) line[kw_end + 1 ..] else ""; if (ieql(kw, "PIPELINING")) { ext.pipelining = true; } else if (ieql(kw, "8BITMIME")) { ext.eight_bit_mime = true; } else if (ieql(kw, "STARTTLS")) { ext.starttls = true; } else if (ieql(kw, "SMTPUTF8")) { ext.smtputf8 = true; } else if (ieql(kw, "CHUNKING")) { ext.chunking = true; } else if (ieql(kw, "BINARYMIME")) { ext.binary_mime = true; } else if (ieql(kw, "ENHANCEDSTATUSCODES")) { ext.enhanced_status_codes = true; } else if (ieql(kw, "DSN")) { ext.dsn = true; } else if (ieql(kw, "REQUIRETLS")) { ext.requiretls = true; } else if (ieql(kw, "AUTH")) { ext.auth = arg; } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) { // Some servers old enough to predate RFC 4954 advertise // "AUTH=PLAIN LOGIN", with the first name jammed onto the // keyword. Taking the line from the '=' recovers the whole // list, which is why this points into the reply rather than // rebuilding it somewhere that would not outlive the call. ext.auth = line[kw_end - (kw.len - 5) ..]; } else if (ieql(kw, "SIZE")) { ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null; } } return ext; } fn ieql(a: []const u8, b: []const u8) bool { return std.ascii.eqlIgnoreCase(a, b); } }; /// `reply_buffer` must be large enough for the largest expected reply text /// (the EHLO response is usually the largest); 512 bytes is plenty in /// practice. pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client { return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer }; } /// Reads the server's 220 greeting. Call once, right after connecting. pub fn greet(c: *Client) Error!Reply { return c.expect(220); } /// Sends EHLO ([RFC 5321 §4.1.1.1](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.1)) /// and returns the extensions the server advertised, falling back /// to plain HELO for servers that do not speak ESMTP. pub fn hello(c: *Client, client_name: []const u8) (Error || ArgumentError)!Extensions { if (!protocol.isSafeArgument(client_name)) return error.UnsafeArgument; c.accepted_recipients = 0; c.binary = false; c.pipelining = false; if (c.mode == .lmtp) { // LHLO has EHLO's semantics, and there is no older greeting to fall // back to: an LMTP server that will not take LHLO is not one. try c.send("LHLO {s}", .{client_name}); const extensions = Extensions.parse(try c.expectClass(2)); c.pipelining = extensions.pipelining; return extensions; } try c.send("EHLO {s}", .{client_name}); const reply = try c.readReply(); if (reply.isPositiveCompletion()) { const extensions = Extensions.parse(reply); c.pipelining = extensions.pipelining; return extensions; } if (reply.code == 500 or reply.code == 502) { // A server old enough to refuse EHLO has no extensions at all. try c.send("HELO {s}", .{client_name}); _ = try c.expectClass(2); return .{}; } return error.UnexpectedReply; } /// Sends STARTTLS ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)) and /// reads the server's 220 go-ahead. On /// success, perform a TLS handshake over the underlying stream (see `Tls`), /// switch to the encrypted transport with `setTransport`, and then call /// `hello` again — the server discards everything it learned before the /// handshake, including the EHLO state. pub fn starttls(c: *Client) Error!void { try c.send("STARTTLS", .{}); _ = try c.expect(220); } /// Replaces the session's transport, typically with a TLS reader/writer /// after `starttls`, and records whether the new one is encrypted. Pass /// `.encrypted` for a TLS transport; that is what lets `authenticate` use a /// mechanism that sends the password. pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer, security: Security) void { c.reader = reader; c.writer = writer; c.security = security; } pub const AuthError = Error || ArgumentError || sasl.Client.Error || error{ /// The server rejected the credentials; see `last_reply`. AuthenticationFailed, /// The server's challenge was not valid base64, or was longer than the /// buffer given to it. InvalidChallenge, /// `sasl_buffer` was empty or smaller than `sasl_buffer_min`. It is not /// allocated here for the same reason `reply_buffer` is not: how much a /// mechanism needs is the caller's to know. SaslBufferTooSmall, /// The server accepted the exchange but the mechanism had not finished /// proving what it set out to prove. /// /// For a one-way mechanism this cannot happen. For SCRAM it means the /// server reported success without ever producing its own signature — /// which is what something in the middle, holding no verifier, would do. /// The credentials are not compromised by it, but the peer is not the /// server, and the session should be abandoned rather than used. ServerNotAuthenticated, }; /// The smallest `sasl_buffer` worth offering: enough plaintext for PLAIN, /// LOGIN, CRAM-MD5, EXTERNAL, ANONYMOUS, DIGEST-MD5 and SCRAM, none of which /// send more than a few hundred bytes. pub const sasl_buffer_min = 896; /// A `sasl_buffer` size that fits everything, including an OAuth token of a /// couple of kilobytes. /// /// [RFC 4954 §4](https://datatracker.ietf.org/doc/html/rfc4954#section-4) /// says a client "MUST be able to handle the maximum encoded size of /// challenges and responses generated by their supported authentication /// mechanisms" and offers 12288 octets as a sufficient line length. Seven /// thousand here is a plaintext message of three thousand, which encodes to /// four — comfortably inside that. pub const sasl_buffer_suggested = 7168; /// Runs a SASL exchange with `mechanism` /// ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). /// /// The mechanisms themselves live in /// [zig-sasl](https://git.jcollie.dev/jeff/zig-sasl) — `sasl.Plain`, /// `sasl.CramMd5`, `sasl.XOAuth2` and the rest, with SCRAM in zig-scram — /// because they are shared with every other protocol that speaks SASL and /// nothing about them is specific to SMTP. What is specific to SMTP is this /// function: `AUTH`, the 334 challenges, the `*` that cancels, and 235. /// /// ```zig /// var plain: sasl.Plain = .init("alice", "secret"); /// const extensions = try client.hello("my-host.example.com"); /// const mechanism = sasl.Client.selectFromList( /// &.{ plain.client() }, /// extensions.auth, /// client.security == .encrypted, /// ) orelse return error.NoSupportedMechanism; /// try client.authenticate(mechanism); /// ``` /// /// A mechanism that would put a reusable credential on an unencrypted /// transport is refused before anything is sent, as it was when the /// mechanisms lived here. When the mechanism itself fails mid-exchange the /// session is cancelled with `*` rather than abandoned, so the connection is /// left usable and the server's 501 is read rather than waiting in the /// stream for whatever comes next. pub fn authenticate(c: *Client, mechanism: sasl.Client) AuthError!void { if (mechanism.cleartext()) try c.requireConfidentiality(); const scratch = try splitSaslBuffer(c.sasl_buffer); var message: Io.Writer = .fixed(scratch.plain); switch (try c.mechanismStep(mechanism.initial(&message))) { .none => try c.send("AUTH {s}", .{mechanism.name()}), .written => { const encoded = std.base64.standard.Encoder.encode(scratch.coded, message.buffered()); // RFC 4954 §4: a zero-length initial response is a single `=`, // because an empty argument would be indistinguishable from // sending none at all. try c.send("AUTH {s} {s}", .{ mechanism.name(), if (encoded.len == 0) "=" else encoded }); }, } while (true) { const reply = try c.readReply(); if (reply.code == 235) break; if (reply.code != 334) return error.AuthenticationFailed; // The challenge decodes into the coded half, which is free: whatever // was encoded there has already gone out. const challenge = decodeChallenge(scratch.coded, reply.text) orelse { try c.cancelAuth(); return error.InvalidChallenge; }; message = .fixed(scratch.plain); try c.mechanismStep(mechanism.respond(challenge, &message)); // ...and the response encodes back over it, the challenge having // been consumed by `respond`. try c.send("{s}", .{std.base64.standard.Encoder.encode(scratch.coded, message.buffered())}); } // The server says yes. Whether that means anything is the mechanism's to // say: see `ServerNotAuthenticated`. if (!mechanism.satisfied()) return error.ServerNotAuthenticated; } /// Cancels the exchange on a mechanism error and turns it into ours. /// /// A mechanism that has failed will not produce another message, so the /// server is left waiting for a line that is never coming. RFC 4954 §4 gives /// `*` for exactly this, and answers it with 501, which is read here so the /// session is clean for whatever the caller does next. fn mechanismStep(c: *Client, result: anytype) AuthError!@typeInfo(@TypeOf(result)).error_union.payload { return result catch |err| { c.cancelAuth() catch {}; return err; }; } fn cancelAuth(c: *Client) Error!void { try c.send("*", .{}); _ = c.readReply() catch {}; } /// Decodes a challenge, which may legitimately be empty: RFC 4954 §4 spells a /// zero-length challenge `334 ` — the code, a space, and nothing after it. fn decodeChallenge(buffer: []u8, text: []const u8) ?[]const u8 { if (text.len == 0) return buffer[0..0]; const len = std.base64.standard.Decoder.calcSizeForSlice(text) catch return null; if (len > buffer.len) return null; std.base64.standard.Decoder.decode(buffer[0..len], text) catch return null; return buffer[0..len]; } /// Refuses a mechanism that would transmit a reusable credential unprotected. fn requireConfidentiality(c: *Client) AuthError!void { if (c.security == .encrypted or c.allow_cleartext_auth) return; return error.InsecureTransport; } /// The two halves of `sasl_buffer`. /// /// `coded` is four sevenths and `plain` three, which is base64's expansion /// exactly — so `coded` always holds the encoding of a full `plain`. They /// never hold anything at the same time: a challenge decodes into `coded`, /// is consumed by the mechanism writing into `plain`, and the answer encodes /// back over it. const SaslScratch = struct { coded: []u8, plain: []u8 }; fn splitSaslBuffer(buffer: []u8) AuthError!SaslScratch { if (buffer.len < sasl_buffer_min) return error.SaslBufferTooSmall; const unit = buffer.len / 7; return .{ .coded = buffer[0 .. unit * 4], .plain = buffer[unit * 4 ..][0 .. unit * 3] }; } /// Parameters for the MAIL command. Send only what the server advertised: /// an unrecognized parameter is a 555 from a conforming server, so check /// `Extensions` first. pub const MailOptions = struct { /// Requests the SMTPUTF8 extension /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)), which /// lets the envelope and headers carry UTF-8. Needs `Extensions.smtputf8`. smtputf8: bool = false, /// `BODY=`: what kind of content the message carries. /// `.eight_bit_mime` needs `Extensions.eight_bit_mime`; /// `.binary_mime` needs `Extensions.binary_mime`, and commits the /// transaction to BDAT — `data` will refuse to open a DATA phase for /// it, as RFC 3030 §3 requires. body: ?protocol.Body = null, /// `REQUIRETLS` /// ([RFC 8689](https://datatracker.ietf.org/doc/html/rfc8689)): do not /// let this message travel onward in the clear — bounce it instead. /// Needs `Extensions.requiretls`. /// /// Refused on a session this client does not believe is encrypted, with /// `error.InsecureTransport`, because asking for a guarantee over a /// channel that has none is asking for nothing. That is the part this /// library can check. The rest of RFC 8689 §4.1's preconditions are the /// caller's and cannot be checked from here: the server's certificate /// must have been validated by a trust chain or DANE — so not with /// `Tls.Options.ca = .insecure` — and the MX itself must have been /// vouched for by DNSSEC or MTA-STS, which this library does not resolve. require_tls: bool = false, /// `AUTH=` /// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): /// who originally submitted this message, for a relay carrying it on /// behalf of somebody else. Needs the server to advertise AUTH. /// /// `.unknown` sends `<>`, which is what a relay should send when it /// cannot vouch for the submitter — RFC 4954 asks for that rather than /// leaving the parameter off, because a server receiving no parameter /// learns nothing while one receiving `<>` learns that the peer /// considered the question. The mailbox form is xtext-encoded, so any /// bytes are safe to pass, and is rejected with `error.ArgumentTooLong` /// past the 500 characters RFC 4954 makes room for. /// /// Being believed is another matter: the receiving server disregards /// this unless this client has authenticated to it. auth: ?protocol.Submitter = null, /// DSN `RET=`: how much of the message a failure report should carry /// back. Needs `Extensions.dsn`. ret: ?protocol.Ret = null, /// DSN `ENVID=`: an identifier quoted back in any report about this /// message. Sent xtext-encoded, so any bytes are safe to pass, and /// rejected with `error.ArgumentTooLong` if the encoded form exceeds the /// 100 characters RFC 3461 allows. Needs `Extensions.dsn`. envid: ?[]const u8 = null, }; /// Parameters for the RCPT command, which in this library means the DSN /// ones. Needs `Extensions.dsn`; see `MailOptions`. pub const RcptOptions = struct { /// DSN `NOTIFY=`: when the sender wants to hear about this recipient. /// Leave null to let the receiver apply its default. notify: ?protocol.Notify = null, /// DSN `ORCPT=`: the address the message was originally addressed to, /// carried through aliasing so a report can name what the sender wrote. /// The address is sent xtext-encoded; the `addr_type` is not, so it is /// checked instead, and the whole parameter is capped at the 500 /// characters RFC 3461 allows. orcpt: ?protocol.Orcpt = null, }; /// Starts a mail transaction. An empty `from` sends the null reverse-path /// (`MAIL FROM:<>`), used for bounces. /// /// Returns `error.UnsafeArgument` for an address that would break out of /// the command line; see `protocol.isSafeArgument`. pub fn mailFrom(c: *Client, from: []const u8) (Error || ArgumentError)!void { return c.mail(from, .{}); } /// `mailFrom` with ESMTP parameters. pub fn mail(c: *Client, from: []const u8, options: MailOptions) (Error || ArgumentError)!void { try c.checkMail(from, options); try c.writeMail(from, options); try c.writer.flush(); _ = try c.expectClass(2); c.accepted_recipients = 0; c.binary = options.body == .binary_mime; } /// Everything about a MAIL command that can be refused before it is /// written. Split out so that a pipelined group can be validated in full /// before any of it goes on the wire. fn checkMail(c: *Client, from: []const u8, options: MailOptions) ArgumentError!void { if (!protocol.isSafeArgument(from)) return error.UnsafeArgument; // A guarantee about a channel with no protection is not a guarantee. if (options.require_tls and c.security != .encrypted) return error.InsecureTransport; if (options.envid) |envid| { if (protocol.xtextEncodedLen(envid) > protocol.max_envid_len) return error.ArgumentTooLong; } if (options.auth) |auth| switch (auth) { .unknown => {}, .mailbox => |mailbox| { if (mailbox.len == 0) return error.UnsafeArgument; if (protocol.xtextEncodedLen(mailbox) > protocol.Submitter.max_len) return error.ArgumentTooLong; }, }; } /// Writes MAIL without flushing or reading its reply. fn writeMail(c: *Client, from: []const u8, options: MailOptions) Error!void { try c.writer.print("MAIL FROM:<{s}>", .{from}); if (options.require_tls) try c.writer.writeAll(" REQUIRETLS"); if (options.auth) |auth| try c.writer.print(" AUTH={f}", .{auth}); if (options.body) |body| try c.writer.print(" BODY={f}", .{body}); if (options.smtputf8) try c.writer.writeAll(" SMTPUTF8"); if (options.ret) |ret| try c.writer.print(" RET={f}", .{ret}); if (options.envid) |envid| { try c.writer.writeAll(" ENVID="); try protocol.writeXtext(c.writer, envid); } try c.writer.writeAll(protocol.crlf); } /// Adds a recipient to the current transaction. Returns /// `error.UnsafeArgument` for an address that would break out of the /// command line; see `protocol.isSafeArgument`. pub fn rcptTo(c: *Client, to: []const u8) (Error || ArgumentError)!void { return c.rcpt(to, .{}); } /// `rcptTo` with ESMTP parameters. pub fn rcpt(c: *Client, to: []const u8, options: RcptOptions) (Error || ArgumentError)!void { const code = try c.rcptCode(to, options); if (code / 100 != 2) return error.UnexpectedReply; } /// `rcpt`, but a refusal is the returned code rather than an error. The /// reply is in `last_reply` either way. fn rcptCode(c: *Client, to: []const u8, options: RcptOptions) (Error || ArgumentError)!u16 { try c.checkRcpt(to, options); try c.writeRcpt(to, options); try c.writer.flush(); const reply = try c.readReply(); if (reply.isPositiveCompletion()) c.accepted_recipients += 1; return reply.code; } /// Everything about a RCPT command that can be refused before it is /// written; see `checkMail`. fn checkRcpt(c: *Client, to: []const u8, options: RcptOptions) ArgumentError!void { _ = c; if (!protocol.isSafeArgument(to)) return error.UnsafeArgument; if (options.orcpt) |orcpt| { if (orcpt.addr_type.len == 0 or !protocol.isSafeArgument(orcpt.addr_type) or std.mem.findScalar(u8, orcpt.addr_type, ';') != null) return error.UnsafeArgument; if (orcpt.addr_type.len + 1 + protocol.xtextEncodedLen(orcpt.address) > protocol.Orcpt.max_len) return error.ArgumentTooLong; } } /// Writes RCPT without flushing or reading its reply. fn writeRcpt(c: *Client, to: []const u8, options: RcptOptions) Error!void { try c.writer.print("RCPT TO:<{s}>", .{to}); if (options.notify) |notify| try c.writer.print(" NOTIFY={f}", .{notify}); if (options.orcpt) |orcpt| try c.writer.print(" ORCPT={f}", .{orcpt}); try c.writer.writeAll(protocol.crlf); } pub const EnvelopeOptions = struct { /// Parameters for the MAIL command. mail: MailOptions = .{}, /// Parameters applied to every RCPT command. Per-recipient parameters /// need `rcpt` called individually. rcpt: RcptOptions = .{}, }; /// Sends MAIL FROM and one RCPT TO per recipient, then reads every reply, /// and returns how many recipients the server accepted. /// /// When the server advertised PIPELINING the commands go out as a single /// group and their replies are read together, which turns an envelope of /// *n* recipients from *n*+1 round trips into one. Otherwise each command /// waits for its own reply, and the result is the same either way. /// /// DATA is deliberately not part of the group, though RFC 2920 §3.1 allows /// it as the last command of one. Once a server has answered DATA with 354 /// the transaction is committed, and a caller that wanted all-or-nothing /// delivery has no way back: the only ways out of the data phase are to /// send the message or to send an empty one to whichever recipients *were* /// accepted. Stopping the group before DATA keeps that decision with the /// caller, and costs one round trip out of the *n*+1 saved. /// /// `codes`, when given, must have room for `recipients.len` entries and /// receives each RCPT reply code in order. Codes rather than replies /// because the replies share one buffer: by the time the group has been /// read, only the last one's text still exists. /// /// A refused MAIL FROM is `error.UnexpectedReply`, with the reply in /// `last_reply` and the rest of the group drained. Refused *recipients* /// are not an error — with several of them the caller is the one who can /// say whether what remains is worth sending — so compare the returned /// count against `recipients.len`. pub fn envelope( c: *Client, from: []const u8, recipients: []const []const u8, codes: ?[]u16, options: EnvelopeOptions, ) (Error || ArgumentError)!usize { if (codes) |slice| std.debug.assert(slice.len >= recipients.len); if (!c.pipelining) { try c.mail(from, options.mail); var accepted: usize = 0; for (recipients, 0..) |recipient, index| { const code = try c.rcptCode(recipient, options.rcpt); if (codes) |slice| slice[index] = code; if (code / 100 == 2) accepted += 1; } return accepted; } // Everything is validated before anything is written: a group that // turned out to be unsendable halfway through would leave the session // holding a partial command. try c.checkMail(from, options.mail); for (recipients) |recipient| try c.checkRcpt(recipient, options.rcpt); try c.writeMail(from, options.mail); for (recipients) |recipient| try c.writeRcpt(recipient, options.rcpt); try c.writer.flush(); // RFC 2920 §3.1: every status in the group must be checked, and all of // them must be read whatever the first one said, or the replies still // queued would be mistaken for the answers to whatever comes next. const mail_reply = try c.readReply(); const mail_ok = mail_reply.isPositiveCompletion(); if (mail_ok) { c.accepted_recipients = 0; c.binary = options.mail.body == .binary_mime; } var accepted: usize = 0; for (0..recipients.len) |index| { if (!mail_ok) { // The MAIL reply is the one worth keeping, so the rest of the // group is drained without disturbing it. try c.discardReply(); if (codes) |slice| slice[index] = 0; continue; } const reply = try c.readReply(); if (codes) |slice| slice[index] = reply.code; if (reply.isPositiveCompletion()) { accepted += 1; c.accepted_recipients += 1; } } if (!mail_ok) return error.UnexpectedReply; return accepted; } /// Sends the message content for the current transaction (DATA). Line /// endings in `data` are normalized to CRLF and leading dots are stuffed. pub fn sendMessage(c: *Client, message_data: []const u8) Error!void { var data_writer = try c.data(); try data_writer.interface.writeAll(message_data); try data_writer.end(); } /// Streams the message content for the current transaction from `message` /// until end of stream. Line endings are normalized to CRLF and leading /// dots stuffed; nothing is buffered beyond the transport writer, so lines /// and messages of any length work. pub fn sendMessageReader(c: *Client, message: *Io.Reader) Error!void { var data_writer = try c.data(); while (true) { const chunk = message.peekGreedy(1) catch |err| switch (err) { error.EndOfStream => break, error.ReadFailed => return error.ReadFailed, }; try data_writer.interface.writeAll(chunk); message.toss(chunk.len); } try data_writer.end(); } /// Starts the DATA phase for streaming a message body: write the content /// through the returned writer's `interface`, then call `end`. Line endings /// are normalized to CRLF and leading dots stuffed as the data flows. pub fn data(c: *Client) Error!DataWriter { if (c.binary) return error.BinaryRequiresChunking; try c.send("DATA", .{}); _ = try c.expect(354); return .{ .client = c, .interface = .{ .buffer = &.{}, .vtable = &.{ .drain = DataWriter.drain }, }, }; } /// Streaming writer for a message body; obtained from `data`. The dot /// stuffing and CRLF normalization state lives here, so chunks may split /// lines (and even CRLF pairs) at any byte boundary. pub const DataWriter = struct { client: *Client, interface: Io.Writer, at_line_start: bool = true, /// A '\r' was seen but not yet emitted; whether it is a line ending /// depends on the next byte. pending_cr: bool = false, /// Terminates the message (adding a final CRLF if the content did not /// end with one, then ".\r\n") and reads the server's verdict. /// /// In LMTP that is one verdict per accepted recipient rather than one /// for the message. All of them are read — leaving any unread would /// desynchronize the session — and a non-2xx among them becomes /// `error.RecipientRejected`, with that first refusal left in /// `last_reply`: once one has been read, the rest of the group is /// drained without disturbing it. Which *recipient* it belonged to is /// only available from `endResults`, which is the whole reason for /// speaking LMTP and the way to see every verdict. pub fn end(dw: *DataWriter) Error!void { var verdicts = try dw.endResults(); const per_recipient = verdicts.remaining > 1; while (try verdicts.next()) |reply| { if (reply.isPositiveCompletion()) continue; while (verdicts.remaining > 0) : (verdicts.remaining -= 1) try dw.client.discardReply(); // With one reply there was never any ambiguity to begin with. return if (per_recipient) error.RecipientRejected else error.UnexpectedReply; } } /// Terminates the message and returns the verdicts to read: one in /// SMTP, one per accepted recipient in LMTP, in the order the RCPT /// commands were issued. Every one of them must be read before the /// session is used again. pub fn endResults(dw: *DataWriter) Error!Results { try dw.interface.flush(); const c = dw.client; if (dw.pending_cr) { // A trailing bare CR counts as a line ending, matching // `protocol.writeStuffed`. dw.pending_cr = false; dw.at_line_start = true; try c.writer.writeAll(protocol.crlf); } if (!dw.at_line_start) try c.writer.writeAll(protocol.crlf); try c.writer.writeAll("." ++ protocol.crlf); try c.writer.flush(); return c.results(); } fn drain(w: *Io.Writer, chunks: []const []const u8, splat: usize) Io.Writer.Error!usize { const dw: *DataWriter = @alignCast(@fieldParentPtr("interface", w)); try dw.writeChunk(w.buffered()); w.end = 0; if (chunks.len == 0) return 0; var n: usize = 0; for (chunks[0 .. chunks.len - 1]) |bytes| { try dw.writeChunk(bytes); n += bytes.len; } const pattern = chunks[chunks.len - 1]; for (0..splat) |_| { try dw.writeChunk(pattern); n += pattern.len; } return n; } test end { var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); var out_buf: [64]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); var data_writer = try client.data(); try data_writer.interface.writeAll("no trailing newline"); try data_writer.end(); // adds the final CRLF, sends ".", reads 250 try std.testing.expectEqualStrings( "DATA\r\nno trailing newline\r\n.\r\n", writer.buffered(), ); } fn writeChunk(dw: *DataWriter, bytes: []const u8) Io.Writer.Error!void { const out = dw.client.writer; var rest = bytes; while (rest.len > 0) { if (dw.pending_cr) { dw.pending_cr = false; if (rest[0] == '\n') { try out.writeAll(protocol.crlf); dw.at_line_start = true; rest = rest[1..]; continue; } // A bare CR mid-line passes through untouched. try out.writeByte('\r'); dw.at_line_start = false; } if (dw.at_line_start and rest[0] == '.') { try out.writeAll(".."); dw.at_line_start = false; rest = rest[1..]; continue; } const special = std.mem.indexOfAny(u8, rest, "\r\n") orelse { try out.writeAll(rest); dw.at_line_start = false; break; }; if (special > 0) { try out.writeAll(rest[0..special]); dw.at_line_start = false; } switch (rest[special]) { '\r' => dw.pending_cr = true, '\n' => { try out.writeAll(protocol.crlf); dw.at_line_start = true; }, else => unreachable, } rest = rest[special + 1 ..]; } } }; /// The verdicts a server sends at the end of a message: one in SMTP, one /// per accepted recipient in LMTP. Each `next` overwrites the client's /// reply buffer, so a reply must be used before the following call. pub const Results = struct { client: *Client, remaining: usize, /// The index into the recipients accepted since the last MAIL that the /// next reply belongs to. Meaningful in LMTP, where replies come back /// in the order the RCPT commands were issued. index: usize = 0, pub fn next(r: *Results) Error!?Reply { if (r.remaining == 0) return null; r.remaining -= 1; r.index += 1; return try r.client.readReply(); } }; /// The verdicts still to be read after a message has been terminated. Use /// `DataWriter.endResults`, which sends the terminator first; this is the /// reading half on its own, for a caller that framed the message itself. pub fn results(c: *Client) Results { return .{ .client = c, .remaining = switch (c.mode) { .smtp => 1, .lmtp => c.accepted_recipients, }, }; } /// Like `mailFrom`, but requests the SMTPUTF8 extension /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)) so the /// envelope addresses and message headers may contain UTF-8. Use only when /// `Extensions.smtputf8` was advertised. pub fn mailFromUtf8(c: *Client, from: []const u8) (Error || ArgumentError)!void { return c.mail(from, .{ .smtputf8 = true }); } /// Sends one BDAT chunk (the CHUNKING extension, /// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)) and reads the /// server's reply. Use only when `Extensions.chunking` was advertised. The /// chunk is transmitted verbatim — no dot-stuffing and no line-ending /// normalization — so message content must already use CRLF line endings. /// Set `last` on the final chunk; `bdat("", true)` is a valid terminator. pub fn bdat(c: *Client, chunk: []const u8, last: bool) Error!void { if (last) { try c.writer.print("BDAT {d} LAST\r\n", .{chunk.len}); } else { try c.writer.print("BDAT {d}\r\n", .{chunk.len}); } try c.writer.writeAll(chunk); try c.writer.flush(); if (!last) { _ = try c.expectClass(2); return; } // RFC 2033 gives the LAST chunk the same per-recipient answer that the // final dot of DATA gets, so it is read the same way. var chunk_results = c.results(); const per_recipient = chunk_results.remaining > 1; while (try chunk_results.next()) |reply| { if (reply.isPositiveCompletion()) continue; while (chunk_results.remaining > 0) : (chunk_results.remaining -= 1) try c.discardReply(); return if (per_recipient) error.RecipientRejected else error.UnexpectedReply; } } /// Sends the message content for the current transaction as a single BDAT /// chunk. See `bdat` for the transmission caveats. pub fn sendMessageChunked(c: *Client, message_data: []const u8) Error!void { try c.bdat(message_data, true); } /// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient, /// then DATA. Call after `greet` and `hello`. pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) (Error || ArgumentError)!void { const accepted = try c.envelope(from, recipients, null, .{}); if (accepted != recipients.len) { // All or nothing, so nothing: the envelope is abandoned before DATA // rather than delivering to the subset that was accepted. A caller // who wants the subset calls `envelope` and decides for itself. c.rset() catch {}; return error.UnexpectedReply; } try c.sendMessage(message_data); } /// Aborts the current mail transaction. pub fn rset(c: *Client) Error!void { try c.send("RSET", .{}); _ = try c.expectClass(2); c.accepted_recipients = 0; c.binary = false; } pub fn noop(c: *Client) Error!void { try c.send("NOOP", .{}); _ = try c.expectClass(2); } /// Ends the session. The connection should be closed afterwards. pub fn quit(c: *Client) Error!void { try c.send("QUIT", .{}); _ = try c.expect(221); } fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void { try c.writer.print(fmt ++ protocol.crlf, args); try c.writer.flush(); } fn readReply(c: *Client) Error!Reply { const reply = try Reply.read(c.reader, c.reply_buffer); c.last_reply = reply; return reply; } /// Reads one reply and throws it away, without touching `reply_buffer`. /// /// That is the point of it: the replies to a pipelined group all arrive /// before any of them can be acted on, and each one read into /// `reply_buffer` overwrites the last. Draining the rest of a group this /// way leaves the failing reply that was already read intact in /// `last_reply`, so an error can still say what went wrong. fn discardReply(c: *Client) Error!void { while (true) { const line = protocol.readLine(c.reader) catch |err| switch (err) { error.LineTooLong => return error.ReplyTooLong, error.ReadFailed, error.EndOfStream => |e| return e, }; if (line.len < 4) return if (line.len == 3) {} else error.InvalidReply; // A '-' in the fourth column continues the reply; a space ends it. switch (line[3]) { '-' => continue, ' ' => return, else => return error.InvalidReply, } } } fn expect(c: *Client, code: u16) Error!Reply { const reply = try c.readReply(); if (reply.code != code) return error.UnexpectedReply; return reply; } fn expectClass(c: *Client, class: u16) Error!Reply { const reply = try c.readReply(); if (reply.code / 100 != class) return error.UnexpectedReply; return reply; } test sendMail { const responses = "220 mx.example.com ESMTP\r\n" ++ "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++ "250 2.1.0 Ok\r\n" ++ "250 2.1.5 Ok\r\n" ++ "354 End data with .\r\n" ++ "250 2.0.0 Ok\r\n" ++ "221 2.0.0 Bye\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [1024]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [512]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); _ = try client.greet(); const ext = try client.hello("client.example.org"); try std.testing.expect(ext.pipelining); try std.testing.expect(ext.eight_bit_mime); try std.testing.expect(!ext.starttls); try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size); try client.sendMail( "alice@example.com", &.{"bob@example.net"}, "Subject: hi\r\n\r\n.leading dot\r\n", ); try client.quit(); try std.testing.expectEqualStrings( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "DATA\r\n" ++ "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++ "QUIT\r\n", writer.buffered(), ); } test "HELO fallback for non-ESMTP servers" { const responses = "220 old.example.com\r\n" ++ "502 command not implemented\r\n" ++ "250 old.example.com\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); _ = try client.greet(); const ext = try client.hello("client.example.org"); try std.testing.expectEqual(Extensions{}, ext); try std.testing.expectEqualStrings( "EHLO client.example.org\r\nHELO client.example.org\r\n", writer.buffered(), ); } test "rejected recipient surfaces the reply" { const responses = "550 5.1.1 No such user\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com")); try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text); } test starttls { const plain_responses = "220 mx.example.com ESMTP\r\n" ++ "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++ "220 2.0.0 Ready to start TLS\r\n"; var reader: Io.Reader = .fixed(plain_responses); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); _ = try client.greet(); const ext = try client.hello("client.example.org"); try std.testing.expect(ext.starttls); try client.starttls(); // Simulate the post-handshake encrypted transport with fresh buffers; // the session must re-EHLO on it. const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n"; var tls_reader: Io.Reader = .fixed(tls_responses); var tls_out_buf: [256]u8 = undefined; var tls_writer: Io.Writer = .fixed(&tls_out_buf); client.setTransport(&tls_reader, &tls_writer, .encrypted); const tls_ext = try client.hello("client.example.org"); try std.testing.expect(!tls_ext.starttls); try std.testing.expect(tls_ext.eight_bit_mime); try std.testing.expectEqualStrings( "EHLO client.example.org\r\nSTARTTLS\r\n", writer.buffered(), ); try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered()); } test "BODY=BINARYMIME commits the transaction to BDAT" { const responses = "250-mx.example.com\r\n250-CHUNKING\r\n250 BINARYMIME\r\n" ++ "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.0.0 Ok\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); const ext = try client.hello("client.example.org"); try std.testing.expect(ext.binary_mime and ext.chunking); try client.mail("alice@example.com", .{ .body = .binary_mime }); try client.rcptTo("bob@example.net"); // The server would answer DATA with 503; the client will not get that // far, because the round trip has nothing to discover. try std.testing.expectError(error.BinaryRequiresChunking, client.data()); // BDAT is the way, and it sends the octets untouched: a bare CR, a NUL // and a lone dot all cross unchanged. try client.bdat("\x00\r.\r\n", true); try std.testing.expect(std.mem.endsWith( u8, writer.buffered(), "MAIL FROM: BODY=BINARYMIME\r\n" ++ "RCPT TO:\r\n" ++ "BDAT 5 LAST\r\n\x00\r.\r\n", )); } test "the binary commitment is lifted by RSET and by the next transaction" { const responses = "250 2.1.0 Ok\r\n250 2.0.0 Ok\r\n250 2.1.0 Ok\r\n354 go\r\n250 ok\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.mail("alice@example.com", .{ .body = .binary_mime }); try std.testing.expect(client.binary); try client.rset(); try std.testing.expect(!client.binary); // And a plain MAIL leaves DATA available again. try client.mail("alice@example.com", .{}); try client.sendMessage("hi\r\n"); } test "a pipelined envelope writes the whole group before reading a reply" { // The MAIL is refused. A client working one command at a time would // stop there; a pipelined one has already sent everything, and that is // what makes the difference observable from the wire alone. const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ "550 5.1.8 Bad sender\r\n250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); _ = try client.hello("client.example.org"); try std.testing.expect(client.pipelining); const recipients: []const []const u8 = &.{ "bob@example.net", "carol@example.net" }; try std.testing.expectError( error.UnexpectedReply, client.envelope("alice@example.com", recipients, null, .{}), ); try std.testing.expectEqualStrings( "EHLO client.example.org\r\n" ++ "MAIL FROM:\r\n" ++ "RCPT TO:\r\n" ++ "RCPT TO:\r\n", writer.buffered(), ); // The MAIL reply is the one kept, even though two more were read after // it, and the group was drained so the stream is where it should be. try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); try std.testing.expectEqualStrings("5.1.8 Bad sender", client.last_reply.?.text); try std.testing.expectEqual(@as(usize, 0), reader.bufferedLen()); } test "without PIPELINING the commands wait for each other" { // Same refusal, no PIPELINING advertised: the RCPTs are never sent. const responses = "250-mx.example.com\r\n250 8BITMIME\r\n550 5.1.8 Bad sender\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); _ = try client.hello("client.example.org"); try std.testing.expect(!client.pipelining); const recipients: []const []const u8 = &.{ "bob@example.net", "carol@example.net" }; try std.testing.expectError( error.UnexpectedReply, client.envelope("alice@example.com", recipients, null, .{}), ); try std.testing.expectEqualStrings( "EHLO client.example.org\r\nMAIL FROM:\r\n", writer.buffered(), ); } test "envelope reports which recipients were refused" { const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n550 5.1.1 No such user\r\n250 2.1.5 Ok\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); _ = try client.hello("client.example.org"); const recipients: []const []const u8 = &.{ "bob@example.net", "nobody@example.net", "carol@example.net", }; var codes: [3]u16 = undefined; const accepted = try client.envelope("alice@example.com", recipients, &codes, .{}); try std.testing.expectEqual(@as(usize, 2), accepted); try std.testing.expectEqualSlices(u16, &.{ 250, 550, 250 }, &codes); // A refused recipient is not an error here, so the transaction is still // open and the client knows how many it may deliver to. try std.testing.expectEqual(@as(usize, 2), client.accepted_recipients); } test "the same envelope works the same way without pipelining" { const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n550 5.1.1 No such user\r\n250 2.1.5 Ok\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); const recipients: []const []const u8 = &.{ "bob@example.net", "nobody@example.net", "carol@example.net", }; var codes: [3]u16 = undefined; const accepted = try client.envelope("alice@example.com", recipients, &codes, .{}); try std.testing.expectEqual(@as(usize, 2), accepted); try std.testing.expectEqualSlices(u16, &.{ 250, 550, 250 }, &codes); } test "sendMail abandons the transaction rather than deliver to some" { const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n550 5.1.1 No such user\r\n" ++ "250 2.0.0 Ok\r\n"; // the RSET var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); _ = try client.hello("client.example.org"); const recipients: []const []const u8 = &.{ "bob@example.net", "nobody@example.net" }; try std.testing.expectError( error.UnexpectedReply, client.sendMail("alice@example.com", recipients, "hi\r\n"), ); // DATA was never sent, so nothing reached the recipient that was // accepted, and the session was left clean for the next transaction. try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "DATA") == null); try std.testing.expect(std.mem.endsWith(u8, writer.buffered(), "RSET\r\n")); } test "LMTP greets with LHLO and reads one verdict per recipient" { const responses = "250-mx.example.com\r\n250 PIPELINING\r\n" ++ // LHLO "250 2.1.0 Ok\r\n" ++ // MAIL "250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n" ++ // two RCPTs "354 End data\r\n" ++ "250 2.0.0 Ok\r\n550 5.2.1 Mailbox disabled\r\n"; // one per recipient var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); client.mode = .lmtp; _ = try client.hello("client.example.org"); try client.mailFrom("alice@example.com"); try client.rcptTo("good@example.net"); try client.rcptTo("bad@example.net"); var data_writer = try client.data(); try data_writer.interface.writeAll("hi\r\n"); var verdicts = try data_writer.endResults(); const first = (try verdicts.next()).?; try std.testing.expectEqual(@as(u16, 250), first.code); try std.testing.expectEqual(@as(usize, 1), verdicts.index); const second = (try verdicts.next()).?; try std.testing.expectEqual(@as(u16, 550), second.code); try std.testing.expectEqualStrings("5.2.1 Mailbox disabled", second.text); try std.testing.expectEqual(@as(?Reply, null), try verdicts.next()); try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "LHLO client.example.org\r\n")); } test "end reports an LMTP rejection distinctly from an SMTP one" { const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n250 2.1.5 Ok\r\n354 End data\r\n" ++ "250 2.0.0 Ok\r\n550 5.2.1 Mailbox disabled\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); client.mode = .lmtp; try client.mailFrom("alice@example.com"); try client.rcptTo("good@example.net"); try client.rcptTo("bad@example.net"); // Both verdicts are read even though the first already decided the // outcome, or the next command would be answered by a stale reply. try std.testing.expectError(error.RecipientRejected, client.sendMessage("hi\r\n")); // The single-reply case keeps `error.UnexpectedReply`, where // `last_reply` can actually say what happened. var smtp_reader: Io.Reader = .fixed("354 End data\r\n550 5.7.1 Rejected\r\n"); var smtp_out: [256]u8 = undefined; var smtp_writer: Io.Writer = .fixed(&smtp_out); var smtp_reply_buf: [256]u8 = undefined; var smtp: Client = .init(&smtp_reader, &smtp_writer, &smtp_reply_buf); try std.testing.expectError(error.UnexpectedReply, smtp.sendMessage("hi\r\n")); try std.testing.expectEqualStrings("5.7.1 Rejected", smtp.last_reply.?.text); } test "the recipient count resets with each new transaction" { const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n" ++ // MAIL, RCPT "250 2.0.0 Ok\r\n" ++ // RSET "250 2.1.0 Ok\r\n"; // MAIL again var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); client.mode = .lmtp; try client.mailFrom("alice@example.com"); try client.rcptTo("bob@example.net"); try std.testing.expectEqual(@as(usize, 1), client.results().remaining); try client.rset(); try std.testing.expectEqual(@as(usize, 0), client.results().remaining); try client.mailFrom("alice@example.com"); try std.testing.expectEqual(@as(usize, 0), client.results().remaining); } test "mail and rcpt carry the DSN parameters" { const responses = "250 2.1.0 Ok\r\n250 2.1.5 Ok\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.mail("me@example.com", .{ .ret = .hdrs, .envid = "batch 7" }); try client.rcpt("bob@example.net", .{ .notify = .{ .on = .{ .failure = true, .delay = true } }, .orcpt = .{ .addr_type = "rfc822", .address = "team@example.net" }, }); try std.testing.expectEqualStrings( "MAIL FROM: RET=HDRS ENVID=batch+207\r\n" ++ "RCPT TO: NOTIFY=FAILURE,DELAY ORCPT=rfc822;team@example.net\r\n", writer.buffered(), ); } test "REQUIRETLS is refused on a session with nothing to guarantee" { var reader: Io.Reader = .fixed(""); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); // Asking for a guarantee over a channel that has none is asking for // nothing, so it is refused here rather than sent and relied upon. try std.testing.expectError( error.InsecureTransport, client.mail("a@example.com", .{ .require_tls = true }), ); try std.testing.expectEqualStrings("", writer.buffered()); } test "REQUIRETLS goes out once the session is encrypted" { var reader: Io.Reader = .fixed("250-mx.example.com\r\n250 REQUIRETLS\r\n250 2.1.0 Ok\r\n"); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); client.security = .encrypted; const extensions = try client.hello("client.example.org"); try std.testing.expect(extensions.requiretls); try client.mail("a@example.com", .{ .require_tls = true }); try std.testing.expect(std.mem.endsWith( u8, writer.buffered(), "MAIL FROM: REQUIRETLS\r\n", )); } test "mail carries AUTH= for a relay speaking for somebody else" { var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n250 2.1.0 Ok\r\n"); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.mail("relay@example.com", .{ .auth = .{ .mailbox = "e=mc2@example.com" }, }); // The '=' is escaped, because an unescaped one would end the parameter. try std.testing.expectEqualStrings( "MAIL FROM: AUTH=e+3Dmc2@example.com\r\n", writer.buffered(), ); // `<>` is what a relay sends when it cannot vouch for anybody, and RFC // 4954 asks for that rather than leaving the parameter off. var second_buf: [256]u8 = undefined; var second: Io.Writer = .fixed(&second_buf); client.setTransport(&reader, &second, .plaintext); try client.mail("relay@example.com", .{ .auth = .unknown }); try std.testing.expectEqualStrings( "MAIL FROM: AUTH=<>\r\n", second.buffered(), ); } test "an AUTH= mailbox that will not fit is refused before it is sent" { var reader: Io.Reader = .fixed(""); var out_buf: [1024]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try std.testing.expectError(error.ArgumentTooLong, client.mail("a@b", .{ .auth = .{ .mailbox = "x" ** (protocol.Submitter.max_len + 1) }, })); // An empty mailbox is not `<>`; the caller meant one or the other. try std.testing.expectError(error.UnsafeArgument, client.mail("a@b", .{ .auth = .{ .mailbox = "" }, })); try std.testing.expectEqualStrings("", writer.buffered()); } test "NOTIFY=NEVER is written on its own" { var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); var out_buf: [128]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.rcpt("bob@example.net", .{ .notify = .never }); try std.testing.expectEqualStrings( "RCPT TO: NOTIFY=NEVER\r\n", writer.buffered(), ); } test "DSN parameter values that exceed their limits are refused" { var reader: Io.Reader = .fixed(""); var out_buf: [1024]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); // 34 spaces encode to 102 characters, over the ENVID limit of 100, // though the value itself is well under it. const spaces = " " ** 34; try std.testing.expectError( error.ArgumentTooLong, client.mail("me@example.com", .{ .envid = spaces }), ); try std.testing.expectError(error.ArgumentTooLong, client.rcpt("bob@example.net", .{ .orcpt = .{ .addr_type = "rfc822", .address = "x" ** 500 }, })); // An addr-type is written literally, so it is checked rather than encoded. try std.testing.expectError(error.UnsafeArgument, client.rcpt("bob@example.net", .{ .orcpt = .{ .addr_type = "rfc822;evil", .address = "x@example.net" }, })); try std.testing.expectEqualStrings("", writer.buffered()); } test "hello reports DSN support" { const responses = "250-mx.example.com\r\n250-DSN\r\n250 8BITMIME\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [128]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; client.sasl_buffer = &sasl_buf; const ext = try client.hello("client.example.org"); try std.testing.expect(ext.dsn); } test authenticate { const responses = "250-mx.example.com\r\n250 AUTH PLAIN LOGIN\r\n" ++ "235 2.7.0 Accepted\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; client.sasl_buffer = &sasl_buf; client.security = .encrypted; const extensions = try client.hello("client.example.org"); var plain: sasl.Plain = .init("alice", "secret"); const mechanism = sasl.Client.selectFromList( &.{plain.client()}, extensions.auth, true, ).?; try client.authenticate(mechanism); // base64("\x00alice\x00secret"), sent as the initial response in one // round trip rather than waiting to be asked. try std.testing.expect(std.mem.endsWith( u8, writer.buffered(), "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n", )); } test "a challenge-response mechanism runs through the 334s" { const responses = "250-mx.example.com\r\n250 AUTH CRAM-MD5\r\n" ++ // base64 of RFC 2195's challenge "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ "235 2.7.0 Accepted\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; client.sasl_buffer = &sasl_buf; const extensions = try client.hello("client.example.org"); var cram: sasl.CramMd5 = .init("tim", "tanstaaftanstaaf"); // CRAM-MD5 is not cleartext, so it is usable on this plaintext session. const mechanism = sasl.Client.selectFromList(&.{cram.client()}, extensions.auth, false).?; try client.authenticate(mechanism); // No initial response, then the digest RFC 2195 publishes, base64'd. try std.testing.expect(std.mem.indexOf(u8, writer.buffered(), "AUTH CRAM-MD5\r\n") != null); try std.testing.expect(std.mem.endsWith( u8, writer.buffered(), "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", )); } test "a mechanism that sends a credential in the clear is refused first" { var reader: Io.Reader = .fixed(""); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; client.sasl_buffer = &sasl_buf; var plain: sasl.Plain = .init("alice", "secret"); try std.testing.expectError( error.InsecureTransport, client.authenticate(plain.client()), ); // Nothing reached the wire, which is the point: the refusal happens // before the credential is written, not after the server rejects it. try std.testing.expectEqualStrings("", writer.buffered()); client.allow_cleartext_auth = true; var accepting: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); client.setTransport(&accepting, &writer, .plaintext); try client.authenticate(plain.client()); } test "a server accepting without finishing the exchange is not authenticated" { // A mechanism that has not proved what it set out to prove, which is // SCRAM's shape: `satisfied` stays false until the server's own proof // has been verified. const Unfinished = struct { fn name(_: *anyopaque) []const u8 { return "MUTUAL-TEST"; } fn initial(_: *anyopaque, out: *Io.Writer) sasl.Client.Error!sasl.Client.Initial { try out.writeAll("hello"); return .written; } fn respond(_: *anyopaque, _: []const u8, _: *Io.Writer) sasl.Client.Error!void {} fn satisfied(_: *anyopaque) bool { return false; } fn cleartext(_: *anyopaque) bool { return false; } const vtable: sasl.Client.VTable = .{ .name = name, .initial = initial, .respond = respond, .satisfied = satisfied, .cleartext = cleartext, }; }; var nothing: u8 = 0; const mechanism: sasl.Client = .{ .context = ¬hing, .vtable = &Unfinished.vtable }; var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; client.sasl_buffer = &sasl_buf; // The server said yes. The mechanism disagrees, and it is the one that // knows — this is the case nothing in this library could express before // the mechanisms moved out of it. try std.testing.expectError( error.ServerNotAuthenticated, client.authenticate(mechanism), ); } test "a mechanism that fails mid-exchange cancels rather than stranding the session" { // PLAIN is never challenged, so a 334 makes it return BadChallenge. const responses = "334 c29tZXRoaW5n\r\n501 5.5.2 Cancelled\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; client.sasl_buffer = &sasl_buf; client.security = .encrypted; var plain: sasl.Plain = .init("alice", "secret"); try std.testing.expectError(error.BadChallenge, client.authenticate(plain.client())); // RFC 4954 §4's cancellation went out, so the server is not left waiting // for a line that was never coming. try std.testing.expect(std.mem.endsWith(u8, writer.buffered(), "*\r\n")); try std.testing.expectEqual(@as(usize, 0), reader.bufferedLen()); } test "authenticate needs a buffer, and says so rather than overrunning one" { var reader: Io.Reader = .fixed(""); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); client.security = .encrypted; var plain: sasl.Plain = .init("alice", "secret"); // No buffer at all: this is the default, and it is an error rather than // a hidden allocation or a stack array the caller cannot see. try std.testing.expectError( error.SaslBufferTooSmall, client.authenticate(plain.client()), ); var tiny: [sasl_buffer_min - 1]u8 = undefined; client.sasl_buffer = &tiny; try std.testing.expectError( error.SaslBufferTooSmall, client.authenticate(plain.client()), ); try std.testing.expectEqualStrings("", writer.buffered()); } test "the two halves of the buffer take turns rather than coexist" { // A challenge decodes into the coded half, the mechanism's answer is // written into the plain half, and the answer encodes back over the // challenge. The minimum buffer is enough to run a real exchange, which // is what this checks: at 896 bytes there are 512 coded and 384 plain. const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ "235 2.7.0 Accepted\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [512]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); var scratch: [sasl_buffer_min]u8 = undefined; client.sasl_buffer = &scratch; var cram: sasl.CramMd5 = .init("tim", "tanstaaftanstaaf"); try client.authenticate(cram.client()); try std.testing.expect(std.mem.endsWith( u8, writer.buffered(), "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", )); } test "a rejection surfaces as AuthenticationFailed with the reply" { var reader: Io.Reader = .fixed("535 5.7.8 Authentication credentials invalid\r\n"); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; client.sasl_buffer = &sasl_buf; client.security = .encrypted; var plain: sasl.Plain = .init("alice", "secret"); try std.testing.expectError( error.AuthenticationFailed, client.authenticate(plain.client()), ); try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); } test "a rejection's enhanced status code says more than its reply code" { // Two different refusals behind the same 550: one about the address, // one about policy. The three-digit code cannot tell them apart and the // enhanced one can, which is the whole reason to read it. var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n550 5.1.1 No such user\r\n"); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.mailFrom("alice@example.com"); try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.net")); const reply = client.last_reply.?; const status = reply.enhanced().?; try std.testing.expect(status.agrees(reply.code)); try std.testing.expectEqual(protocol.Enhanced.Subject.addressing, status.subjectClass()); try std.testing.expectEqual(@as(u16, 1), status.detail); // And the part meant for a person, without the code in front of it. try std.testing.expectEqualStrings("No such user", reply.message()); } test "a server that contradicts itself is detectable" { // 250 carrying a 5.x.x code. Nothing in RFC 3463 says what to do about // it, but a caller can at least see it rather than trusting either half. var reader: Io.Reader = .fixed("250 5.1.1 Ok?\r\n"); var out_buf: [128]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [128]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.mailFrom("alice@example.com"); // the 2xx is what `mail` checks const reply = client.last_reply.?; try std.testing.expect(!reply.enhanced().?.agrees(reply.code)); } test "a multiline reply repeats the code on every line" { const responses = "250-mx.example.com\r\n250 SIZE 1000000\r\n" ++ "452-4.5.3 Too many recipients\r\n452 4.5.3 Try fewer\r\n"; var reader: Io.Reader = .fixed(responses); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); _ = try client.hello("client.example.org"); try std.testing.expectError(error.UnexpectedReply, client.rcptTo("b@example.net")); const reply = client.last_reply.?; // `message` strips the first line's code; the rest are reached through // `lines`, which is what the doc comment says to do. try std.testing.expectEqualStrings("Too many recipients\nTry fewer", blk: { var joined: [64]u8 = undefined; var out: Io.Writer = .fixed(&joined); var it = reply.lines(); var first = true; while (it.next()) |line| { if (!first) try out.writeByte('\n'); first = false; try out.writeAll(protocol.Enhanced.strip(line)); } break :blk out.buffered(); }); } test "an address carrying CRLF cannot inject a command" { // Without the check this would put a second RCPT on the wire. const smuggled = "bob@example.net>\r\nRCPT TO:\r\n", writer.buffered()); } test rcptTo { var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n"); var out_buf: [64]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.rcptTo("bob@example.net"); try std.testing.expectEqualStrings("RCPT TO:\r\n", writer.buffered()); } test sendMessage { var reader: Io.Reader = .fixed("354 End data with .\r\n250 2.0.0 Ok\r\n"); var out_buf: [128]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.sendMessage("Subject: hi\n\nhello\n"); try std.testing.expectEqualStrings( "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n", writer.buffered(), ); } test rset { var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); var out_buf: [16]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.rset(); try std.testing.expectEqualStrings("RSET\r\n", writer.buffered()); } test noop { var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); var out_buf: [16]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.noop(); try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered()); } test quit { var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n"); var out_buf: [16]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.quit(); try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered()); } test data { var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); var out_buf: [256]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); // Chunks may split lines, CRLF pairs, and leading dots arbitrarily. var data_writer = try client.data(); try data_writer.interface.writeAll("Subject: chunked\n\nfirst"); try data_writer.interface.writeAll(" second\r"); try data_writer.interface.writeAll("\n.needs stuffing\r\nsplit\r"); try data_writer.interface.writeAll("\n"); try data_writer.interface.writeAll(".x\nend"); try data_writer.end(); try std.testing.expectEqualStrings( "DATA\r\n" ++ "Subject: chunked\r\n" ++ "\r\n" ++ "first second\r\n" ++ "..needs stuffing\r\n" ++ "split\r\n" ++ "..x\r\n" ++ "end\r\n" ++ ".\r\n", writer.buffered(), ); } test sendMessageReader { var reader: Io.Reader = .fixed("354 go ahead\r\n250 2.0.0 Ok\r\n"); var out_buf: [128]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; client.sasl_buffer = &sasl_buf; var message: Io.Reader = .fixed("Subject: hi\n\n.streamed body\n"); try client.sendMessageReader(&message); try std.testing.expectEqualStrings( "DATA\r\nSubject: hi\r\n\r\n..streamed body\r\n.\r\n", writer.buffered(), ); } test "fuzz client against arbitrary server replies" { try std.testing.fuzz({}, fuzzClientReplies, .{}); } fn fuzzClientReplies(context: void, smith: *std.testing.Smith) !void { _ = context; var input_buf: [1024]u8 = undefined; const input = input_buf[0..smith.value(u10)]; smith.bytes(input); var reader: Io.Reader = .fixed(input); var out_buf: [4096]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [256]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); var sasl_buf: [Client.sasl_buffer_suggested]u8 = undefined; client.sasl_buffer = &sasl_buf; // Whatever the "server" says, the client must fail cleanly, never crash. _ = client.greet() catch return; const extensions = client.hello("fuzz.example.org") catch return; var plain: sasl.Plain = .init("user", "password"); client.allow_cleartext_auth = true; if (sasl.Client.selectFromList(&.{plain.client()}, extensions.auth, true)) |mechanism| client.authenticate(mechanism) catch {}; client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {}; client.quit() catch {}; } test "fuzz DataWriter equivalence with writeStuffed" { try std.testing.fuzz({}, fuzzDataWriter, .{}); } fn fuzzDataWriter(context: void, smith: *std.testing.Smith) !void { _ = context; var message_buf: [1024]u8 = undefined; const message = message_buf[0..smith.value(u10)]; smith.bytes(message); // Reference implementation: slice-based stuffing. var expected_buf: [2100]u8 = undefined; var expected: Io.Writer = .fixed(&expected_buf); try protocol.writeStuffed(&expected, message); // Streaming implementation, with fuzzer-chosen chunk boundaries. var responses: Io.Reader = .fixed("354 go\r\n250 ok\r\n"); var out_buf: [2200]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&responses, &writer, &reply_buf); var data_writer = try client.data(); var rest: []const u8 = message; while (rest.len > 0) { const n: usize = smith.valueRangeAtMost(u16, 1, @intCast(rest.len)); try data_writer.interface.writeAll(rest[0..n]); rest = rest[n..]; } try data_writer.end(); const written = writer.buffered(); try std.testing.expect(std.mem.startsWith(u8, written, "DATA\r\n")); try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n")); const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len]; try std.testing.expectEqualStrings(expected.buffered(), stuffed); } test Extensions { const extensions: Extensions = .{ .pipelining = true, .max_size = 1024 }; try std.testing.expect(extensions.pipelining); try std.testing.expect(!extensions.starttls); try std.testing.expectEqualStrings("", extensions.auth); try std.testing.expectEqual(@as(?u64, 1024), extensions.max_size); } test bdat { var reader: Io.Reader = .fixed("250 2.0.0 Chunk received\r\n250 2.0.0 Ok\r\n"); var out_buf: [128]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.bdat("Subject: hi\r\n\r\n", false); try client.bdat("body\r\n", true); try std.testing.expectEqualStrings( "BDAT 15\r\nSubject: hi\r\n\r\nBDAT 6 LAST\r\nbody\r\n", writer.buffered(), ); } test sendMessageChunked { var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); var out_buf: [128]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); // Raw transmission: the leading dot is not stuffed. try client.sendMessageChunked(".raw\r\n"); try std.testing.expectEqualStrings("BDAT 6 LAST\r\n.raw\r\n", writer.buffered()); } test mailFromUtf8 { var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); var out_buf: [64]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [64]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); try client.mailFromUtf8("böb@example.com"); try std.testing.expectEqualStrings("MAIL FROM: SMTPUTF8\r\n", writer.buffered()); }