// 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 Reply = protocol.Reply; reader: *Io.Reader, writer: *Io.Writer, /// Backing storage for reply text; `last_reply.text` points into it. reply_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, pub const Error = error{ WriteFailed, ReadFailed, EndOfStream, LineTooLong, InvalidReply, ReplyTooLong, /// The server answered with an unexpected code; see `last_reply`. UnexpectedReply, }; /// 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, enhanced_status_codes: bool = false, /// AUTH mechanisms advertised by the server. auth: Auth = .{}, /// Value of the SIZE extension, if advertised with a value. max_size: ?u64 = null, pub const Auth = struct { plain: bool = false, login: bool = false, cram_md5: bool = false, pub fn any(a: Auth) bool { return a.plain or a.login or a.cram_md5; } fn parse(arg: []const u8) Auth { var auth: Auth = .{}; var it = std.mem.tokenizeScalar(u8, arg, ' '); while (it.next()) |mechanism| { if (ieql(mechanism, "PLAIN")) { auth.plain = true; } else if (ieql(mechanism, "LOGIN")) { auth.login = true; } else if (ieql(mechanism, "CRAM-MD5")) { auth.cram_md5 = true; } } return auth; } }; 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, "ENHANCEDSTATUSCODES")) { ext.enhanced_status_codes = true; } else if (ieql(kw, "AUTH")) { ext.auth = Auth.parse(arg); } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) { // Some legacy servers advertise "AUTH=PLAIN LOGIN". var legacy_arg_buf: [128]u8 = undefined; const joined = std.fmt.bufPrint(&legacy_arg_buf, "{s} {s}", .{ kw[5..], arg }) catch kw[5..]; ext.auth = Auth.parse(joined); } 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 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!Extensions { try c.send("EHLO {s}", .{client_name}); const reply = try c.readReply(); if (reply.isPositiveCompletion()) return Extensions.parse(reply); if (reply.code == 500 or reply.code == 502) { try c.send("HELO {s}", .{client_name}); _ = try c.expectClass(2); return .{}; } return error.UnexpectedReply; } /// Sends STARTTLS (RFC 3207) 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`. pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer) void { c.reader = reader; c.writer = writer; } pub const AuthError = Error || error{ CredentialsTooLong, /// The server rejected the credentials; see `last_reply`. AuthenticationFailed, /// The server's CRAM-MD5 challenge was not valid base64. InvalidChallenge, /// The server advertised none of the supported mechanisms. NoSupportedMechanism, }; /// Authenticates with the best mechanism the server advertised (PLAIN, /// then LOGIN, then CRAM-MD5). Note that PLAIN and LOGIN send credentials /// unprotected: use TLS on real networks. pub fn authenticate(c: *Client, extensions: Extensions, username: []const u8, password: []const u8) AuthError!void { if (extensions.auth.plain) return c.authPlain("", username, password); if (extensions.auth.login) return c.authLogin(username, password); if (extensions.auth.cram_md5) return c.authCramMd5(username, password); return error.NoSupportedMechanism; } /// Authenticates with AUTH PLAIN (RFC 4616). Pass an empty `authzid` unless /// you need to act on behalf of another identity. Note that sending /// credentials over an unencrypted connection exposes them to the network. pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) AuthError!void { var plain_buf: [512]u8 = undefined; var plain: Io.Writer = .fixed(&plain_buf); plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch return error.CredentialsTooLong; var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined; const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered()); try c.send("AUTH PLAIN {s}", .{b64}); try c.expectAuthSuccess(); } /// Authenticates with AUTH LOGIN, the legacy two-step username/password /// exchange still required by some servers. pub fn authLogin(c: *Client, username: []const u8, password: []const u8) AuthError!void { try c.send("AUTH LOGIN", .{}); _ = try c.expect(334); // Username: prompt try c.sendBase64(username); _ = try c.expect(334); // Password: prompt try c.sendBase64(password); try c.expectAuthSuccess(); } /// Authenticates with AUTH CRAM-MD5 (RFC 2195): the password never crosses /// the wire, only an HMAC-MD5 of the server's challenge. pub fn authCramMd5(c: *Client, username: []const u8, password: []const u8) AuthError!void { try c.send("AUTH CRAM-MD5", .{}); const reply = try c.expect(334); var challenge_buf: [512]u8 = undefined; const challenge_len = std.base64.standard.Decoder.calcSizeForSlice(reply.text) catch return error.InvalidChallenge; if (challenge_len > challenge_buf.len) return error.InvalidChallenge; std.base64.standard.Decoder.decode(challenge_buf[0..challenge_len], reply.text) catch return error.InvalidChallenge; var mac: [std.crypto.auth.hmac.HmacMd5.mac_length]u8 = undefined; std.crypto.auth.hmac.HmacMd5.create(&mac, challenge_buf[0..challenge_len], password); const digest = std.fmt.bytesToHex(mac, .lower); var response_buf: [384]u8 = undefined; var response: Io.Writer = .fixed(&response_buf); response.print("{s} {s}", .{ username, digest }) catch return error.CredentialsTooLong; try c.sendBase64(response.buffered()); try c.expectAuthSuccess(); } /// Sends `bytes` base64-encoded as a bare continuation line. fn sendBase64(c: *Client, bytes: []const u8) AuthError!void { var b64_buf: [std.base64.standard.Encoder.calcSize(384)]u8 = undefined; if (std.base64.standard.Encoder.calcSize(bytes.len) > b64_buf.len) return error.CredentialsTooLong; const b64 = std.base64.standard.Encoder.encode(&b64_buf, bytes); try c.send("{s}", .{b64}); } fn expectAuthSuccess(c: *Client) AuthError!void { const reply = try c.readReply(); if (reply.code != 235) return error.AuthenticationFailed; } /// Starts a mail transaction. An empty `from` sends the null reverse-path /// (`MAIL FROM:<>`), used for bounces. pub fn mailFrom(c: *Client, from: []const u8) Error!void { try c.send("MAIL FROM:<{s}>", .{from}); _ = try c.expectClass(2); } pub fn rcptTo(c: *Client, to: []const u8) Error!void { try c.send("RCPT TO:<{s}>", .{to}); _ = try c.expectClass(2); } /// 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 { 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. pub fn end(dw: *DataWriter) Error!void { 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(); _ = try c.expectClass(2); } 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; } 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 ..]; } } }; /// 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!void { try c.mailFrom(from); for (recipients) |recipient| try c.rcptTo(recipient); 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); } 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; } 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); 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 authPlain { const responses = "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); try client.authPlain("", "user", "pass"); // base64("\x00user\x00pass") try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); } test authLogin { const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 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); try client.authLogin("user", "pass"); try std.testing.expectEqualStrings( "AUTH LOGIN\r\ndXNlcg==\r\ncGFzcw==\r\n", writer.buffered(), ); } test authCramMd5 { // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim", // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890. const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\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); try client.authCramMd5("tim", "tanstaaftanstaaf"); try std.testing.expectEqualStrings( "AUTH CRAM-MD5\r\ndGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n", writer.buffered(), ); } test authenticate { var out_buf: [256]u8 = undefined; var reply_buf: [256]u8 = undefined; { // Only CRAM-MD5 advertised. const responses = "334 YWJj\r\n235 ok\r\n"; var reader: Io.Reader = .fixed(responses); var writer: Io.Writer = .fixed(&out_buf); var client: Client = .init(&reader, &writer, &reply_buf); try client.authenticate(.{ .auth = .{ .cram_md5 = true } }, "u", "p"); try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n")); } { // Nothing advertised. var reader: Io.Reader = .fixed(""); var writer: Io.Writer = .fixed(&out_buf); var client: Client = .init(&reader, &writer, &reply_buf); try std.testing.expectError( error.NoSupportedMechanism, client.authenticate(.{}, "u", "p"), ); } } test "rejected credentials surface AuthenticationFailed" { const responses = "535 5.7.8 Authentication credentials invalid\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.AuthenticationFailed, client.authPlain("", "u", "p")); try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); } test hello { const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\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); const ext = try client.hello("c.example"); try std.testing.expect(ext.auth.plain); try std.testing.expect(ext.auth.login); try std.testing.expect(ext.auth.cram_md5); try std.testing.expect(ext.auth.any()); } test init { var reader: Io.Reader = .fixed(""); var out_buf: [16]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [128]u8 = undefined; const client: Client = .init(&reader, &writer, &reply_buf); try std.testing.expect(client.last_reply == null); } test greet { var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n"); var out_buf: [16]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); var reply_buf: [128]u8 = undefined; var client: Client = .init(&reader, &writer, &reply_buf); const reply = try client.greet(); try std.testing.expectEqual(@as(u16, 220), reply.code); try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text); } test setTransport { var reader: Io.Reader = .fixed(""); 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); // After a TLS handshake, point the session at the encrypted streams. var tls_reader: Io.Reader = .fixed(""); var tls_out_buf: [16]u8 = undefined; var tls_writer: Io.Writer = .fixed(&tls_out_buf); client.setTransport(&tls_reader, &tls_writer); try std.testing.expectEqual(&tls_reader, client.reader); try std.testing.expectEqual(&tls_writer, client.writer); } test mailFrom { 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.mailFrom("alice@example.com"); try std.testing.expectEqualStrings("MAIL FROM:\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 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(), ); }