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, the session speaks TLS (see `TlsOptions.mode`). The
44 /// underlying stream reader/writer handed to `init` must then have
45 /// buffers of at least `tls.input_buffer_len` and
46 /// `tls.output_buffer_len` bytes, since the handshake and TLS records
47 /// run over them.
48 tls: ?TlsOptions = null,
49 /// Reject MAIL with 530 until the client has authenticated. Requires a
50 /// handler with an `authenticate` callback.
51 require_auth: bool = false,
52};
53
54pub const TlsOptions = struct {
55 io: Io,
56 /// Server certificate chain and private key presented to clients.
57 auth: *tls.config.CertKeyPair,
58 mode: Mode = .starttls,
59
60 pub const Mode = enum {
61 /// Advertise and accept the STARTTLS command (RFC 3207).
62 starttls,
63 /// Perform the TLS handshake before the greeting (implicit TLS /
64 /// SMTPS, port 465 style).
65 implicit,
66 };
67};
68
69/// A handler's verdict on an envelope step or a complete message.
70pub const Decision = union(enum) {
71 accept,
72 reject: Rejection,
73
74 pub const Rejection = struct {
75 /// Use 4xx for "try again later", 5xx for permanent rejection.
76 code: u16 = 550,
77 text: []const u8 = "5.7.1 Rejected",
78 };
79};
80
81pub const Envelope = struct {
82 /// Empty for the null reverse-path (`MAIL FROM:<>`).
83 from: []const u8,
84 recipients: []const []const u8,
85 /// Value of the MAIL SIZE= parameter (RFC 1870), if the client
86 /// declared one. Already validated against `Options.max_message_size`.
87 declared_size: ?u64 = null,
88 /// Value of the MAIL BODY= parameter (RFC 6152).
89 body: Body = .unspecified,
90
91 pub const Body = enum { unspecified, seven_bit, eight_bit_mime };
92};
93
94/// Callbacks invoked during a session. All slices passed to callbacks are
95/// only valid for the duration of the call.
96pub const Handler = struct {
97 context: ?*anyopaque = null,
98 vtable: *const VTable,
99
100 pub const VTable = struct {
101 /// Called for AUTH with the decoded credentials; return true to
102 /// accept. When set, AUTH PLAIN and AUTH LOGIN are advertised and
103 /// accepted (RFC 4954).
104 authenticate: ?*const fn (context: ?*anyopaque, username: []const u8, password: []const u8) bool = null,
105 /// Called for MAIL FROM. Null accepts every sender.
106 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null,
107 /// Called for each RCPT TO. Null accepts every recipient.
108 rcptTo: ?*const fn (context: ?*anyopaque, to: []const u8) Decision = null,
109 /// Called once the complete message has been received. The data has
110 /// CRLF line endings and dot-stuffing already removed. Exactly one
111 /// of `message` and `messageReader` must be set.
112 message: ?*const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision = null,
113 /// Streaming alternative to `message`: called after DATA with a
114 /// reader that yields the message content (dot-stuffing removed,
115 /// line endings normalized to CRLF) until end of stream. Anything
116 /// the callback leaves unread is drained by the session, so
117 /// returning early is fine. `Options.max_message_size` is not
118 /// enforced in this mode; individual message lines must fit the
119 /// session's stream reader buffer.
120 messageReader: ?*const fn (context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision = null,
121 };
122};
123
124pub fn init(reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options) Server {
125 return .{ .reader = reader, .writer = writer, .handler = handler, .options = options };
126}
127
128pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory, TlsHandshakeFailed };
129
130/// Serves the session until the client sends QUIT or disconnects. `gpa`
131/// backs per-transaction storage (envelope and message data); everything is
132/// freed on return.
133pub fn run(s: *Server, gpa: std.mem.Allocator) RunError!void {
134 var arena_state: std.heap.ArenaAllocator = .init(gpa);
135 defer arena_state.deinit();
136 const arena = arena_state.allocator();
137
138 std.debug.assert(!s.options.require_auth or s.handler.vtable.authenticate != null);
139 std.debug.assert((s.handler.vtable.message == null) != (s.handler.vtable.messageReader == null));
140
141 if (s.options.tls) |config| {
142 if (config.mode == .implicit and !s.secured) try s.upgradeToTls(config);
143 }
144
145 var greeted = false;
146 var authenticated = false;
147 var from: ?[]const u8 = null;
148 var recipients: std.ArrayList([]const u8) = .empty;
149 var declared_size: ?u64 = null;
150 var body: Envelope.Body = .unspecified;
151
152 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname});
153 try s.writer.flush();
154
155 while (true) {
156 const line = protocol.readLine(s.reader) catch |err| switch (err) {
157 error.EndOfStream => return, // Client disconnected.
158 error.ReadFailed => return error.ReadFailed,
159 error.LineTooLong => {
160 try s.discardLine();
161 try s.reply(500, "5.5.2 Line too long");
162 continue;
163 },
164 };
165 const command = protocol.Command.parse(line) catch {
166 try s.reply(501, "5.5.4 Syntax error in parameters");
167 continue;
168 };
169 switch (command) {
170 .helo => {
171 greeted = true;
172 from = null;
173 recipients = .empty;
174 declared_size = null;
175 body = .unspecified;
176 _ = arena_state.reset(.retain_capacity);
177 try s.reply(250, s.options.hostname);
178 },
179 .ehlo => {
180 greeted = true;
181 from = null;
182 recipients = .empty;
183 declared_size = null;
184 body = .unspecified;
185 _ = arena_state.reset(.retain_capacity);
186 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n", .{s.options.hostname});
187 if (s.options.tls) |config| {
188 if (config.mode == .starttls and !s.secured)
189 try s.writer.writeAll("250-STARTTLS\r\n");
190 }
191 if (s.handler.vtable.authenticate != null and !authenticated)
192 try s.writer.writeAll("250-AUTH PLAIN LOGIN\r\n");
193 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size});
194 try s.writer.flush();
195 },
196 .mail => |args| {
197 if (!greeted) {
198 try s.reply(503, "5.5.1 Send EHLO first");
199 continue;
200 }
201 if (s.options.require_auth and !authenticated) {
202 try s.reply(530, "5.7.0 Authentication required");
203 continue;
204 }
205 if (from != null) {
206 try s.reply(503, "5.5.1 Nested MAIL command");
207 continue;
208 }
209 var mail_declared_size: ?u64 = null;
210 var mail_body: Envelope.Body = .unspecified;
211 var params_ok = true;
212 var params = args.paramIterator();
213 while (params.next()) |param| {
214 if (std.ascii.eqlIgnoreCase(param.keyword, "SIZE")) {
215 const size = std.fmt.parseInt(u64, param.value, 10) catch {
216 try s.reply(501, "5.5.2 Invalid SIZE parameter");
217 params_ok = false;
218 break;
219 };
220 if (size > s.options.max_message_size) {
221 try s.reply(552, "5.3.4 Message size exceeds fixed maximum");
222 params_ok = false;
223 break;
224 }
225 mail_declared_size = size;
226 } else if (std.ascii.eqlIgnoreCase(param.keyword, "BODY")) {
227 if (std.ascii.eqlIgnoreCase(param.value, "7BIT")) {
228 mail_body = .seven_bit;
229 } else if (std.ascii.eqlIgnoreCase(param.value, "8BITMIME")) {
230 mail_body = .eight_bit_mime;
231 } else {
232 try s.reply(555, "5.5.4 Unsupported BODY value");
233 params_ok = false;
234 break;
235 }
236 } else {
237 try s.reply(555, "5.5.4 Unrecognized parameter");
238 params_ok = false;
239 break;
240 }
241 }
242 if (!params_ok) continue;
243 if (s.handler.vtable.mailFrom) |callback| {
244 switch (callback(s.handler.context, args.path)) {
245 .accept => {},
246 .reject => |r| {
247 try s.reply(r.code, r.text);
248 continue;
249 },
250 }
251 }
252 from = try arena.dupe(u8, args.path);
253 declared_size = mail_declared_size;
254 body = mail_body;
255 try s.reply(250, "2.1.0 Ok");
256 },
257 .rcpt => |args| {
258 if (from == null) {
259 try s.reply(503, "5.5.1 Need MAIL command first");
260 continue;
261 }
262 if (args.params.len != 0) {
263 try s.reply(555, "5.5.4 Unrecognized parameter");
264 continue;
265 }
266 if (recipients.items.len >= s.options.max_recipients) {
267 try s.reply(452, "4.5.3 Too many recipients");
268 continue;
269 }
270 if (s.handler.vtable.rcptTo) |callback| {
271 switch (callback(s.handler.context, args.path)) {
272 .accept => {},
273 .reject => |r| {
274 try s.reply(r.code, r.text);
275 continue;
276 },
277 }
278 }
279 try recipients.append(arena, try arena.dupe(u8, args.path));
280 try s.reply(250, "2.1.5 Ok");
281 },
282 .data => {
283 if (recipients.items.len == 0) {
284 try s.reply(503, "5.5.1 Need RCPT command first");
285 continue;
286 }
287 try s.receiveData(arena, .{
288 .from = from.?,
289 .recipients = recipients.items,
290 .declared_size = declared_size,
291 .body = body,
292 });
293 from = null;
294 recipients = .empty;
295 declared_size = null;
296 body = .unspecified;
297 _ = arena_state.reset(.retain_capacity);
298 },
299 .rset => {
300 from = null;
301 recipients = .empty;
302 declared_size = null;
303 body = .unspecified;
304 _ = arena_state.reset(.retain_capacity);
305 try s.reply(250, "2.0.0 Ok");
306 },
307 .noop => try s.reply(250, "2.0.0 Ok"),
308 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"),
309 .help => try s.reply(214, "2.0.0 See RFC 5321"),
310 .starttls => {
311 const config = s.options.tls orelse {
312 try s.reply(502, "5.5.1 STARTTLS not supported");
313 continue;
314 };
315 if (config.mode != .starttls) {
316 try s.reply(502, "5.5.1 STARTTLS not supported");
317 continue;
318 }
319 if (s.secured) {
320 try s.reply(503, "5.5.1 TLS already active");
321 continue;
322 }
323 try s.reply(220, "2.0.0 Ready to start TLS");
324 try s.upgradeToTls(config);
325 // RFC 3207 §4.2: both sides return to their initial state;
326 // the client must EHLO again.
327 greeted = false;
328 authenticated = false;
329 from = null;
330 recipients = .empty;
331 declared_size = null;
332 body = .unspecified;
333 _ = arena_state.reset(.retain_capacity);
334 },
335 .quit => {
336 try s.reply(221, "2.0.0 Bye");
337 if (s.secured) s.tls_connection.close() catch {};
338 return;
339 },
340 .auth => |args| {
341 if (s.handler.vtable.authenticate == null) {
342 try s.reply(503, "5.5.1 Authentication not enabled");
343 continue;
344 }
345 if (!greeted) {
346 try s.reply(503, "5.5.1 Send EHLO first");
347 continue;
348 }
349 if (authenticated) {
350 try s.reply(503, "5.5.1 Already authenticated");
351 continue;
352 }
353 if (from != null) {
354 try s.reply(503, "5.5.1 MAIL transaction in progress");
355 continue;
356 }
357 switch (try s.receiveAuth(args)) {
358 .authenticated => authenticated = true,
359 .rejected => {},
360 .disconnected => return,
361 }
362 },
363 .unknown => try s.reply(500, "5.5.2 Command not recognized"),
364 }
365 }
366}
367
368/// Performs the server-side TLS handshake over the current transport and
369/// swaps the session onto the encrypted connection.
370fn upgradeToTls(s: *Server, config: TlsOptions) error{TlsHandshakeFailed}!void {
371 var rng_source: std.Random.IoSource = .{ .io = config.io };
372 s.tls_connection = tls.server(s.reader, s.writer, .{
373 .auth = config.auth,
374 .rng = rng_source.interface(),
375 .now = Io.Clock.real.now(config.io),
376 }) catch return error.TlsHandshakeFailed;
377 s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer);
378 s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer);
379 s.reader = &s.tls_reader.interface;
380 s.writer = &s.tls_writer.interface;
381 s.secured = true;
382}
383
384const AuthOutcome = enum { authenticated, rejected, disconnected };
385
386/// Runs the challenge/response exchange for AUTH PLAIN or AUTH LOGIN
387/// (RFC 4954) and consults the handler's `authenticate` callback. Every
388/// outcome except `disconnected` has already sent its reply.
389fn receiveAuth(s: *Server, args: protocol.Command.AuthArgs) RunError!AuthOutcome {
390 const callback = s.handler.vtable.authenticate.?;
391
392 if (std.ascii.eqlIgnoreCase(args.mechanism, "PLAIN")) {
393 var decoded_buf: [576]u8 = undefined;
394 var response: []const u8 = args.initial;
395 if (response.len == 0) {
396 try s.reply(334, "");
397 response = switch (try s.takeAuthLine()) {
398 .line => |line| line,
399 .cancelled => return .rejected,
400 .disconnected => return .disconnected,
401 };
402 }
403 const decoded = decodeBase64(&decoded_buf, response) orelse {
404 try s.reply(501, "5.5.2 Invalid base64");
405 return .rejected;
406 };
407 // authzid NUL authcid NUL password; the authzid is ignored.
408 const first_nul = std.mem.indexOfScalar(u8, decoded, 0) orelse {
409 try s.reply(501, "5.5.2 Malformed PLAIN response");
410 return .rejected;
411 };
412 const after_authzid = decoded[first_nul + 1 ..];
413 const second_nul = std.mem.indexOfScalar(u8, after_authzid, 0) orelse {
414 try s.reply(501, "5.5.2 Malformed PLAIN response");
415 return .rejected;
416 };
417 return s.finishAuth(callback, after_authzid[0..second_nul], after_authzid[second_nul + 1 ..]);
418 }
419
420 if (std.ascii.eqlIgnoreCase(args.mechanism, "LOGIN")) {
421 var user_buf: [192]u8 = undefined;
422 var pass_buf: [192]u8 = undefined;
423
424 var username: []const u8 = undefined;
425 if (args.initial.len > 0) {
426 // Some clients send the username as an initial response.
427 username = decodeBase64(&user_buf, args.initial) orelse {
428 try s.reply(501, "5.5.2 Invalid base64");
429 return .rejected;
430 };
431 } else {
432 try s.reply(334, "VXNlcm5hbWU6"); // base64("Username:")
433 const line = switch (try s.takeAuthLine()) {
434 .line => |line| line,
435 .cancelled => return .rejected,
436 .disconnected => return .disconnected,
437 };
438 username = decodeBase64(&user_buf, line) orelse {
439 try s.reply(501, "5.5.2 Invalid base64");
440 return .rejected;
441 };
442 }
443 try s.reply(334, "UGFzc3dvcmQ6"); // base64("Password:")
444 const line = switch (try s.takeAuthLine()) {
445 .line => |line| line,
446 .cancelled => return .rejected,
447 .disconnected => return .disconnected,
448 };
449 const password = decodeBase64(&pass_buf, line) orelse {
450 try s.reply(501, "5.5.2 Invalid base64");
451 return .rejected;
452 };
453 return s.finishAuth(callback, username, password);
454 }
455
456 try s.reply(504, "5.5.4 Unrecognized authentication type");
457 return .rejected;
458}
459
460fn finishAuth(
461 s: *Server,
462 callback: *const fn (?*anyopaque, []const u8, []const u8) bool,
463 username: []const u8,
464 password: []const u8,
465) RunError!AuthOutcome {
466 if (callback(s.handler.context, username, password)) {
467 try s.reply(235, "2.7.0 Authentication successful");
468 return .authenticated;
469 }
470 try s.reply(535, "5.7.8 Authentication credentials invalid");
471 return .rejected;
472}
473
474const AuthLine = union(enum) { line: []u8, cancelled, disconnected };
475
476/// Reads one continuation line of an AUTH exchange. `cancelled` covers both
477/// an explicit "*" and an overlong line; its reply has already been sent.
478fn takeAuthLine(s: *Server) RunError!AuthLine {
479 const line = protocol.readLine(s.reader) catch |err| switch (err) {
480 error.EndOfStream => return .disconnected,
481 error.ReadFailed => return error.ReadFailed,
482 error.LineTooLong => {
483 try s.discardLine();
484 try s.reply(501, "5.5.2 Response too long");
485 return .cancelled;
486 },
487 };
488 if (std.mem.eql(u8, line, "*")) {
489 try s.reply(501, "5.7.0 Authentication cancelled");
490 return .cancelled;
491 }
492 return .{ .line = line };
493}
494
495/// Decodes a base64 AUTH argument; "=" denotes an empty response.
496fn decodeBase64(out: []u8, encoded: []const u8) ?[]u8 {
497 if (std.mem.eql(u8, encoded, "=")) return out[0..0];
498 const len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return null;
499 if (len > out.len) return null;
500 std.base64.standard.Decoder.decode(out[0..len], encoded) catch return null;
501 return out[0..len];
502}
503
504/// Reads message content after DATA up to the terminating ".\r\n",
505/// un-stuffing dots, then asks the handler to accept or reject.
506fn receiveData(s: *Server, arena: std.mem.Allocator, envelope: Envelope) RunError!void {
507 try s.reply(354, "End data with <CR><LF>.<CR><LF>");
508
509 if (s.handler.vtable.messageReader) |callback| {
510 var buffer: [1024]u8 = undefined;
511 var data_reader: DataReader = .{
512 .session_reader = s.reader,
513 .interface = .{
514 .buffer = &buffer,
515 .vtable = &.{ .stream = DataReader.stream },
516 .seek = 0,
517 .end = 0,
518 },
519 };
520 const decision = callback(s.handler.context, envelope, &data_reader.interface);
521 // Consume whatever the callback left unread, up to and including
522 // the terminating ".".
523 while (!data_reader.finished) {
524 const line = protocol.readLine(s.reader) catch |err| switch (err) {
525 error.EndOfStream => return, // Client disconnected mid-message.
526 error.ReadFailed => return error.ReadFailed,
527 error.LineTooLong => {
528 try s.discardLine();
529 continue;
530 },
531 };
532 if (std.mem.eql(u8, line, ".")) break;
533 }
534 switch (decision) {
535 .accept => try s.reply(250, "2.0.0 Ok, message accepted"),
536 .reject => |r| try s.reply(r.code, r.text),
537 }
538 return;
539 }
540
541 var data: std.ArrayList(u8) = .empty;
542 var oversize = false;
543 while (true) {
544 const line = protocol.readLine(s.reader) catch |err| switch (err) {
545 error.EndOfStream => return, // Client disconnected mid-message.
546 error.ReadFailed => return error.ReadFailed,
547 error.LineTooLong => {
548 // Longer than our reader buffer; RFC 5321 caps text lines at
549 // 1000 octets, so treat it as oversize but keep scanning for
550 // the terminator.
551 try s.discardLine();
552 oversize = true;
553 continue;
554 },
555 };
556 if (std.mem.eql(u8, line, ".")) break;
557 const content = if (line.len > 0 and line[0] == '.') line[1..] else line;
558 if (oversize) continue;
559 if (data.items.len + content.len + protocol.crlf.len > s.options.max_message_size) {
560 oversize = true;
561 continue;
562 }
563 try data.appendSlice(arena, content);
564 try data.appendSlice(arena, protocol.crlf);
565 }
566 if (oversize) {
567 try s.reply(552, "5.3.4 Message exceeds maximum size");
568 return;
569 }
570 switch (s.handler.vtable.message.?(s.handler.context, envelope, data.items)) {
571 .accept => try s.reply(250, "2.0.0 Ok, message accepted"),
572 .reject => |r| try s.reply(r.code, r.text),
573 }
574}
575
576/// Adapts the session's line-based DATA phase into an `Io.Reader` of the
577/// unstuffed message content for `Handler.VTable.messageReader`.
578const DataReader = struct {
579 session_reader: *Io.Reader,
580 interface: Io.Reader,
581 /// Unread remainder of the current line (points into the session
582 /// reader's buffer, which only this reader touches during DATA).
583 line: []const u8 = &.{},
584 line_ending: []const u8 = &.{},
585 finished: bool = false,
586
587 fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize {
588 const dr: *DataReader = @alignCast(@fieldParentPtr("interface", io_r));
589 if (dr.line.len == 0 and dr.line_ending.len == 0) {
590 if (dr.finished) return error.EndOfStream;
591 const raw = protocol.readLine(dr.session_reader) catch return error.ReadFailed;
592 if (std.mem.eql(u8, raw, ".")) {
593 dr.finished = true;
594 return error.EndOfStream;
595 }
596 dr.line = if (raw.len > 0 and raw[0] == '.') raw[1..] else raw;
597 dr.line_ending = protocol.crlf;
598 }
599 const dest = limit.slice(try w.writableSliceGreedy(1));
600 const line_n = @min(dest.len, dr.line.len);
601 @memcpy(dest[0..line_n], dr.line[0..line_n]);
602 dr.line = dr.line[line_n..];
603 var n = line_n;
604 if (dr.line.len == 0) {
605 const ending_n = @min(dest.len - n, dr.line_ending.len);
606 @memcpy(dest[n..][0..ending_n], dr.line_ending[0..ending_n]);
607 dr.line_ending = dr.line_ending[ending_n..];
608 n += ending_n;
609 }
610 w.advance(n);
611 return n;
612 }
613};
614
615fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void {
616 try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text });
617 try s.writer.flush();
618}
619
620/// Discards input through the next newline after `error.LineTooLong`, which
621/// leaves the reader positioned at the start of the oversized line.
622fn discardLine(s: *Server) error{ReadFailed}!void {
623 _ = s.reader.discardDelimiterInclusive('\n') catch |err| switch (err) {
624 error.EndOfStream => {},
625 error.ReadFailed => return error.ReadFailed,
626 };
627}
628
629const TestHandler = struct {
630 from: std.ArrayList(u8) = .empty,
631 recipients: std.ArrayList(u8) = .empty,
632 data: std.ArrayList(u8) = .empty,
633 messages_accepted: usize = 0,
634 reject_recipient: ?[]const u8 = null,
635 declared_size: ?u64 = null,
636 body: Envelope.Body = .unspecified,
637 /// When set, enables the authenticate callback accepting user "alice"
638 /// with this password.
639 password: ?[]const u8 = null,
640
641 fn deinit(h: *TestHandler) void {
642 h.from.deinit(std.testing.allocator);
643 h.recipients.deinit(std.testing.allocator);
644 h.data.deinit(std.testing.allocator);
645 }
646
647 fn handler(h: *TestHandler) Handler {
648 return .{ .context = h, .vtable = if (h.password != null) &.{
649 .authenticate = onAuthenticate,
650 .rcptTo = onRcptTo,
651 .message = onMessage,
652 } else &.{
653 .rcptTo = onRcptTo,
654 .message = onMessage,
655 } };
656 }
657
658 fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool {
659 const h: *TestHandler = @ptrCast(@alignCast(context.?));
660 return std.mem.eql(u8, username, "alice") and
661 std.mem.eql(u8, password, h.password.?);
662 }
663
664 fn onRcptTo(context: ?*anyopaque, to: []const u8) Decision {
665 const h: *TestHandler = @ptrCast(@alignCast(context.?));
666 if (h.reject_recipient) |rejected| {
667 if (std.mem.eql(u8, to, rejected)) return .{ .reject = .{
668 .code = 550,
669 .text = "5.1.1 No such user",
670 } };
671 }
672 return .accept;
673 }
674
675 fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision {
676 const h: *TestHandler = @ptrCast(@alignCast(context.?));
677 const gpa = std.testing.allocator;
678 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} };
679 for (envelope.recipients) |recipient| {
680 h.recipients.appendSlice(gpa, recipient) catch return .{ .reject = .{} };
681 h.recipients.append(gpa, ';') catch return .{ .reject = .{} };
682 }
683 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} };
684 h.messages_accepted += 1;
685 h.declared_size = envelope.declared_size;
686 h.body = envelope.body;
687 return .accept;
688 }
689};
690
691fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 {
692 var reader: Io.Reader = .fixed(input);
693 var writer: Io.Writer = .fixed(out_buf);
694 var session: Server = .init(&reader, &writer, handler, options);
695 try session.run(std.testing.allocator);
696 return writer.buffered();
697}
698
699test run {
700 var h: TestHandler = .{};
701 defer h.deinit();
702
703 var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++
704 "MAIL FROM:<alice@example.com>\r\n" ++
705 "RCPT TO:<bob@example.net>\r\n" ++
706 "RCPT TO:<carol@example.net>\r\n" ++
707 "DATA\r\n" ++
708 "Subject: hi\r\n" ++
709 "\r\n" ++
710 "..stuffed line\r\n" ++
711 "body\r\n" ++
712 ".\r\n" ++
713 "QUIT\r\n");
714 var out_buf: [1024]u8 = undefined;
715 var writer: Io.Writer = .fixed(&out_buf);
716
717 var session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" });
718 try session.run(std.testing.allocator);
719 const output = writer.buffered();
720
721 try std.testing.expectEqualStrings("alice@example.com", h.from.items);
722 try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items);
723 try std.testing.expectEqualStrings("Subject: hi\r\n\r\n.stuffed line\r\nbody\r\n", h.data.items);
724 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
725
726 try std.testing.expectEqualStrings(
727 "220 mx.test ESMTP ready\r\n" ++
728 "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 16777216\r\n" ++
729 "250 2.1.0 Ok\r\n" ++
730 "250 2.1.5 Ok\r\n" ++
731 "250 2.1.5 Ok\r\n" ++
732 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
733 "250 2.0.0 Ok, message accepted\r\n" ++
734 "221 2.0.0 Bye\r\n",
735 output,
736 );
737}
738
739test "command sequencing is enforced" {
740 var h: TestHandler = .{};
741 defer h.deinit();
742
743 var out_buf: [1024]u8 = undefined;
744 const output = try runScript(
745 "MAIL FROM:<early@example.com>\r\n" ++
746 "EHLO client.example.org\r\n" ++
747 "RCPT TO:<bob@example.net>\r\n" ++
748 "DATA\r\n" ++
749 "QUIT\r\n",
750 &out_buf,
751 h.handler(),
752 .{},
753 );
754
755 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
756 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Send EHLO first") != null);
757 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null);
758 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null);
759}
760
761test "handler can reject a recipient" {
762 var h: TestHandler = .{ .reject_recipient = "nobody@example.net" };
763 defer h.deinit();
764
765 var out_buf: [1024]u8 = undefined;
766 const output = try runScript(
767 "EHLO client.example.org\r\n" ++
768 "MAIL FROM:<alice@example.com>\r\n" ++
769 "RCPT TO:<nobody@example.net>\r\n" ++
770 "RCPT TO:<bob@example.net>\r\n" ++
771 "DATA\r\n" ++
772 "hello\r\n" ++
773 ".\r\n" ++
774 "QUIT\r\n",
775 &out_buf,
776 h.handler(),
777 .{},
778 );
779
780 try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null);
781 try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items);
782 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
783}
784
785test "AUTH PLAIN with initial response" {
786 var h: TestHandler = .{ .password = "secret" };
787 defer h.deinit();
788
789 var out_buf: [1024]u8 = undefined;
790 // base64("\x00alice\x00secret")
791 const output = try runScript(
792 "EHLO client.example.org\r\n" ++
793 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++
794 "MAIL FROM:<alice@example.com>\r\n" ++
795 "RCPT TO:<bob@example.net>\r\n" ++
796 "DATA\r\nauthed mail\r\n.\r\n" ++
797 "QUIT\r\n",
798 &out_buf,
799 h.handler(),
800 .{ .require_auth = true },
801 );
802
803 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null);
804 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
805 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
806}
807
808test "AUTH LOGIN challenge exchange" {
809 var h: TestHandler = .{ .password = "secret" };
810 defer h.deinit();
811
812 var out_buf: [1024]u8 = undefined;
813 // base64("alice"), base64("secret")
814 const output = try runScript(
815 "EHLO client.example.org\r\n" ++
816 "AUTH LOGIN\r\n" ++
817 "YWxpY2U=\r\n" ++
818 "c2VjcmV0\r\n" ++
819 "QUIT\r\n",
820 &out_buf,
821 h.handler(),
822 .{},
823 );
824
825 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null);
826 try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null);
827 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
828}
829
830test "AUTH failures and sequencing" {
831 var h: TestHandler = .{ .password = "secret" };
832 defer h.deinit();
833
834 var out_buf: [2048]u8 = undefined;
835 const output = try runScript(
836 "EHLO client.example.org\r\n" ++
837 "MAIL FROM:<alice@example.com>\r\n" ++ // before auth: 530
838 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // wrong password: 535
839 "AUTH GSSAPI\r\n" ++ // unsupported: 504
840 "AUTH PLAIN not!base64\r\n" ++ // 501
841 "AUTH LOGIN\r\n" ++
842 "*\r\n" ++ // cancelled: 501
843 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // correct: 235
844 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // again: 503
845 "QUIT\r\n",
846 &out_buf,
847 h.handler(),
848 .{ .require_auth = true },
849 );
850
851 try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null);
852 try std.testing.expect(std.mem.indexOf(u8, output, "535 5.7.8") != null);
853 try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null);
854 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid base64") != null);
855 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.7.0 Authentication cancelled") != null);
856 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
857 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Already authenticated") != null);
858}
859
860test "AUTH without a handler is refused" {
861 var h: TestHandler = .{};
862 defer h.deinit();
863
864 var out_buf: [1024]u8 = undefined;
865 const output = try runScript(
866 "EHLO client.example.org\r\nAUTH PLAIN AGEAYg==\r\nQUIT\r\n",
867 &out_buf,
868 h.handler(),
869 .{},
870 );
871
872 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH") == null);
873 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null);
874}
875
876test "oversize message is rejected but session continues" {
877 var h: TestHandler = .{};
878 defer h.deinit();
879
880 var out_buf: [1024]u8 = undefined;
881 const output = try runScript(
882 "EHLO client.example.org\r\n" ++
883 "MAIL FROM:<alice@example.com>\r\n" ++
884 "RCPT TO:<bob@example.net>\r\n" ++
885 "DATA\r\n" ++
886 "0123456789012345678901234567890123456789\r\n" ++
887 ".\r\n" ++
888 "NOOP\r\n" ++
889 "QUIT\r\n",
890 &out_buf,
891 h.handler(),
892 .{ .max_message_size = 16 },
893 );
894
895 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
896 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null);
897 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null);
898}
899
900const StreamTestHandler = struct {
901 collected: std.ArrayList(u8) = .empty,
902 take_only: ?usize = null,
903
904 fn handler(h: *StreamTestHandler) Handler {
905 return .{ .context = h, .vtable = &.{
906 .messageReader = onMessageReader,
907 } };
908 }
909
910 fn onMessageReader(context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision {
911 const h: *StreamTestHandler = @ptrCast(@alignCast(context.?));
912 _ = envelope;
913 const gpa = std.testing.allocator;
914 if (h.take_only) |n| {
915 const bytes = message.take(n) catch return .{ .reject = .{} };
916 h.collected.appendSlice(gpa, bytes) catch return .{ .reject = .{} };
917 return .accept;
918 }
919 message.appendRemaining(gpa, &h.collected, .unlimited) catch return .{ .reject = .{} };
920 return .accept;
921 }
922};
923
924test "streaming message handler receives unstuffed content" {
925 var h: StreamTestHandler = .{};
926 defer h.collected.deinit(std.testing.allocator);
927
928 var out_buf: [1024]u8 = undefined;
929 const output = try runScript(
930 "EHLO client.example.org\r\n" ++
931 "MAIL FROM:<alice@example.com>\r\n" ++
932 "RCPT TO:<bob@example.net>\r\n" ++
933 "DATA\r\n" ++
934 "Subject: streamed\r\n" ++
935 "\r\n" ++
936 "..dot line\r\n" ++
937 "body\r\n" ++
938 ".\r\n" ++
939 "QUIT\r\n",
940 &out_buf,
941 h.handler(),
942 .{},
943 );
944
945 try std.testing.expectEqualStrings(
946 "Subject: streamed\r\n\r\n.dot line\r\nbody\r\n",
947 h.collected.items,
948 );
949 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null);
950}
951
952test "session drains what a streaming handler leaves unread" {
953 var h: StreamTestHandler = .{ .take_only = 7 };
954 defer h.collected.deinit(std.testing.allocator);
955
956 var out_buf: [1024]u8 = undefined;
957 const output = try runScript(
958 "EHLO client.example.org\r\n" ++
959 "MAIL FROM:<alice@example.com>\r\n" ++
960 "RCPT TO:<bob@example.net>\r\n" ++
961 "DATA\r\n" ++
962 "Subject: mostly unread\r\n" ++
963 "lots of body\r\n" ++
964 ".\r\n" ++
965 "NOOP\r\n" ++
966 "QUIT\r\n",
967 &out_buf,
968 h.handler(),
969 .{},
970 );
971
972 try std.testing.expectEqualStrings("Subject", h.collected.items);
973 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null);
974 // The NOOP after DATA proves the terminator was consumed.
975 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null);
976}
977
978test "fuzz session with arbitrary client input" {
979 try std.testing.fuzz({}, fuzzSession, .{});
980}
981
982fn fuzzSession(context: void, smith: *std.testing.Smith) !void {
983 _ = context;
984 var input_buf: [2048]u8 = undefined;
985 const input = input_buf[0..smith.value(u11)];
986 smith.bytes(input);
987
988 var h: TestHandler = .{ .password = "secret" };
989 defer h.deinit();
990
991 var reader: Io.Reader = .fixed(input);
992 var discarding: Io.Writer.Discarding = .init(&.{});
993 var session: Server = .init(&reader, &discarding.writer, h.handler(), .{
994 .max_message_size = 512,
995 .max_recipients = 4,
996 });
997 // Whatever the "client" sends, the session must fail cleanly, never crash.
998 session.run(std.testing.allocator) catch {};
999}
1000
1001test "fuzz collecting and streaming DATA agree" {
1002 try std.testing.fuzz({}, fuzzDataEquivalence, .{});
1003}
1004
1005fn fuzzDataEquivalence(context: void, smith: *std.testing.Smith) !void {
1006 _ = context;
1007 var body_buf: [1024]u8 = undefined;
1008 const body = body_buf[0..smith.value(u10)];
1009 smith.bytes(body);
1010
1011 var script_buf: [1200]u8 = undefined;
1012 const script = std.fmt.bufPrint(
1013 &script_buf,
1014 "EHLO fuzz.example.org\r\n" ++
1015 "MAIL FROM:<a@example.com>\r\n" ++
1016 "RCPT TO:<b@example.net>\r\n" ++
1017 "DATA\r\n{s}\r\n.\r\nQUIT\r\n",
1018 .{body},
1019 ) catch unreachable;
1020
1021 var collecting: TestHandler = .{};
1022 defer collecting.deinit();
1023 var out_buf: [4096]u8 = undefined;
1024 _ = runScript(script, &out_buf, collecting.handler(), .{}) catch {};
1025
1026 var streaming: StreamTestHandler = .{};
1027 defer streaming.collected.deinit(std.testing.allocator);
1028 _ = runScript(script, &out_buf, streaming.handler(), .{}) catch {};
1029
1030 try std.testing.expectEqualSlices(u8, collecting.data.items, streaming.collected.items);
1031}
1032
1033test "MAIL parameters SIZE and BODY are honored" {
1034 var h: TestHandler = .{};
1035 defer h.deinit();
1036
1037 var out_buf: [1024]u8 = undefined;
1038 const output = try runScript(
1039 "EHLO client.example.org\r\n" ++
1040 "MAIL FROM:<alice@example.com> SIZE=42 BODY=8BITMIME\r\n" ++
1041 "RCPT TO:<bob@example.net>\r\n" ++
1042 "DATA\r\nsized body\r\n.\r\n" ++
1043 "QUIT\r\n",
1044 &out_buf,
1045 h.handler(),
1046 .{ .max_message_size = 1024 },
1047 );
1048
1049 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
1050 try std.testing.expectEqual(@as(?u64, 42), h.declared_size);
1051 try std.testing.expectEqual(Envelope.Body.eight_bit_mime, h.body);
1052 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null);
1053}
1054
1055test "invalid MAIL and RCPT parameters are rejected" {
1056 var h: TestHandler = .{};
1057 defer h.deinit();
1058
1059 var out_buf: [2048]u8 = undefined;
1060 const output = try runScript(
1061 "EHLO client.example.org\r\n" ++
1062 "MAIL FROM:<a@example.com> SIZE=9999\r\n" ++ // over the maximum: 552
1063 "RCPT TO:<b@example.net>\r\n" ++ // that MAIL never started: 503
1064 "MAIL FROM:<a@example.com> SIZE=banana\r\n" ++ // 501
1065 "MAIL FROM:<a@example.com> BODY=BINARYMIME\r\n" ++ // 555
1066 "MAIL FROM:<a@example.com> FUTURE=yes\r\n" ++ // 555
1067 "MAIL FROM:<a@example.com> BODY=7bit\r\n" ++ // ok
1068 "RCPT TO:<b@example.net> NOTIFY=SUCCESS\r\n" ++ // no RCPT params: 555
1069 "RCPT TO:<b@example.net>\r\n" ++
1070 "DATA\r\nbody\r\n.\r\nQUIT\r\n",
1071 &out_buf,
1072 h.handler(),
1073 .{ .max_message_size = 1024 },
1074 );
1075
1076 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null);
1077 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null);
1078 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid SIZE parameter") != null);
1079 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unsupported BODY value") != null);
1080 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null);
1081 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
1082 try std.testing.expectEqual(Envelope.Body.seven_bit, h.body);
1083 try std.testing.expectEqual(@as(?u64, null), h.declared_size);
1084}
1085
1086test init {
1087 var reader: Io.Reader = .fixed("");
1088 var out_buf: [16]u8 = undefined;
1089 var writer: Io.Writer = .fixed(&out_buf);
1090 var h: TestHandler = .{};
1091 const session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" });
1092 try std.testing.expectEqualStrings("mx.test", session.options.hostname);
1093 try std.testing.expect(!session.secured);
1094}
1095
1096test Options {
1097 const options: Options = .{};
1098 try std.testing.expectEqualStrings("localhost", options.hostname);
1099 try std.testing.expect(options.tls == null);
1100 try std.testing.expect(!options.require_auth);
1101}
1102
1103test Decision {
1104 const ok: Decision = .accept;
1105 try std.testing.expectEqual(Decision.accept, ok);
1106
1107 const no: Decision = .{ .reject = .{ .code = 451, .text = "4.3.0 Try again later" } };
1108 try std.testing.expectEqual(@as(u16, 451), no.reject.code);
1109}
1110
1111test Envelope {
1112 const envelope: Envelope = .{ .from = "", .recipients = &.{"a@example.com"} };
1113 try std.testing.expectEqual(@as(usize, 1), envelope.recipients.len);
1114 try std.testing.expectEqual(@as(?u64, null), envelope.declared_size);
1115 try std.testing.expectEqual(Envelope.Body.unspecified, envelope.body);
1116}
1117
1118test Handler {
1119 const Callbacks = struct {
1120 fn onMessage(context: ?*anyopaque, envelope: Envelope, message_data: []const u8) Decision {
1121 _ = context;
1122 _ = envelope;
1123 _ = message_data;
1124 return .accept;
1125 }
1126 };
1127 const handler: Handler = .{ .vtable = &.{ .message = Callbacks.onMessage } };
1128 const envelope: Envelope = .{ .from = "", .recipients = &.{} };
1129 try std.testing.expectEqual(Decision.accept, handler.vtable.message.?(null, envelope, ""));
1130}