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/// Authenticates with AUTH PLAIN (RFC 4616). Pass an empty `authzid` unless
114/// you need to act on behalf of another identity. Note that sending
115/// credentials over an unencrypted connection exposes them to the network.
116pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) (Error || error{CredentialsTooLong})!void {
117 var plain_buf: [512]u8 = undefined;
118 var plain: Io.Writer = .fixed(&plain_buf);
119 plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch
120 return error.CredentialsTooLong;
121 var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined;
122 const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered());
123 try c.send("AUTH PLAIN {s}", .{b64});
124 _ = try c.expect(235);
125}
126
127/// Starts a mail transaction. An empty `from` sends the null reverse-path
128/// (`MAIL FROM:<>`), used for bounces.
129pub fn mailFrom(c: *Client, from: []const u8) Error!void {
130 try c.send("MAIL FROM:<{s}>", .{from});
131 _ = try c.expectClass(2);
132}
133
134pub fn rcptTo(c: *Client, to: []const u8) Error!void {
135 try c.send("RCPT TO:<{s}>", .{to});
136 _ = try c.expectClass(2);
137}
138
139/// Sends the message content for the current transaction (DATA). Line
140/// endings in `data` are normalized to CRLF and leading dots are stuffed.
141pub fn sendMessage(c: *Client, data: []const u8) Error!void {
142 try c.send("DATA", .{});
143 _ = try c.expect(354);
144 try protocol.writeStuffed(c.writer, data);
145 try c.writer.writeAll("." ++ protocol.crlf);
146 try c.writer.flush();
147 _ = try c.expectClass(2);
148}
149
150/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient,
151/// then DATA. Call after `greet` and `hello`.
152pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, data: []const u8) Error!void {
153 try c.mailFrom(from);
154 for (recipients) |recipient| try c.rcptTo(recipient);
155 try c.sendMessage(data);
156}
157
158/// Aborts the current mail transaction.
159pub fn rset(c: *Client) Error!void {
160 try c.send("RSET", .{});
161 _ = try c.expectClass(2);
162}
163
164pub fn noop(c: *Client) Error!void {
165 try c.send("NOOP", .{});
166 _ = try c.expectClass(2);
167}
168
169/// Ends the session. The connection should be closed afterwards.
170pub fn quit(c: *Client) Error!void {
171 try c.send("QUIT", .{});
172 _ = try c.expect(221);
173}
174
175fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void {
176 try c.writer.print(fmt ++ protocol.crlf, args);
177 try c.writer.flush();
178}
179
180fn readReply(c: *Client) Error!Reply {
181 const reply = try Reply.read(c.reader, c.reply_buffer);
182 c.last_reply = reply;
183 return reply;
184}
185
186fn expect(c: *Client, code: u16) Error!Reply {
187 const reply = try c.readReply();
188 if (reply.code != code) return error.UnexpectedReply;
189 return reply;
190}
191
192fn expectClass(c: *Client, class: u16) Error!Reply {
193 const reply = try c.readReply();
194 if (reply.code / 100 != class) return error.UnexpectedReply;
195 return reply;
196}
197
198test "full transaction against a scripted server" {
199 const responses = "220 mx.example.com ESMTP\r\n" ++
200 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++
201 "250 2.1.0 Ok\r\n" ++
202 "250 2.1.5 Ok\r\n" ++
203 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
204 "250 2.0.0 Ok\r\n" ++
205 "221 2.0.0 Bye\r\n";
206 var reader: Io.Reader = .fixed(responses);
207 var out_buf: [1024]u8 = undefined;
208 var writer: Io.Writer = .fixed(&out_buf);
209 var reply_buf: [512]u8 = undefined;
210 var client: Client = .init(&reader, &writer, &reply_buf);
211
212 _ = try client.greet();
213 const ext = try client.hello("client.example.org");
214 try std.testing.expect(ext.pipelining);
215 try std.testing.expect(ext.eight_bit_mime);
216 try std.testing.expect(!ext.starttls);
217 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size);
218
219 try client.sendMail(
220 "alice@example.com",
221 &.{"bob@example.net"},
222 "Subject: hi\r\n\r\n.leading dot\r\n",
223 );
224 try client.quit();
225
226 try std.testing.expectEqualStrings(
227 "EHLO client.example.org\r\n" ++
228 "MAIL FROM:<alice@example.com>\r\n" ++
229 "RCPT TO:<bob@example.net>\r\n" ++
230 "DATA\r\n" ++
231 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++
232 "QUIT\r\n",
233 writer.buffered(),
234 );
235}
236
237test "HELO fallback for non-ESMTP servers" {
238 const responses = "220 old.example.com\r\n" ++
239 "502 command not implemented\r\n" ++
240 "250 old.example.com\r\n";
241 var reader: Io.Reader = .fixed(responses);
242 var out_buf: [256]u8 = undefined;
243 var writer: Io.Writer = .fixed(&out_buf);
244 var reply_buf: [256]u8 = undefined;
245 var client: Client = .init(&reader, &writer, &reply_buf);
246
247 _ = try client.greet();
248 const ext = try client.hello("client.example.org");
249 try std.testing.expectEqual(Extensions{}, ext);
250 try std.testing.expectEqualStrings(
251 "EHLO client.example.org\r\nHELO client.example.org\r\n",
252 writer.buffered(),
253 );
254}
255
256test "rejected recipient surfaces the reply" {
257 const responses = "550 5.1.1 No such user\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 std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com"));
265 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code);
266 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text);
267}
268
269test "authPlain encodes credentials" {
270 const responses = "235 2.7.0 Accepted\r\n";
271 var reader: Io.Reader = .fixed(responses);
272 var out_buf: [256]u8 = undefined;
273 var writer: Io.Writer = .fixed(&out_buf);
274 var reply_buf: [256]u8 = undefined;
275 var client: Client = .init(&reader, &writer, &reply_buf);
276
277 try client.authPlain("", "user", "pass");
278 // base64("\x00user\x00pass")
279 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered());
280}