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//! A single-connection SMTP server session. Like the client, it runs over
5//! any `Io.Reader`/`Io.Writer` pair; accept a TCP connection and hand its
6//! stream reader/writer to `run`. Accepting connections, concurrency, and
7//! message storage are left to the caller — the session just speaks the
8//! protocol and forwards decisions to a `Handler`.
9//!
10//! Typical use:
11//! ```
12//! var session: Server = .init(&stream_reader, &stream_writer, handler, .{
13//! .hostname = "mx.example.com",
14//! });
15//! try session.run(gpa);
16//! ```
17
18const Server = @This();
19
20const std = @import("std");
21const Io = std.Io;
22const protocol = @import("protocol.zig");
23
24reader: *Io.Reader,
25writer: *Io.Writer,
26handler: Handler,
27options: Options,
28
29pub const Options = struct {
30 /// Hostname announced in the greeting and the EHLO response.
31 hostname: []const u8 = "localhost",
32 /// Advertised via the SIZE extension and enforced during DATA.
33 max_message_size: usize = 16 * 1024 * 1024,
34 max_recipients: usize = 100,
35};
36
37/// A handler's verdict on an envelope step or a complete message.
38pub const Decision = union(enum) {
39 accept,
40 reject: Rejection,
41
42 pub const Rejection = struct {
43 /// Use 4xx for "try again later", 5xx for permanent rejection.
44 code: u16 = 550,
45 text: []const u8 = "5.7.1 Rejected",
46 };
47};
48
49pub const Envelope = struct {
50 /// Empty for the null reverse-path (`MAIL FROM:<>`).
51 from: []const u8,
52 recipients: []const []const u8,
53};
54
55/// Callbacks invoked during a session. All slices passed to callbacks are
56/// only valid for the duration of the call.
57pub const Handler = struct {
58 context: ?*anyopaque = null,
59 vtable: *const VTable,
60
61 pub const VTable = struct {
62 /// Called for MAIL FROM. Null accepts every sender.
63 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null,
64 /// Called for each RCPT TO. Null accepts every recipient.
65 rcptTo: ?*const fn (context: ?*anyopaque, to: []const u8) Decision = null,
66 /// Called once the complete message has been received. The data has
67 /// CRLF line endings and dot-stuffing already removed.
68 message: *const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision,
69 };
70};
71
72pub fn init(reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options) Server {
73 return .{ .reader = reader, .writer = writer, .handler = handler, .options = options };
74}
75
76pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory };
77
78/// Serves the session until the client sends QUIT or disconnects. `gpa`
79/// backs per-transaction storage (envelope and message data); everything is
80/// freed on return.
81pub fn run(s: *Server, gpa: std.mem.Allocator) RunError!void {
82 var arena_state: std.heap.ArenaAllocator = .init(gpa);
83 defer arena_state.deinit();
84 const arena = arena_state.allocator();
85
86 var greeted = false;
87 var from: ?[]const u8 = null;
88 var recipients: std.ArrayList([]const u8) = .empty;
89
90 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname});
91 try s.writer.flush();
92
93 while (true) {
94 const line = protocol.readLine(s.reader) catch |err| switch (err) {
95 error.EndOfStream => return, // Client disconnected.
96 error.ReadFailed => return error.ReadFailed,
97 error.LineTooLong => {
98 try s.discardLine();
99 try s.reply(500, "5.5.2 Line too long");
100 continue;
101 },
102 };
103 const command = protocol.Command.parse(line) catch {
104 try s.reply(501, "5.5.4 Syntax error in parameters");
105 continue;
106 };
107 switch (command) {
108 .helo => {
109 greeted = true;
110 from = null;
111 recipients = .empty;
112 _ = arena_state.reset(.retain_capacity);
113 try s.reply(250, s.options.hostname);
114 },
115 .ehlo => {
116 greeted = true;
117 from = null;
118 recipients = .empty;
119 _ = arena_state.reset(.retain_capacity);
120 try s.writer.print(
121 "250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE {d}\r\n",
122 .{ s.options.hostname, s.options.max_message_size },
123 );
124 try s.writer.flush();
125 },
126 .mail => |args| {
127 if (!greeted) {
128 try s.reply(503, "5.5.1 Send EHLO first");
129 continue;
130 }
131 if (from != null) {
132 try s.reply(503, "5.5.1 Nested MAIL command");
133 continue;
134 }
135 if (s.handler.vtable.mailFrom) |callback| {
136 switch (callback(s.handler.context, args.path)) {
137 .accept => {},
138 .reject => |r| {
139 try s.reply(r.code, r.text);
140 continue;
141 },
142 }
143 }
144 from = try arena.dupe(u8, args.path);
145 try s.reply(250, "2.1.0 Ok");
146 },
147 .rcpt => |args| {
148 if (from == null) {
149 try s.reply(503, "5.5.1 Need MAIL command first");
150 continue;
151 }
152 if (recipients.items.len >= s.options.max_recipients) {
153 try s.reply(452, "4.5.3 Too many recipients");
154 continue;
155 }
156 if (s.handler.vtable.rcptTo) |callback| {
157 switch (callback(s.handler.context, args.path)) {
158 .accept => {},
159 .reject => |r| {
160 try s.reply(r.code, r.text);
161 continue;
162 },
163 }
164 }
165 try recipients.append(arena, try arena.dupe(u8, args.path));
166 try s.reply(250, "2.1.5 Ok");
167 },
168 .data => {
169 if (recipients.items.len == 0) {
170 try s.reply(503, "5.5.1 Need RCPT command first");
171 continue;
172 }
173 try s.receiveData(arena, .{
174 .from = from.?,
175 .recipients = recipients.items,
176 });
177 from = null;
178 recipients = .empty;
179 _ = arena_state.reset(.retain_capacity);
180 },
181 .rset => {
182 from = null;
183 recipients = .empty;
184 _ = arena_state.reset(.retain_capacity);
185 try s.reply(250, "2.0.0 Ok");
186 },
187 .noop => try s.reply(250, "2.0.0 Ok"),
188 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"),
189 .help => try s.reply(214, "2.0.0 See RFC 5321"),
190 .quit => {
191 try s.reply(221, "2.0.0 Bye");
192 return;
193 },
194 .unknown => try s.reply(500, "5.5.2 Command not recognized"),
195 }
196 }
197}
198
199/// Reads message content after DATA up to the terminating ".\r\n",
200/// un-stuffing dots, then asks the handler to accept or reject.
201fn receiveData(s: *Server, arena: std.mem.Allocator, envelope: Envelope) RunError!void {
202 try s.reply(354, "End data with <CR><LF>.<CR><LF>");
203 var data: std.ArrayList(u8) = .empty;
204 var oversize = false;
205 while (true) {
206 const line = protocol.readLine(s.reader) catch |err| switch (err) {
207 error.EndOfStream => return, // Client disconnected mid-message.
208 error.ReadFailed => return error.ReadFailed,
209 error.LineTooLong => {
210 // Longer than our reader buffer; RFC 5321 caps text lines at
211 // 1000 octets, so treat it as oversize but keep scanning for
212 // the terminator.
213 try s.discardLine();
214 oversize = true;
215 continue;
216 },
217 };
218 if (std.mem.eql(u8, line, ".")) break;
219 const content = if (line.len > 0 and line[0] == '.') line[1..] else line;
220 if (oversize) continue;
221 if (data.items.len + content.len + protocol.crlf.len > s.options.max_message_size) {
222 oversize = true;
223 continue;
224 }
225 try data.appendSlice(arena, content);
226 try data.appendSlice(arena, protocol.crlf);
227 }
228 if (oversize) {
229 try s.reply(552, "5.3.4 Message exceeds maximum size");
230 return;
231 }
232 switch (s.handler.vtable.message(s.handler.context, envelope, data.items)) {
233 .accept => try s.reply(250, "2.0.0 Ok, message accepted"),
234 .reject => |r| try s.reply(r.code, r.text),
235 }
236}
237
238fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void {
239 try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text });
240 try s.writer.flush();
241}
242
243/// Discards input through the next newline after `error.LineTooLong`, which
244/// leaves the reader positioned at the start of the oversized line.
245fn discardLine(s: *Server) error{ReadFailed}!void {
246 _ = s.reader.discardDelimiterInclusive('\n') catch |err| switch (err) {
247 error.EndOfStream => {},
248 error.ReadFailed => return error.ReadFailed,
249 };
250}
251
252const TestHandler = struct {
253 from: std.ArrayList(u8) = .empty,
254 recipients: std.ArrayList(u8) = .empty,
255 data: std.ArrayList(u8) = .empty,
256 messages_accepted: usize = 0,
257 reject_recipient: ?[]const u8 = null,
258
259 fn deinit(h: *TestHandler) void {
260 h.from.deinit(std.testing.allocator);
261 h.recipients.deinit(std.testing.allocator);
262 h.data.deinit(std.testing.allocator);
263 }
264
265 fn handler(h: *TestHandler) Handler {
266 return .{ .context = h, .vtable = &.{
267 .rcptTo = onRcptTo,
268 .message = onMessage,
269 } };
270 }
271
272 fn onRcptTo(context: ?*anyopaque, to: []const u8) Decision {
273 const h: *TestHandler = @ptrCast(@alignCast(context.?));
274 if (h.reject_recipient) |rejected| {
275 if (std.mem.eql(u8, to, rejected)) return .{ .reject = .{
276 .code = 550,
277 .text = "5.1.1 No such user",
278 } };
279 }
280 return .accept;
281 }
282
283 fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision {
284 const h: *TestHandler = @ptrCast(@alignCast(context.?));
285 const gpa = std.testing.allocator;
286 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} };
287 for (envelope.recipients) |recipient| {
288 h.recipients.appendSlice(gpa, recipient) catch return .{ .reject = .{} };
289 h.recipients.append(gpa, ';') catch return .{ .reject = .{} };
290 }
291 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} };
292 h.messages_accepted += 1;
293 return .accept;
294 }
295};
296
297fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 {
298 var reader: Io.Reader = .fixed(input);
299 var writer: Io.Writer = .fixed(out_buf);
300 var session: Server = .init(&reader, &writer, handler, options);
301 try session.run(std.testing.allocator);
302 return writer.buffered();
303}
304
305test "complete session" {
306 var h: TestHandler = .{};
307 defer h.deinit();
308
309 var out_buf: [1024]u8 = undefined;
310 const output = try runScript(
311 "EHLO client.example.org\r\n" ++
312 "MAIL FROM:<alice@example.com>\r\n" ++
313 "RCPT TO:<bob@example.net>\r\n" ++
314 "RCPT TO:<carol@example.net>\r\n" ++
315 "DATA\r\n" ++
316 "Subject: hi\r\n" ++
317 "\r\n" ++
318 "..stuffed line\r\n" ++
319 "body\r\n" ++
320 ".\r\n" ++
321 "QUIT\r\n",
322 &out_buf,
323 h.handler(),
324 .{ .hostname = "mx.test" },
325 );
326
327 try std.testing.expectEqualStrings("alice@example.com", h.from.items);
328 try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items);
329 try std.testing.expectEqualStrings("Subject: hi\r\n\r\n.stuffed line\r\nbody\r\n", h.data.items);
330 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
331
332 try std.testing.expectEqualStrings(
333 "220 mx.test ESMTP ready\r\n" ++
334 "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 16777216\r\n" ++
335 "250 2.1.0 Ok\r\n" ++
336 "250 2.1.5 Ok\r\n" ++
337 "250 2.1.5 Ok\r\n" ++
338 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
339 "250 2.0.0 Ok, message accepted\r\n" ++
340 "221 2.0.0 Bye\r\n",
341 output,
342 );
343}
344
345test "command sequencing is enforced" {
346 var h: TestHandler = .{};
347 defer h.deinit();
348
349 var out_buf: [1024]u8 = undefined;
350 const output = try runScript(
351 "MAIL FROM:<early@example.com>\r\n" ++
352 "EHLO client.example.org\r\n" ++
353 "RCPT TO:<bob@example.net>\r\n" ++
354 "DATA\r\n" ++
355 "QUIT\r\n",
356 &out_buf,
357 h.handler(),
358 .{},
359 );
360
361 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
362 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Send EHLO first") != null);
363 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null);
364 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null);
365}
366
367test "handler can reject a recipient" {
368 var h: TestHandler = .{ .reject_recipient = "nobody@example.net" };
369 defer h.deinit();
370
371 var out_buf: [1024]u8 = undefined;
372 const output = try runScript(
373 "EHLO client.example.org\r\n" ++
374 "MAIL FROM:<alice@example.com>\r\n" ++
375 "RCPT TO:<nobody@example.net>\r\n" ++
376 "RCPT TO:<bob@example.net>\r\n" ++
377 "DATA\r\n" ++
378 "hello\r\n" ++
379 ".\r\n" ++
380 "QUIT\r\n",
381 &out_buf,
382 h.handler(),
383 .{},
384 );
385
386 try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null);
387 try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items);
388 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
389}
390
391test "oversize message is rejected but session continues" {
392 var h: TestHandler = .{};
393 defer h.deinit();
394
395 var out_buf: [1024]u8 = undefined;
396 const output = try runScript(
397 "EHLO client.example.org\r\n" ++
398 "MAIL FROM:<alice@example.com>\r\n" ++
399 "RCPT TO:<bob@example.net>\r\n" ++
400 "DATA\r\n" ++
401 "0123456789012345678901234567890123456789\r\n" ++
402 ".\r\n" ++
403 "NOOP\r\n" ++
404 "QUIT\r\n",
405 &out_buf,
406 h.handler(),
407 .{ .max_message_size = 16 },
408 );
409
410 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
411 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null);
412 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null);
413}