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