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