// 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: bool = false, /// 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, "ENHANCEDSTATUSCODES")) { ext.enhanced_status_codes = true; } else if (ieql(kw, "AUTH")) { ext.auth = true; } 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; } /// 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) (Error || error{CredentialsTooLong})!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.expect(235); } /// 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, data: []const u8) Error!void { try c.send("DATA", .{}); _ = try c.expect(354); try protocol.writeStuffed(c.writer, data); try c.writer.writeAll("." ++ protocol.crlf); try c.writer.flush(); _ = try c.expectClass(2); } /// 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, data: []const u8) Error!void { try c.mailFrom(from); for (recipients) |recipient| try c.rcptTo(recipient); try c.sendMessage(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 "full transaction against a scripted server" { 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 handshake handoff" { 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 encodes credentials" { 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()); }