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.
94 message: *const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision,
95 };
96};
97
98pub fn init(reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options) Server {
99 return .{ .reader = reader, .writer = writer, .handler = handler, .options = options };
100}
101
102pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory, TlsHandshakeFailed };
103
104/// Serves the session until the client sends QUIT or disconnects. `gpa`
105/// backs per-transaction storage (envelope and message data); everything is
106/// freed on return.
107pub fn run(s: *Server, gpa: std.mem.Allocator) RunError!void {
108 var arena_state: std.heap.ArenaAllocator = .init(gpa);
109 defer arena_state.deinit();
110 const arena = arena_state.allocator();
111
112 std.debug.assert(!s.options.require_auth or s.handler.vtable.authenticate != null);
113
114 var greeted = false;
115 var authenticated = false;
116 var from: ?[]const u8 = null;
117 var recipients: std.ArrayList([]const u8) = .empty;
118
119 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname});
120 try s.writer.flush();
121
122 while (true) {
123 const line = protocol.readLine(s.reader) catch |err| switch (err) {
124 error.EndOfStream => return, // Client disconnected.
125 error.ReadFailed => return error.ReadFailed,
126 error.LineTooLong => {
127 try s.discardLine();
128 try s.reply(500, "5.5.2 Line too long");
129 continue;
130 },
131 };
132 const command = protocol.Command.parse(line) catch {
133 try s.reply(501, "5.5.4 Syntax error in parameters");
134 continue;
135 };
136 switch (command) {
137 .helo => {
138 greeted = true;
139 from = null;
140 recipients = .empty;
141 _ = arena_state.reset(.retain_capacity);
142 try s.reply(250, s.options.hostname);
143 },
144 .ehlo => {
145 greeted = true;
146 from = null;
147 recipients = .empty;
148 _ = arena_state.reset(.retain_capacity);
149 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n", .{s.options.hostname});
150 if (s.options.starttls != null and !s.secured)
151 try s.writer.writeAll("250-STARTTLS\r\n");
152 if (s.handler.vtable.authenticate != null and !authenticated)
153 try s.writer.writeAll("250-AUTH PLAIN LOGIN\r\n");
154 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size});
155 try s.writer.flush();
156 },
157 .mail => |args| {
158 if (!greeted) {
159 try s.reply(503, "5.5.1 Send EHLO first");
160 continue;
161 }
162 if (s.options.require_auth and !authenticated) {
163 try s.reply(530, "5.7.0 Authentication required");
164 continue;
165 }
166 if (from != null) {
167 try s.reply(503, "5.5.1 Nested MAIL command");
168 continue;
169 }
170 if (s.handler.vtable.mailFrom) |callback| {
171 switch (callback(s.handler.context, args.path)) {
172 .accept => {},
173 .reject => |r| {
174 try s.reply(r.code, r.text);
175 continue;
176 },
177 }
178 }
179 from = try arena.dupe(u8, args.path);
180 try s.reply(250, "2.1.0 Ok");
181 },
182 .rcpt => |args| {
183 if (from == null) {
184 try s.reply(503, "5.5.1 Need MAIL command first");
185 continue;
186 }
187 if (recipients.items.len >= s.options.max_recipients) {
188 try s.reply(452, "4.5.3 Too many recipients");
189 continue;
190 }
191 if (s.handler.vtable.rcptTo) |callback| {
192 switch (callback(s.handler.context, args.path)) {
193 .accept => {},
194 .reject => |r| {
195 try s.reply(r.code, r.text);
196 continue;
197 },
198 }
199 }
200 try recipients.append(arena, try arena.dupe(u8, args.path));
201 try s.reply(250, "2.1.5 Ok");
202 },
203 .data => {
204 if (recipients.items.len == 0) {
205 try s.reply(503, "5.5.1 Need RCPT command first");
206 continue;
207 }
208 try s.receiveData(arena, .{
209 .from = from.?,
210 .recipients = recipients.items,
211 });
212 from = null;
213 recipients = .empty;
214 _ = arena_state.reset(.retain_capacity);
215 },
216 .rset => {
217 from = null;
218 recipients = .empty;
219 _ = arena_state.reset(.retain_capacity);
220 try s.reply(250, "2.0.0 Ok");
221 },
222 .noop => try s.reply(250, "2.0.0 Ok"),
223 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"),
224 .help => try s.reply(214, "2.0.0 See RFC 5321"),
225 .starttls => {
226 const config = s.options.starttls orelse {
227 try s.reply(502, "5.5.1 STARTTLS not supported");
228 continue;
229 };
230 if (s.secured) {
231 try s.reply(503, "5.5.1 TLS already active");
232 continue;
233 }
234 try s.reply(220, "2.0.0 Ready to start TLS");
235 var rng_source: std.Random.IoSource = .{ .io = config.io };
236 s.tls_connection = tls.server(s.reader, s.writer, .{
237 .auth = config.auth,
238 .rng = rng_source.interface(),
239 .now = Io.Clock.real.now(config.io),
240 }) catch return error.TlsHandshakeFailed;
241 s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer);
242 s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer);
243 s.reader = &s.tls_reader.interface;
244 s.writer = &s.tls_writer.interface;
245 s.secured = true;
246 // RFC 3207 §4.2: both sides return to their initial state;
247 // the client must EHLO again.
248 greeted = false;
249 authenticated = false;
250 from = null;
251 recipients = .empty;
252 _ = arena_state.reset(.retain_capacity);
253 },
254 .quit => {
255 try s.reply(221, "2.0.0 Bye");
256 if (s.secured) s.tls_connection.close() catch {};
257 return;
258 },
259 .auth => |args| {
260 if (s.handler.vtable.authenticate == null) {
261 try s.reply(503, "5.5.1 Authentication not enabled");
262 continue;
263 }
264 if (!greeted) {
265 try s.reply(503, "5.5.1 Send EHLO first");
266 continue;
267 }
268 if (authenticated) {
269 try s.reply(503, "5.5.1 Already authenticated");
270 continue;
271 }
272 if (from != null) {
273 try s.reply(503, "5.5.1 MAIL transaction in progress");
274 continue;
275 }
276 switch (try s.receiveAuth(args)) {
277 .authenticated => authenticated = true,
278 .rejected => {},
279 .disconnected => return,
280 }
281 },
282 .unknown => try s.reply(500, "5.5.2 Command not recognized"),
283 }
284 }
285}
286
287const AuthOutcome = enum { authenticated, rejected, disconnected };
288
289/// Runs the challenge/response exchange for AUTH PLAIN or AUTH LOGIN
290/// (RFC 4954) and consults the handler's `authenticate` callback. Every
291/// outcome except `disconnected` has already sent its reply.
292fn receiveAuth(s: *Server, args: protocol.Command.AuthArgs) RunError!AuthOutcome {
293 const callback = s.handler.vtable.authenticate.?;
294
295 if (std.ascii.eqlIgnoreCase(args.mechanism, "PLAIN")) {
296 var decoded_buf: [576]u8 = undefined;
297 var response: []const u8 = args.initial;
298 if (response.len == 0) {
299 try s.reply(334, "");
300 response = switch (try s.takeAuthLine()) {
301 .line => |line| line,
302 .cancelled => return .rejected,
303 .disconnected => return .disconnected,
304 };
305 }
306 const decoded = decodeBase64(&decoded_buf, response) orelse {
307 try s.reply(501, "5.5.2 Invalid base64");
308 return .rejected;
309 };
310 // authzid NUL authcid NUL password; the authzid is ignored.
311 const first_nul = std.mem.indexOfScalar(u8, decoded, 0) orelse {
312 try s.reply(501, "5.5.2 Malformed PLAIN response");
313 return .rejected;
314 };
315 const after_authzid = decoded[first_nul + 1 ..];
316 const second_nul = std.mem.indexOfScalar(u8, after_authzid, 0) orelse {
317 try s.reply(501, "5.5.2 Malformed PLAIN response");
318 return .rejected;
319 };
320 return s.finishAuth(callback, after_authzid[0..second_nul], after_authzid[second_nul + 1 ..]);
321 }
322
323 if (std.ascii.eqlIgnoreCase(args.mechanism, "LOGIN")) {
324 var user_buf: [192]u8 = undefined;
325 var pass_buf: [192]u8 = undefined;
326
327 var username: []const u8 = undefined;
328 if (args.initial.len > 0) {
329 // Some clients send the username as an initial response.
330 username = decodeBase64(&user_buf, args.initial) orelse {
331 try s.reply(501, "5.5.2 Invalid base64");
332 return .rejected;
333 };
334 } else {
335 try s.reply(334, "VXNlcm5hbWU6"); // base64("Username:")
336 const line = switch (try s.takeAuthLine()) {
337 .line => |line| line,
338 .cancelled => return .rejected,
339 .disconnected => return .disconnected,
340 };
341 username = decodeBase64(&user_buf, line) orelse {
342 try s.reply(501, "5.5.2 Invalid base64");
343 return .rejected;
344 };
345 }
346 try s.reply(334, "UGFzc3dvcmQ6"); // base64("Password:")
347 const line = switch (try s.takeAuthLine()) {
348 .line => |line| line,
349 .cancelled => return .rejected,
350 .disconnected => return .disconnected,
351 };
352 const password = decodeBase64(&pass_buf, line) orelse {
353 try s.reply(501, "5.5.2 Invalid base64");
354 return .rejected;
355 };
356 return s.finishAuth(callback, username, password);
357 }
358
359 try s.reply(504, "5.5.4 Unrecognized authentication type");
360 return .rejected;
361}
362
363fn finishAuth(
364 s: *Server,
365 callback: *const fn (?*anyopaque, []const u8, []const u8) bool,
366 username: []const u8,
367 password: []const u8,
368) RunError!AuthOutcome {
369 if (callback(s.handler.context, username, password)) {
370 try s.reply(235, "2.7.0 Authentication successful");
371 return .authenticated;
372 }
373 try s.reply(535, "5.7.8 Authentication credentials invalid");
374 return .rejected;
375}
376
377const AuthLine = union(enum) { line: []u8, cancelled, disconnected };
378
379/// Reads one continuation line of an AUTH exchange. `cancelled` covers both
380/// an explicit "*" and an overlong line; its reply has already been sent.
381fn takeAuthLine(s: *Server) RunError!AuthLine {
382 const line = protocol.readLine(s.reader) catch |err| switch (err) {
383 error.EndOfStream => return .disconnected,
384 error.ReadFailed => return error.ReadFailed,
385 error.LineTooLong => {
386 try s.discardLine();
387 try s.reply(501, "5.5.2 Response too long");
388 return .cancelled;
389 },
390 };
391 if (std.mem.eql(u8, line, "*")) {
392 try s.reply(501, "5.7.0 Authentication cancelled");
393 return .cancelled;
394 }
395 return .{ .line = line };
396}
397
398/// Decodes a base64 AUTH argument; "=" denotes an empty response.
399fn decodeBase64(out: []u8, encoded: []const u8) ?[]u8 {
400 if (std.mem.eql(u8, encoded, "=")) return out[0..0];
401 const len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return null;
402 if (len > out.len) return null;
403 std.base64.standard.Decoder.decode(out[0..len], encoded) catch return null;
404 return out[0..len];
405}
406
407/// Reads message content after DATA up to the terminating ".\r\n",
408/// un-stuffing dots, then asks the handler to accept or reject.
409fn receiveData(s: *Server, arena: std.mem.Allocator, envelope: Envelope) RunError!void {
410 try s.reply(354, "End data with <CR><LF>.<CR><LF>");
411 var data: std.ArrayList(u8) = .empty;
412 var oversize = false;
413 while (true) {
414 const line = protocol.readLine(s.reader) catch |err| switch (err) {
415 error.EndOfStream => return, // Client disconnected mid-message.
416 error.ReadFailed => return error.ReadFailed,
417 error.LineTooLong => {
418 // Longer than our reader buffer; RFC 5321 caps text lines at
419 // 1000 octets, so treat it as oversize but keep scanning for
420 // the terminator.
421 try s.discardLine();
422 oversize = true;
423 continue;
424 },
425 };
426 if (std.mem.eql(u8, line, ".")) break;
427 const content = if (line.len > 0 and line[0] == '.') line[1..] else line;
428 if (oversize) continue;
429 if (data.items.len + content.len + protocol.crlf.len > s.options.max_message_size) {
430 oversize = true;
431 continue;
432 }
433 try data.appendSlice(arena, content);
434 try data.appendSlice(arena, protocol.crlf);
435 }
436 if (oversize) {
437 try s.reply(552, "5.3.4 Message exceeds maximum size");
438 return;
439 }
440 switch (s.handler.vtable.message(s.handler.context, envelope, data.items)) {
441 .accept => try s.reply(250, "2.0.0 Ok, message accepted"),
442 .reject => |r| try s.reply(r.code, r.text),
443 }
444}
445
446fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void {
447 try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text });
448 try s.writer.flush();
449}
450
451/// Discards input through the next newline after `error.LineTooLong`, which
452/// leaves the reader positioned at the start of the oversized line.
453fn discardLine(s: *Server) error{ReadFailed}!void {
454 _ = s.reader.discardDelimiterInclusive('\n') catch |err| switch (err) {
455 error.EndOfStream => {},
456 error.ReadFailed => return error.ReadFailed,
457 };
458}
459
460const TestHandler = struct {
461 from: std.ArrayList(u8) = .empty,
462 recipients: std.ArrayList(u8) = .empty,
463 data: std.ArrayList(u8) = .empty,
464 messages_accepted: usize = 0,
465 reject_recipient: ?[]const u8 = null,
466 /// When set, enables the authenticate callback accepting user "alice"
467 /// with this password.
468 password: ?[]const u8 = null,
469
470 fn deinit(h: *TestHandler) void {
471 h.from.deinit(std.testing.allocator);
472 h.recipients.deinit(std.testing.allocator);
473 h.data.deinit(std.testing.allocator);
474 }
475
476 fn handler(h: *TestHandler) Handler {
477 return .{ .context = h, .vtable = if (h.password != null) &.{
478 .authenticate = onAuthenticate,
479 .rcptTo = onRcptTo,
480 .message = onMessage,
481 } else &.{
482 .rcptTo = onRcptTo,
483 .message = onMessage,
484 } };
485 }
486
487 fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool {
488 const h: *TestHandler = @ptrCast(@alignCast(context.?));
489 return std.mem.eql(u8, username, "alice") and
490 std.mem.eql(u8, password, h.password.?);
491 }
492
493 fn onRcptTo(context: ?*anyopaque, to: []const u8) Decision {
494 const h: *TestHandler = @ptrCast(@alignCast(context.?));
495 if (h.reject_recipient) |rejected| {
496 if (std.mem.eql(u8, to, rejected)) return .{ .reject = .{
497 .code = 550,
498 .text = "5.1.1 No such user",
499 } };
500 }
501 return .accept;
502 }
503
504 fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision {
505 const h: *TestHandler = @ptrCast(@alignCast(context.?));
506 const gpa = std.testing.allocator;
507 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} };
508 for (envelope.recipients) |recipient| {
509 h.recipients.appendSlice(gpa, recipient) catch return .{ .reject = .{} };
510 h.recipients.append(gpa, ';') catch return .{ .reject = .{} };
511 }
512 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} };
513 h.messages_accepted += 1;
514 return .accept;
515 }
516};
517
518fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 {
519 var reader: Io.Reader = .fixed(input);
520 var writer: Io.Writer = .fixed(out_buf);
521 var session: Server = .init(&reader, &writer, handler, options);
522 try session.run(std.testing.allocator);
523 return writer.buffered();
524}
525
526test "complete session" {
527 var h: TestHandler = .{};
528 defer h.deinit();
529
530 var out_buf: [1024]u8 = undefined;
531 const output = try runScript(
532 "EHLO client.example.org\r\n" ++
533 "MAIL FROM:<alice@example.com>\r\n" ++
534 "RCPT TO:<bob@example.net>\r\n" ++
535 "RCPT TO:<carol@example.net>\r\n" ++
536 "DATA\r\n" ++
537 "Subject: hi\r\n" ++
538 "\r\n" ++
539 "..stuffed line\r\n" ++
540 "body\r\n" ++
541 ".\r\n" ++
542 "QUIT\r\n",
543 &out_buf,
544 h.handler(),
545 .{ .hostname = "mx.test" },
546 );
547
548 try std.testing.expectEqualStrings("alice@example.com", h.from.items);
549 try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items);
550 try std.testing.expectEqualStrings("Subject: hi\r\n\r\n.stuffed line\r\nbody\r\n", h.data.items);
551 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
552
553 try std.testing.expectEqualStrings(
554 "220 mx.test ESMTP ready\r\n" ++
555 "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 16777216\r\n" ++
556 "250 2.1.0 Ok\r\n" ++
557 "250 2.1.5 Ok\r\n" ++
558 "250 2.1.5 Ok\r\n" ++
559 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
560 "250 2.0.0 Ok, message accepted\r\n" ++
561 "221 2.0.0 Bye\r\n",
562 output,
563 );
564}
565
566test "command sequencing is enforced" {
567 var h: TestHandler = .{};
568 defer h.deinit();
569
570 var out_buf: [1024]u8 = undefined;
571 const output = try runScript(
572 "MAIL FROM:<early@example.com>\r\n" ++
573 "EHLO client.example.org\r\n" ++
574 "RCPT TO:<bob@example.net>\r\n" ++
575 "DATA\r\n" ++
576 "QUIT\r\n",
577 &out_buf,
578 h.handler(),
579 .{},
580 );
581
582 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
583 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Send EHLO first") != null);
584 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null);
585 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null);
586}
587
588test "handler can reject a recipient" {
589 var h: TestHandler = .{ .reject_recipient = "nobody@example.net" };
590 defer h.deinit();
591
592 var out_buf: [1024]u8 = undefined;
593 const output = try runScript(
594 "EHLO client.example.org\r\n" ++
595 "MAIL FROM:<alice@example.com>\r\n" ++
596 "RCPT TO:<nobody@example.net>\r\n" ++
597 "RCPT TO:<bob@example.net>\r\n" ++
598 "DATA\r\n" ++
599 "hello\r\n" ++
600 ".\r\n" ++
601 "QUIT\r\n",
602 &out_buf,
603 h.handler(),
604 .{},
605 );
606
607 try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null);
608 try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items);
609 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
610}
611
612test "AUTH PLAIN with initial response" {
613 var h: TestHandler = .{ .password = "secret" };
614 defer h.deinit();
615
616 var out_buf: [1024]u8 = undefined;
617 // base64("\x00alice\x00secret")
618 const output = try runScript(
619 "EHLO client.example.org\r\n" ++
620 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++
621 "MAIL FROM:<alice@example.com>\r\n" ++
622 "RCPT TO:<bob@example.net>\r\n" ++
623 "DATA\r\nauthed mail\r\n.\r\n" ++
624 "QUIT\r\n",
625 &out_buf,
626 h.handler(),
627 .{ .require_auth = true },
628 );
629
630 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null);
631 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
632 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
633}
634
635test "AUTH LOGIN challenge exchange" {
636 var h: TestHandler = .{ .password = "secret" };
637 defer h.deinit();
638
639 var out_buf: [1024]u8 = undefined;
640 // base64("alice"), base64("secret")
641 const output = try runScript(
642 "EHLO client.example.org\r\n" ++
643 "AUTH LOGIN\r\n" ++
644 "YWxpY2U=\r\n" ++
645 "c2VjcmV0\r\n" ++
646 "QUIT\r\n",
647 &out_buf,
648 h.handler(),
649 .{},
650 );
651
652 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null);
653 try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null);
654 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
655}
656
657test "AUTH failures and sequencing" {
658 var h: TestHandler = .{ .password = "secret" };
659 defer h.deinit();
660
661 var out_buf: [2048]u8 = undefined;
662 const output = try runScript(
663 "EHLO client.example.org\r\n" ++
664 "MAIL FROM:<alice@example.com>\r\n" ++ // before auth: 530
665 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // wrong password: 535
666 "AUTH GSSAPI\r\n" ++ // unsupported: 504
667 "AUTH PLAIN not!base64\r\n" ++ // 501
668 "AUTH LOGIN\r\n" ++
669 "*\r\n" ++ // cancelled: 501
670 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // correct: 235
671 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // again: 503
672 "QUIT\r\n",
673 &out_buf,
674 h.handler(),
675 .{ .require_auth = true },
676 );
677
678 try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null);
679 try std.testing.expect(std.mem.indexOf(u8, output, "535 5.7.8") != null);
680 try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null);
681 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid base64") != null);
682 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.7.0 Authentication cancelled") != null);
683 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
684 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Already authenticated") != null);
685}
686
687test "AUTH without a handler is refused" {
688 var h: TestHandler = .{};
689 defer h.deinit();
690
691 var out_buf: [1024]u8 = undefined;
692 const output = try runScript(
693 "EHLO client.example.org\r\nAUTH PLAIN AGEAYg==\r\nQUIT\r\n",
694 &out_buf,
695 h.handler(),
696 .{},
697 );
698
699 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH") == null);
700 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null);
701}
702
703test "oversize message is rejected but session continues" {
704 var h: TestHandler = .{};
705 defer h.deinit();
706
707 var out_buf: [1024]u8 = undefined;
708 const output = try runScript(
709 "EHLO client.example.org\r\n" ++
710 "MAIL FROM:<alice@example.com>\r\n" ++
711 "RCPT TO:<bob@example.net>\r\n" ++
712 "DATA\r\n" ++
713 "0123456789012345678901234567890123456789\r\n" ++
714 ".\r\n" ++
715 "NOOP\r\n" ++
716 "QUIT\r\n",
717 &out_buf,
718 h.handler(),
719 .{ .max_message_size = 16 },
720 );
721
722 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
723 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null);
724 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null);
725}