An SMTP client and server library for Zig implementing RFC 5321.
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! An SMTP client session over any `Io.Reader`/`Io.Writer` pair, which keeps
5//! it transport-agnostic: wrap a TCP stream for real use, or fixed buffers
6//! for testing. TLS can be layered in the same way once the transport
7//! supports it.
8//!
9//! Typical use:
10//! ```
11//! var client: Client = .init(&stream_reader, &stream_writer, &reply_buf);
12//! _ = try client.greet();
13//! _ = try client.hello("my-host.example.com");
14//! try client.sendMail("me@example.com", &.{"you@example.net"}, message);
15//! try client.quit();
16//! ```
17
18const Client = @This();
19
20const std = @import("std");
21const Io = std.Io;
22const protocol = @import("protocol.zig");
23const Reply = protocol.Reply;
24
25reader: *Io.Reader,
26writer: *Io.Writer,
27/// Backing storage for reply text; `last_reply.text` points into it.
28reply_buffer: []u8,
29/// The most recent reply read from the server. Useful for reporting the
30/// server's actual response after an `error.UnexpectedReply`.
31last_reply: ?Reply = null,
32
33pub const Error = error{
34 WriteFailed,
35 ReadFailed,
36 EndOfStream,
37 LineTooLong,
38 InvalidReply,
39 ReplyTooLong,
40 /// The server answered with an unexpected code; see `last_reply`.
41 UnexpectedReply,
42};
43
44/// Extensions advertised in the server's EHLO response.
45pub const Extensions = struct {
46 pipelining: bool = false,
47 eight_bit_mime: bool = false,
48 starttls: bool = false,
49 smtputf8: bool = false,
50 enhanced_status_codes: bool = false,
51 auth: bool = false,
52 /// Value of the SIZE extension, if advertised with a value.
53 max_size: ?u64 = null,
54
55 fn parse(reply: Reply) Extensions {
56 var ext: Extensions = .{};
57 var it = reply.lines();
58 _ = it.next(); // The first line is the server's greeting, not a keyword.
59 while (it.next()) |line| {
60 const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len;
61 const kw = line[0..kw_end];
62 const arg = if (kw_end < line.len) line[kw_end + 1 ..] else "";
63 if (ieql(kw, "PIPELINING")) {
64 ext.pipelining = true;
65 } else if (ieql(kw, "8BITMIME")) {
66 ext.eight_bit_mime = true;
67 } else if (ieql(kw, "STARTTLS")) {
68 ext.starttls = true;
69 } else if (ieql(kw, "SMTPUTF8")) {
70 ext.smtputf8 = true;
71 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) {
72 ext.enhanced_status_codes = true;
73 } else if (ieql(kw, "AUTH")) {
74 ext.auth = true;
75 } else if (ieql(kw, "SIZE")) {
76 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null;
77 }
78 }
79 return ext;
80 }
81
82 fn ieql(a: []const u8, b: []const u8) bool {
83 return std.ascii.eqlIgnoreCase(a, b);
84 }
85};
86
87/// `reply_buffer` must be large enough for the largest expected reply text
88/// (the EHLO response is usually the largest); 512 bytes is plenty in
89/// practice.
90pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client {
91 return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer };
92}
93
94/// Reads the server's 220 greeting. Call once, right after connecting.
95pub fn greet(c: *Client) Error!Reply {
96 return c.expect(220);
97}
98
99/// Sends EHLO and returns the extensions the server advertised, falling back
100/// to plain HELO for servers that do not speak ESMTP.
101pub fn hello(c: *Client, client_name: []const u8) Error!Extensions {
102 try c.send("EHLO {s}", .{client_name});
103 const reply = try c.readReply();
104 if (reply.isPositiveCompletion()) return Extensions.parse(reply);
105 if (reply.code == 500 or reply.code == 502) {
106 try c.send("HELO {s}", .{client_name});
107 _ = try c.expectClass(2);
108 return .{};
109 }
110 return error.UnexpectedReply;
111}
112
113/// Sends STARTTLS (RFC 3207) and reads the server's 220 go-ahead. On
114/// success, perform a TLS handshake over the underlying stream (see `Tls`),
115/// switch to the encrypted transport with `setTransport`, and then call
116/// `hello` again — the server discards everything it learned before the
117/// handshake, including the EHLO state.
118pub fn starttls(c: *Client) Error!void {
119 try c.send("STARTTLS", .{});
120 _ = try c.expect(220);
121}
122
123/// Replaces the session's transport, typically with a TLS reader/writer
124/// after `starttls`.
125pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer) void {
126 c.reader = reader;
127 c.writer = writer;
128}
129
130/// Authenticates with AUTH PLAIN (RFC 4616). Pass an empty `authzid` unless
131/// you need to act on behalf of another identity. Note that sending
132/// credentials over an unencrypted connection exposes them to the network.
133pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) (Error || error{CredentialsTooLong})!void {
134 var plain_buf: [512]u8 = undefined;
135 var plain: Io.Writer = .fixed(&plain_buf);
136 plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch
137 return error.CredentialsTooLong;
138 var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined;
139 const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered());
140 try c.send("AUTH PLAIN {s}", .{b64});
141 _ = try c.expect(235);
142}
143
144/// Starts a mail transaction. An empty `from` sends the null reverse-path
145/// (`MAIL FROM:<>`), used for bounces.
146pub fn mailFrom(c: *Client, from: []const u8) Error!void {
147 try c.send("MAIL FROM:<{s}>", .{from});
148 _ = try c.expectClass(2);
149}
150
151pub fn rcptTo(c: *Client, to: []const u8) Error!void {
152 try c.send("RCPT TO:<{s}>", .{to});
153 _ = try c.expectClass(2);
154}
155
156/// Sends the message content for the current transaction (DATA). Line
157/// endings in `data` are normalized to CRLF and leading dots are stuffed.
158pub fn sendMessage(c: *Client, data: []const u8) Error!void {
159 try c.send("DATA", .{});
160 _ = try c.expect(354);
161 try protocol.writeStuffed(c.writer, data);
162 try c.writer.writeAll("." ++ protocol.crlf);
163 try c.writer.flush();
164 _ = try c.expectClass(2);
165}
166
167/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient,
168/// then DATA. Call after `greet` and `hello`.
169pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, data: []const u8) Error!void {
170 try c.mailFrom(from);
171 for (recipients) |recipient| try c.rcptTo(recipient);
172 try c.sendMessage(data);
173}
174
175/// Aborts the current mail transaction.
176pub fn rset(c: *Client) Error!void {
177 try c.send("RSET", .{});
178 _ = try c.expectClass(2);
179}
180
181pub fn noop(c: *Client) Error!void {
182 try c.send("NOOP", .{});
183 _ = try c.expectClass(2);
184}
185
186/// Ends the session. The connection should be closed afterwards.
187pub fn quit(c: *Client) Error!void {
188 try c.send("QUIT", .{});
189 _ = try c.expect(221);
190}
191
192fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void {
193 try c.writer.print(fmt ++ protocol.crlf, args);
194 try c.writer.flush();
195}
196
197fn readReply(c: *Client) Error!Reply {
198 const reply = try Reply.read(c.reader, c.reply_buffer);
199 c.last_reply = reply;
200 return reply;
201}
202
203fn expect(c: *Client, code: u16) Error!Reply {
204 const reply = try c.readReply();
205 if (reply.code != code) return error.UnexpectedReply;
206 return reply;
207}
208
209fn expectClass(c: *Client, class: u16) Error!Reply {
210 const reply = try c.readReply();
211 if (reply.code / 100 != class) return error.UnexpectedReply;
212 return reply;
213}
214
215test "full transaction against a scripted server" {
216 const responses = "220 mx.example.com ESMTP\r\n" ++
217 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++
218 "250 2.1.0 Ok\r\n" ++
219 "250 2.1.5 Ok\r\n" ++
220 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
221 "250 2.0.0 Ok\r\n" ++
222 "221 2.0.0 Bye\r\n";
223 var reader: Io.Reader = .fixed(responses);
224 var out_buf: [1024]u8 = undefined;
225 var writer: Io.Writer = .fixed(&out_buf);
226 var reply_buf: [512]u8 = undefined;
227 var client: Client = .init(&reader, &writer, &reply_buf);
228
229 _ = try client.greet();
230 const ext = try client.hello("client.example.org");
231 try std.testing.expect(ext.pipelining);
232 try std.testing.expect(ext.eight_bit_mime);
233 try std.testing.expect(!ext.starttls);
234 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size);
235
236 try client.sendMail(
237 "alice@example.com",
238 &.{"bob@example.net"},
239 "Subject: hi\r\n\r\n.leading dot\r\n",
240 );
241 try client.quit();
242
243 try std.testing.expectEqualStrings(
244 "EHLO client.example.org\r\n" ++
245 "MAIL FROM:<alice@example.com>\r\n" ++
246 "RCPT TO:<bob@example.net>\r\n" ++
247 "DATA\r\n" ++
248 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++
249 "QUIT\r\n",
250 writer.buffered(),
251 );
252}
253
254test "HELO fallback for non-ESMTP servers" {
255 const responses = "220 old.example.com\r\n" ++
256 "502 command not implemented\r\n" ++
257 "250 old.example.com\r\n";
258 var reader: Io.Reader = .fixed(responses);
259 var out_buf: [256]u8 = undefined;
260 var writer: Io.Writer = .fixed(&out_buf);
261 var reply_buf: [256]u8 = undefined;
262 var client: Client = .init(&reader, &writer, &reply_buf);
263
264 _ = try client.greet();
265 const ext = try client.hello("client.example.org");
266 try std.testing.expectEqual(Extensions{}, ext);
267 try std.testing.expectEqualStrings(
268 "EHLO client.example.org\r\nHELO client.example.org\r\n",
269 writer.buffered(),
270 );
271}
272
273test "rejected recipient surfaces the reply" {
274 const responses = "550 5.1.1 No such user\r\n";
275 var reader: Io.Reader = .fixed(responses);
276 var out_buf: [256]u8 = undefined;
277 var writer: Io.Writer = .fixed(&out_buf);
278 var reply_buf: [256]u8 = undefined;
279 var client: Client = .init(&reader, &writer, &reply_buf);
280
281 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com"));
282 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code);
283 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text);
284}
285
286test "starttls handshake handoff" {
287 const plain_responses = "220 mx.example.com ESMTP\r\n" ++
288 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++
289 "220 2.0.0 Ready to start TLS\r\n";
290 var reader: Io.Reader = .fixed(plain_responses);
291 var out_buf: [256]u8 = undefined;
292 var writer: Io.Writer = .fixed(&out_buf);
293 var reply_buf: [256]u8 = undefined;
294 var client: Client = .init(&reader, &writer, &reply_buf);
295
296 _ = try client.greet();
297 const ext = try client.hello("client.example.org");
298 try std.testing.expect(ext.starttls);
299 try client.starttls();
300
301 // Simulate the post-handshake encrypted transport with fresh buffers;
302 // the session must re-EHLO on it.
303 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n";
304 var tls_reader: Io.Reader = .fixed(tls_responses);
305 var tls_out_buf: [256]u8 = undefined;
306 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
307 client.setTransport(&tls_reader, &tls_writer);
308
309 const tls_ext = try client.hello("client.example.org");
310 try std.testing.expect(!tls_ext.starttls);
311 try std.testing.expect(tls_ext.eight_bit_mime);
312 try std.testing.expectEqualStrings(
313 "EHLO client.example.org\r\nSTARTTLS\r\n",
314 writer.buffered(),
315 );
316 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered());
317}
318
319test "authPlain encodes credentials" {
320 const responses = "235 2.7.0 Accepted\r\n";
321 var reader: Io.Reader = .fixed(responses);
322 var out_buf: [256]u8 = undefined;
323 var writer: Io.Writer = .fixed(&out_buf);
324 var reply_buf: [256]u8 = undefined;
325 var client: Client = .init(&reader, &writer, &reply_buf);
326
327 try client.authPlain("", "user", "pass");
328 // base64("\x00user\x00pass")
329 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered());
330}