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