An SMTP client and server library for Zig implementing RFC 5321.
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! A single-connection SMTP server session. Like the client, it runs over
5//! any `Io.Reader`/`Io.Writer` pair; accept a TCP connection and hand its
6//! stream reader/writer to `run`. Accepting connections, concurrency, and
7//! message storage are left to the caller — the session just speaks the
8//! protocol and forwards decisions to a `Handler`.
9//!
10//! Typical use:
11//! ```
12//! var session: Server = .init(&stream_reader, &stream_writer, handler, .{
13//! .hostname = "mx.example.com",
14//! });
15//! try session.run(gpa);
16//! ```
17
18const Server = @This();
19
20const std = @import("std");
21const Io = std.Io;
22const tls = @import("tls");
23const protocol = @import("protocol.zig");
24
25reader: *Io.Reader,
26writer: *Io.Writer,
27handler: Handler,
28options: Options,
29/// True once a STARTTLS handshake has completed for this session.
30secured: bool = false,
31tls_connection: tls.Connection = undefined,
32tls_reader: tls.Connection.Reader = undefined,
33tls_writer: tls.Connection.Writer = undefined,
34tls_read_buffer: [4096]u8 = undefined,
35tls_write_buffer: [4096]u8 = undefined,
36
37pub const Options = struct {
38 /// Hostname announced in the greeting and the EHLO response.
39 hostname: []const u8 = "localhost",
40 /// Advertised via the SIZE extension and enforced during DATA.
41 max_message_size: usize = 16 * 1024 * 1024,
42 max_recipients: usize = 100,
43 /// When set, the session speaks TLS (see `TlsOptions.mode`). The
44 /// underlying stream reader/writer handed to `init` must then have
45 /// buffers of at least `tls.input_buffer_len` and
46 /// `tls.output_buffer_len` bytes, since the handshake and TLS records
47 /// run over them.
48 tls: ?TlsOptions = null,
49 /// Reject MAIL with 530 until the client has authenticated. Requires a
50 /// handler with an `authenticate` callback.
51 require_auth: bool = false,
52};
53
54pub const TlsOptions = struct {
55 io: Io,
56 /// Server certificate chain and private key presented to clients.
57 auth: *tls.config.CertKeyPair,
58 mode: Mode = .starttls,
59
60 pub const Mode = enum {
61 /// Advertise and accept the STARTTLS command
62 /// ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)).
63 starttls,
64 /// Perform the TLS handshake before the greeting (implicit TLS /
65 /// SMTPS, port 465 style; [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314)).
66 implicit,
67 };
68};
69
70/// A handler's verdict on an envelope step or a complete message.
71pub const Decision = union(enum) {
72 accept,
73 reject: Rejection,
74
75 pub const Rejection = struct {
76 /// Use 4xx for "try again later", 5xx for permanent rejection.
77 code: u16 = 550,
78 /// By convention prefixed with an enhanced status code
79 /// ([RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463)).
80 text: []const u8 = "5.7.1 Rejected",
81 };
82};
83
84pub const Envelope = struct {
85 /// Empty for the null reverse-path (`MAIL FROM:<>`).
86 from: []const u8,
87 recipients: []const []const u8,
88 /// Value of the MAIL SIZE= parameter
89 /// ([RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870)), if the client
90 /// declared one. Already validated against `Options.max_message_size`.
91 declared_size: ?u64 = null,
92 /// Value of the MAIL BODY= parameter
93 /// ([RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152)).
94 body: Body = .unspecified,
95
96 pub const Body = enum { unspecified, seven_bit, eight_bit_mime };
97};
98
99/// Callbacks invoked during a session. All slices passed to callbacks are
100/// only valid for the duration of the call.
101pub const Handler = struct {
102 context: ?*anyopaque = null,
103 vtable: *const VTable,
104
105 pub const VTable = struct {
106 /// Called for AUTH with the decoded credentials; return true to
107 /// accept. When set, AUTH PLAIN and AUTH LOGIN are advertised and
108 /// accepted ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)).
109 authenticate: ?*const fn (context: ?*anyopaque, username: []const u8, password: []const u8) bool = null,
110 /// Called for MAIL FROM. Null accepts every sender.
111 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null,
112 /// Called for each RCPT TO. Null accepts every recipient.
113 rcptTo: ?*const fn (context: ?*anyopaque, to: []const u8) Decision = null,
114 /// Called once the complete message has been received. The data has
115 /// CRLF line endings and dot-stuffing already removed. Exactly one
116 /// of `message` and `messageReader` must be set.
117 message: ?*const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision = null,
118 /// Streaming alternative to `message`: called after DATA with a
119 /// reader that yields the message content (dot-stuffing removed,
120 /// line endings normalized to CRLF) until end of stream. Anything
121 /// the callback leaves unread is drained by the session, so
122 /// returning early is fine. `Options.max_message_size` is not
123 /// enforced in this mode; individual message lines must fit the
124 /// session's stream reader buffer.
125 messageReader: ?*const fn (context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision = null,
126 };
127};
128
129pub fn init(reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options) Server {
130 return .{ .reader = reader, .writer = writer, .handler = handler, .options = options };
131}
132
133pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory, TlsHandshakeFailed };
134
135/// Serves the session until the client sends QUIT or disconnects. `gpa`
136/// backs per-transaction storage (envelope and message data); everything is
137/// freed on return.
138pub fn run(s: *Server, gpa: std.mem.Allocator) RunError!void {
139 var arena_state: std.heap.ArenaAllocator = .init(gpa);
140 defer arena_state.deinit();
141 const arena = arena_state.allocator();
142
143 std.debug.assert(!s.options.require_auth or s.handler.vtable.authenticate != null);
144 std.debug.assert((s.handler.vtable.message == null) != (s.handler.vtable.messageReader == null));
145
146 if (s.options.tls) |config| {
147 if (config.mode == .implicit and !s.secured) try s.upgradeToTls(config);
148 }
149
150 var greeted = false;
151 var authenticated = false;
152 var from: ?[]const u8 = null;
153 var recipients: std.ArrayList([]const u8) = .empty;
154 var declared_size: ?u64 = null;
155 var body: Envelope.Body = .unspecified;
156
157 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname});
158 try s.writer.flush();
159
160 while (true) {
161 const line = protocol.readLine(s.reader) catch |err| switch (err) {
162 error.EndOfStream => return, // Client disconnected.
163 error.ReadFailed => return error.ReadFailed,
164 error.LineTooLong => {
165 try s.discardLine();
166 try s.reply(500, "5.5.2 Line too long");
167 continue;
168 },
169 };
170 const command = protocol.Command.parse(line) catch {
171 try s.reply(501, "5.5.4 Syntax error in parameters");
172 continue;
173 };
174 switch (command) {
175 .helo => {
176 greeted = true;
177 from = null;
178 recipients = .empty;
179 declared_size = null;
180 body = .unspecified;
181 _ = arena_state.reset(.retain_capacity);
182 try s.reply(250, s.options.hostname);
183 },
184 .ehlo => {
185 greeted = true;
186 from = null;
187 recipients = .empty;
188 declared_size = null;
189 body = .unspecified;
190 _ = arena_state.reset(.retain_capacity);
191 // Every reply carries an enhanced status code (RFC 3463), so
192 // the ENHANCEDSTATUSCODES extension (RFC 2034) is advertised.
193 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-ENHANCEDSTATUSCODES\r\n", .{s.options.hostname});
194 if (s.options.tls) |config| {
195 if (config.mode == .starttls and !s.secured)
196 try s.writer.writeAll("250-STARTTLS\r\n");
197 }
198 if (s.handler.vtable.authenticate != null and !authenticated)
199 try s.writer.writeAll("250-AUTH PLAIN LOGIN\r\n");
200 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size});
201 try s.writer.flush();
202 },
203 .mail => |args| {
204 if (!greeted) {
205 try s.reply(503, "5.5.1 Send EHLO first");
206 continue;
207 }
208 if (s.options.require_auth and !authenticated) {
209 try s.reply(530, "5.7.0 Authentication required");
210 continue;
211 }
212 if (from != null) {
213 try s.reply(503, "5.5.1 Nested MAIL command");
214 continue;
215 }
216 var mail_declared_size: ?u64 = null;
217 var mail_body: Envelope.Body = .unspecified;
218 var params_ok = true;
219 var params = args.paramIterator();
220 while (params.next()) |param| {
221 if (std.ascii.eqlIgnoreCase(param.keyword, "SIZE")) {
222 const size = std.fmt.parseInt(u64, param.value, 10) catch {
223 try s.reply(501, "5.5.2 Invalid SIZE parameter");
224 params_ok = false;
225 break;
226 };
227 if (size > s.options.max_message_size) {
228 try s.reply(552, "5.3.4 Message size exceeds fixed maximum");
229 params_ok = false;
230 break;
231 }
232 mail_declared_size = size;
233 } else if (std.ascii.eqlIgnoreCase(param.keyword, "BODY")) {
234 if (std.ascii.eqlIgnoreCase(param.value, "7BIT")) {
235 mail_body = .seven_bit;
236 } else if (std.ascii.eqlIgnoreCase(param.value, "8BITMIME")) {
237 mail_body = .eight_bit_mime;
238 } else {
239 try s.reply(555, "5.5.4 Unsupported BODY value");
240 params_ok = false;
241 break;
242 }
243 } else {
244 try s.reply(555, "5.5.4 Unrecognized parameter");
245 params_ok = false;
246 break;
247 }
248 }
249 if (!params_ok) continue;
250 if (s.handler.vtable.mailFrom) |callback| {
251 switch (callback(s.handler.context, args.path)) {
252 .accept => {},
253 .reject => |r| {
254 try s.reply(r.code, r.text);
255 continue;
256 },
257 }
258 }
259 from = try arena.dupe(u8, args.path);
260 declared_size = mail_declared_size;
261 body = mail_body;
262 try s.reply(250, "2.1.0 Ok");
263 },
264 .rcpt => |args| {
265 if (from == null) {
266 try s.reply(503, "5.5.1 Need MAIL command first");
267 continue;
268 }
269 if (args.params.len != 0) {
270 try s.reply(555, "5.5.4 Unrecognized parameter");
271 continue;
272 }
273 if (recipients.items.len >= s.options.max_recipients) {
274 try s.reply(452, "4.5.3 Too many recipients");
275 continue;
276 }
277 if (s.handler.vtable.rcptTo) |callback| {
278 switch (callback(s.handler.context, args.path)) {
279 .accept => {},
280 .reject => |r| {
281 try s.reply(r.code, r.text);
282 continue;
283 },
284 }
285 }
286 try recipients.append(arena, try arena.dupe(u8, args.path));
287 try s.reply(250, "2.1.5 Ok");
288 },
289 .data => {
290 if (recipients.items.len == 0) {
291 try s.reply(503, "5.5.1 Need RCPT command first");
292 continue;
293 }
294 try s.receiveData(arena, .{
295 .from = from.?,
296 .recipients = recipients.items,
297 .declared_size = declared_size,
298 .body = body,
299 });
300 from = null;
301 recipients = .empty;
302 declared_size = null;
303 body = .unspecified;
304 _ = arena_state.reset(.retain_capacity);
305 },
306 .bdat => |args| {
307 if (recipients.items.len == 0) {
308 // The chunk's octets follow regardless; consume them to
309 // keep the length-framed stream in sync.
310 s.reader.discardAll64(args.size) catch |err| switch (err) {
311 error.EndOfStream => return,
312 error.ReadFailed => return error.ReadFailed,
313 };
314 try s.reply(503, "5.5.1 Need RCPT command first");
315 continue;
316 }
317 const outcome = try s.receiveChunked(arena, .{
318 .from = from.?,
319 .recipients = recipients.items,
320 .declared_size = declared_size,
321 .body = body,
322 }, args);
323 from = null;
324 recipients = .empty;
325 declared_size = null;
326 body = .unspecified;
327 _ = arena_state.reset(.retain_capacity);
328 switch (outcome) {
329 .done => {},
330 .end_session => return,
331 }
332 },
333 .rset => {
334 from = null;
335 recipients = .empty;
336 declared_size = null;
337 body = .unspecified;
338 _ = arena_state.reset(.retain_capacity);
339 try s.reply(250, "2.0.0 Ok");
340 },
341 .noop => try s.reply(250, "2.0.0 Ok"),
342 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"),
343 .help => try s.reply(214, "2.0.0 See RFC 5321"),
344 .starttls => {
345 const config = s.options.tls orelse {
346 try s.reply(502, "5.5.1 STARTTLS not supported");
347 continue;
348 };
349 if (config.mode != .starttls) {
350 try s.reply(502, "5.5.1 STARTTLS not supported");
351 continue;
352 }
353 if (s.secured) {
354 try s.reply(503, "5.5.1 TLS already active");
355 continue;
356 }
357 try s.reply(220, "2.0.0 Ready to start TLS");
358 try s.upgradeToTls(config);
359 // RFC 3207 §4.2: both sides return to their initial state;
360 // the client must EHLO again.
361 greeted = false;
362 authenticated = false;
363 from = null;
364 recipients = .empty;
365 declared_size = null;
366 body = .unspecified;
367 _ = arena_state.reset(.retain_capacity);
368 },
369 .quit => {
370 try s.reply(221, "2.0.0 Bye");
371 if (s.secured) s.tls_connection.close() catch {};
372 return;
373 },
374 .auth => |args| {
375 if (s.handler.vtable.authenticate == null) {
376 try s.reply(503, "5.5.1 Authentication not enabled");
377 continue;
378 }
379 if (!greeted) {
380 try s.reply(503, "5.5.1 Send EHLO first");
381 continue;
382 }
383 if (authenticated) {
384 try s.reply(503, "5.5.1 Already authenticated");
385 continue;
386 }
387 if (from != null) {
388 try s.reply(503, "5.5.1 MAIL transaction in progress");
389 continue;
390 }
391 switch (try s.receiveAuth(args)) {
392 .authenticated => authenticated = true,
393 .rejected => {},
394 .disconnected => return,
395 }
396 },
397 .unknown => try s.reply(500, "5.5.2 Command not recognized"),
398 }
399 }
400}
401
402/// Performs the server-side TLS handshake over the current transport and
403/// swaps the session onto the encrypted connection.
404fn upgradeToTls(s: *Server, config: TlsOptions) error{TlsHandshakeFailed}!void {
405 var rng_source: std.Random.IoSource = .{ .io = config.io };
406 s.tls_connection = tls.server(s.reader, s.writer, .{
407 .auth = config.auth,
408 .rng = rng_source.interface(),
409 .now = Io.Clock.real.now(config.io),
410 }) catch return error.TlsHandshakeFailed;
411 s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer);
412 s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer);
413 s.reader = &s.tls_reader.interface;
414 s.writer = &s.tls_writer.interface;
415 s.secured = true;
416}
417
418const AuthOutcome = enum { authenticated, rejected, disconnected };
419
420/// Runs the challenge/response exchange for AUTH PLAIN or AUTH LOGIN
421/// (RFC 4954) and consults the handler's `authenticate` callback. Every
422/// outcome except `disconnected` has already sent its reply.
423fn receiveAuth(s: *Server, args: protocol.Command.AuthArgs) RunError!AuthOutcome {
424 const callback = s.handler.vtable.authenticate.?;
425
426 if (std.ascii.eqlIgnoreCase(args.mechanism, "PLAIN")) {
427 var decoded_buf: [576]u8 = undefined;
428 var response: []const u8 = args.initial;
429 if (response.len == 0) {
430 try s.reply(334, "");
431 response = switch (try s.takeAuthLine()) {
432 .line => |line| line,
433 .cancelled => return .rejected,
434 .disconnected => return .disconnected,
435 };
436 }
437 const decoded = decodeBase64(&decoded_buf, response) orelse {
438 try s.reply(501, "5.5.2 Invalid base64");
439 return .rejected;
440 };
441 // authzid NUL authcid NUL password; the authzid is ignored.
442 const first_nul = std.mem.indexOfScalar(u8, decoded, 0) orelse {
443 try s.reply(501, "5.5.2 Malformed PLAIN response");
444 return .rejected;
445 };
446 const after_authzid = decoded[first_nul + 1 ..];
447 const second_nul = std.mem.indexOfScalar(u8, after_authzid, 0) orelse {
448 try s.reply(501, "5.5.2 Malformed PLAIN response");
449 return .rejected;
450 };
451 return s.finishAuth(callback, after_authzid[0..second_nul], after_authzid[second_nul + 1 ..]);
452 }
453
454 if (std.ascii.eqlIgnoreCase(args.mechanism, "LOGIN")) {
455 var user_buf: [192]u8 = undefined;
456 var pass_buf: [192]u8 = undefined;
457
458 var username: []const u8 = undefined;
459 if (args.initial.len > 0) {
460 // Some clients send the username as an initial response.
461 username = decodeBase64(&user_buf, args.initial) orelse {
462 try s.reply(501, "5.5.2 Invalid base64");
463 return .rejected;
464 };
465 } else {
466 try s.reply(334, "VXNlcm5hbWU6"); // base64("Username:")
467 const line = switch (try s.takeAuthLine()) {
468 .line => |line| line,
469 .cancelled => return .rejected,
470 .disconnected => return .disconnected,
471 };
472 username = decodeBase64(&user_buf, line) orelse {
473 try s.reply(501, "5.5.2 Invalid base64");
474 return .rejected;
475 };
476 }
477 try s.reply(334, "UGFzc3dvcmQ6"); // base64("Password:")
478 const line = switch (try s.takeAuthLine()) {
479 .line => |line| line,
480 .cancelled => return .rejected,
481 .disconnected => return .disconnected,
482 };
483 const password = decodeBase64(&pass_buf, line) orelse {
484 try s.reply(501, "5.5.2 Invalid base64");
485 return .rejected;
486 };
487 return s.finishAuth(callback, username, password);
488 }
489
490 try s.reply(504, "5.5.4 Unrecognized authentication type");
491 return .rejected;
492}
493
494fn finishAuth(
495 s: *Server,
496 callback: *const fn (?*anyopaque, []const u8, []const u8) bool,
497 username: []const u8,
498 password: []const u8,
499) RunError!AuthOutcome {
500 if (callback(s.handler.context, username, password)) {
501 try s.reply(235, "2.7.0 Authentication successful");
502 return .authenticated;
503 }
504 try s.reply(535, "5.7.8 Authentication credentials invalid");
505 return .rejected;
506}
507
508const AuthLine = union(enum) { line: []u8, cancelled, disconnected };
509
510/// Reads one continuation line of an AUTH exchange. `cancelled` covers both
511/// an explicit "*" and an overlong line; its reply has already been sent.
512fn takeAuthLine(s: *Server) RunError!AuthLine {
513 const line = protocol.readLine(s.reader) catch |err| switch (err) {
514 error.EndOfStream => return .disconnected,
515 error.ReadFailed => return error.ReadFailed,
516 error.LineTooLong => {
517 try s.discardLine();
518 try s.reply(501, "5.5.2 Response too long");
519 return .cancelled;
520 },
521 };
522 if (std.mem.eql(u8, line, "*")) {
523 try s.reply(501, "5.7.0 Authentication cancelled");
524 return .cancelled;
525 }
526 return .{ .line = line };
527}
528
529/// Decodes a base64 AUTH argument; "=" denotes an empty response.
530fn decodeBase64(out: []u8, encoded: []const u8) ?[]u8 {
531 if (std.mem.eql(u8, encoded, "=")) return out[0..0];
532 const len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return null;
533 if (len > out.len) return null;
534 std.base64.standard.Decoder.decode(out[0..len], encoded) catch return null;
535 return out[0..len];
536}
537
538const ChunkOutcome = enum { done, end_session };
539
540/// Receives a message sent with BDAT chunks (RFC 3030 CHUNKING), starting
541/// from the already-parsed first chunk header. Chunk data is raw: no
542/// dot-stuffing and no line-ending normalization.
543fn receiveChunked(
544 s: *Server,
545 arena: std.mem.Allocator,
546 envelope: Envelope,
547 first: protocol.Command.BdatArgs,
548) RunError!ChunkOutcome {
549 if (s.handler.vtable.messageReader) |callback| {
550 var buffer: [1024]u8 = undefined;
551 var bdat_reader: BdatReader = .{
552 .server = s,
553 .remaining = first.size,
554 .last = first.last,
555 .interface = .{
556 .buffer = &buffer,
557 .vtable = &.{ .stream = BdatReader.stream },
558 .seek = 0,
559 .end = 0,
560 },
561 };
562 const decision = callback(s.handler.context, envelope, &bdat_reader.interface);
563 if (bdat_reader.abort == null and !bdat_reader.finished) {
564 // Consume whatever the callback left unread, through LAST.
565 var discard_buf: [256]u8 = undefined;
566 var discarding: Io.Writer.Discarding = .init(&discard_buf);
567 _ = bdat_reader.interface.streamRemaining(&discarding.writer) catch {};
568 }
569 if (bdat_reader.abort) |abort| switch (abort) {
570 .rset, .protocol => return .done, // Replies already sent.
571 .quit, .disconnected => return .end_session,
572 .transport_failure => return error.ReadFailed,
573 };
574 switch (decision) {
575 .accept => try s.reply(250, "2.0.0 Ok, message accepted"),
576 .reject => |r| try s.reply(r.code, r.text),
577 }
578 return .done;
579 }
580
581 var data: std.ArrayList(u8) = .empty;
582 var oversize = false;
583 var size = first.size;
584 var last = first.last;
585 while (true) {
586 var left = size;
587 while (left > 0) {
588 const available = s.reader.peekGreedy(1) catch |err| switch (err) {
589 error.EndOfStream => return .end_session,
590 error.ReadFailed => return error.ReadFailed,
591 };
592 const n: usize = @intCast(@min(@as(u64, available.len), left));
593 if (!oversize) {
594 if (data.items.len + n > s.options.max_message_size) {
595 oversize = true;
596 } else {
597 try data.appendSlice(arena, available[0..n]);
598 }
599 }
600 s.reader.toss(n);
601 left -= n;
602 }
603 if (last) break;
604 try s.reply(250, "2.0.0 Chunk received");
605 const line = protocol.readLine(s.reader) catch |err| switch (err) {
606 error.EndOfStream => return .end_session,
607 error.ReadFailed => return error.ReadFailed,
608 error.LineTooLong => {
609 try s.discardLine();
610 try s.reply(500, "5.5.2 Line too long");
611 return .done; // Transaction aborted.
612 },
613 };
614 const command = protocol.Command.parse(line) catch {
615 try s.reply(501, "5.5.4 Syntax error in parameters");
616 return .done;
617 };
618 switch (command) {
619 .bdat => |b| {
620 size = b.size;
621 last = b.last;
622 },
623 .rset => {
624 try s.reply(250, "2.0.0 Ok");
625 return .done;
626 },
627 .quit => {
628 try s.reply(221, "2.0.0 Bye");
629 if (s.secured) s.tls_connection.close() catch {};
630 return .end_session;
631 },
632 else => {
633 try s.reply(503, "5.5.1 BDAT expected");
634 return .done;
635 },
636 }
637 }
638 if (oversize) {
639 try s.reply(552, "5.3.4 Message exceeds maximum size");
640 return .done;
641 }
642 switch (s.handler.vtable.message.?(s.handler.context, envelope, data.items)) {
643 .accept => try s.reply(250, "2.0.0 Ok, message accepted"),
644 .reject => |r| try s.reply(r.code, r.text),
645 }
646 return .done;
647}
648
649/// Adapts a BDAT chunk sequence into an `Io.Reader` of the raw message
650/// content for `Handler.VTable.messageReader`, replying 250 between chunks
651/// and following the chunk headers as they arrive.
652const BdatReader = struct {
653 server: *Server,
654 interface: Io.Reader,
655 remaining: u64,
656 last: bool,
657 finished: bool = false,
658 abort: ?Abort = null,
659
660 const Abort = enum { rset, quit, protocol, disconnected, transport_failure };
661
662 fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize {
663 const br: *BdatReader = @alignCast(@fieldParentPtr("interface", io_r));
664 const s = br.server;
665 while (br.remaining == 0) {
666 if (br.last) {
667 br.finished = true;
668 return error.EndOfStream;
669 }
670 s.reply(250, "2.0.0 Chunk received") catch {
671 br.abort = .transport_failure;
672 return error.ReadFailed;
673 };
674 const line = protocol.readLine(s.reader) catch |err| {
675 switch (err) {
676 error.EndOfStream => br.abort = .disconnected,
677 error.ReadFailed => br.abort = .transport_failure,
678 error.LineTooLong => {
679 s.discardLine() catch {};
680 s.reply(500, "5.5.2 Line too long") catch {};
681 br.abort = .protocol;
682 },
683 }
684 return error.ReadFailed;
685 };
686 const command = protocol.Command.parse(line) catch {
687 s.reply(501, "5.5.4 Syntax error in parameters") catch {};
688 br.abort = .protocol;
689 return error.ReadFailed;
690 };
691 switch (command) {
692 .bdat => |b| {
693 br.remaining = b.size;
694 br.last = b.last;
695 },
696 .rset => {
697 s.reply(250, "2.0.0 Ok") catch {};
698 br.abort = .rset;
699 return error.ReadFailed;
700 },
701 .quit => {
702 s.reply(221, "2.0.0 Bye") catch {};
703 if (s.secured) s.tls_connection.close() catch {};
704 br.abort = .quit;
705 return error.ReadFailed;
706 },
707 else => {
708 s.reply(503, "5.5.1 BDAT expected") catch {};
709 br.abort = .protocol;
710 return error.ReadFailed;
711 },
712 }
713 }
714 const available = s.reader.peekGreedy(1) catch |err| switch (err) {
715 error.EndOfStream => {
716 br.abort = .disconnected;
717 return error.ReadFailed;
718 },
719 error.ReadFailed => {
720 br.abort = .transport_failure;
721 return error.ReadFailed;
722 },
723 };
724 const dest = limit.slice(try w.writableSliceGreedy(1));
725 const n: usize = @intCast(@min(@min(@as(u64, available.len), @as(u64, dest.len)), br.remaining));
726 @memcpy(dest[0..n], available[0..n]);
727 s.reader.toss(n);
728 br.remaining -= n;
729 w.advance(n);
730 return n;
731 }
732};
733
734/// Reads message content after DATA up to the terminating ".\r\n",
735/// un-stuffing dots, then asks the handler to accept or reject.
736fn receiveData(s: *Server, arena: std.mem.Allocator, envelope: Envelope) RunError!void {
737 try s.reply(354, "End data with <CR><LF>.<CR><LF>");
738
739 if (s.handler.vtable.messageReader) |callback| {
740 var buffer: [1024]u8 = undefined;
741 var data_reader: DataReader = .{
742 .session_reader = s.reader,
743 .interface = .{
744 .buffer = &buffer,
745 .vtable = &.{ .stream = DataReader.stream },
746 .seek = 0,
747 .end = 0,
748 },
749 };
750 const decision = callback(s.handler.context, envelope, &data_reader.interface);
751 // Consume whatever the callback left unread, up to and including
752 // the terminating ".".
753 while (!data_reader.finished) {
754 const line = protocol.readLine(s.reader) catch |err| switch (err) {
755 error.EndOfStream => return, // Client disconnected mid-message.
756 error.ReadFailed => return error.ReadFailed,
757 error.LineTooLong => {
758 try s.discardLine();
759 continue;
760 },
761 };
762 if (std.mem.eql(u8, line, ".")) break;
763 }
764 switch (decision) {
765 .accept => try s.reply(250, "2.0.0 Ok, message accepted"),
766 .reject => |r| try s.reply(r.code, r.text),
767 }
768 return;
769 }
770
771 var data: std.ArrayList(u8) = .empty;
772 var oversize = false;
773 while (true) {
774 const line = protocol.readLine(s.reader) catch |err| switch (err) {
775 error.EndOfStream => return, // Client disconnected mid-message.
776 error.ReadFailed => return error.ReadFailed,
777 error.LineTooLong => {
778 // Longer than our reader buffer; RFC 5321 caps text lines at
779 // 1000 octets, so treat it as oversize but keep scanning for
780 // the terminator.
781 try s.discardLine();
782 oversize = true;
783 continue;
784 },
785 };
786 if (std.mem.eql(u8, line, ".")) break;
787 const content = if (line.len > 0 and line[0] == '.') line[1..] else line;
788 if (oversize) continue;
789 if (data.items.len + content.len + protocol.crlf.len > s.options.max_message_size) {
790 oversize = true;
791 continue;
792 }
793 try data.appendSlice(arena, content);
794 try data.appendSlice(arena, protocol.crlf);
795 }
796 if (oversize) {
797 try s.reply(552, "5.3.4 Message exceeds maximum size");
798 return;
799 }
800 switch (s.handler.vtable.message.?(s.handler.context, envelope, data.items)) {
801 .accept => try s.reply(250, "2.0.0 Ok, message accepted"),
802 .reject => |r| try s.reply(r.code, r.text),
803 }
804}
805
806/// Adapts the session's line-based DATA phase into an `Io.Reader` of the
807/// unstuffed message content for `Handler.VTable.messageReader`.
808const DataReader = struct {
809 session_reader: *Io.Reader,
810 interface: Io.Reader,
811 /// Unread remainder of the current line (points into the session
812 /// reader's buffer, which only this reader touches during DATA).
813 line: []const u8 = &.{},
814 line_ending: []const u8 = &.{},
815 finished: bool = false,
816
817 fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize {
818 const dr: *DataReader = @alignCast(@fieldParentPtr("interface", io_r));
819 if (dr.line.len == 0 and dr.line_ending.len == 0) {
820 if (dr.finished) return error.EndOfStream;
821 const raw = protocol.readLine(dr.session_reader) catch return error.ReadFailed;
822 if (std.mem.eql(u8, raw, ".")) {
823 dr.finished = true;
824 return error.EndOfStream;
825 }
826 dr.line = if (raw.len > 0 and raw[0] == '.') raw[1..] else raw;
827 dr.line_ending = protocol.crlf;
828 }
829 const dest = limit.slice(try w.writableSliceGreedy(1));
830 const line_n = @min(dest.len, dr.line.len);
831 @memcpy(dest[0..line_n], dr.line[0..line_n]);
832 dr.line = dr.line[line_n..];
833 var n = line_n;
834 if (dr.line.len == 0) {
835 const ending_n = @min(dest.len - n, dr.line_ending.len);
836 @memcpy(dest[n..][0..ending_n], dr.line_ending[0..ending_n]);
837 dr.line_ending = dr.line_ending[ending_n..];
838 n += ending_n;
839 }
840 w.advance(n);
841 return n;
842 }
843};
844
845fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void {
846 try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text });
847 try s.writer.flush();
848}
849
850/// Discards input through the next newline after `error.LineTooLong`, which
851/// leaves the reader positioned at the start of the oversized line.
852fn discardLine(s: *Server) error{ReadFailed}!void {
853 _ = s.reader.discardDelimiterInclusive('\n') catch |err| switch (err) {
854 error.EndOfStream => {},
855 error.ReadFailed => return error.ReadFailed,
856 };
857}
858
859const TestHandler = struct {
860 from: std.ArrayList(u8) = .empty,
861 recipients: std.ArrayList(u8) = .empty,
862 data: std.ArrayList(u8) = .empty,
863 messages_accepted: usize = 0,
864 reject_recipient: ?[]const u8 = null,
865 declared_size: ?u64 = null,
866 body: Envelope.Body = .unspecified,
867 /// When set, enables the authenticate callback accepting user "alice"
868 /// with this password.
869 password: ?[]const u8 = null,
870
871 fn deinit(h: *TestHandler) void {
872 h.from.deinit(std.testing.allocator);
873 h.recipients.deinit(std.testing.allocator);
874 h.data.deinit(std.testing.allocator);
875 }
876
877 fn handler(h: *TestHandler) Handler {
878 return .{ .context = h, .vtable = if (h.password != null) &.{
879 .authenticate = onAuthenticate,
880 .rcptTo = onRcptTo,
881 .message = onMessage,
882 } else &.{
883 .rcptTo = onRcptTo,
884 .message = onMessage,
885 } };
886 }
887
888 fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool {
889 const h: *TestHandler = @ptrCast(@alignCast(context.?));
890 return std.mem.eql(u8, username, "alice") and
891 std.mem.eql(u8, password, h.password.?);
892 }
893
894 fn onRcptTo(context: ?*anyopaque, to: []const u8) Decision {
895 const h: *TestHandler = @ptrCast(@alignCast(context.?));
896 if (h.reject_recipient) |rejected| {
897 if (std.mem.eql(u8, to, rejected)) return .{ .reject = .{
898 .code = 550,
899 .text = "5.1.1 No such user",
900 } };
901 }
902 return .accept;
903 }
904
905 fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision {
906 const h: *TestHandler = @ptrCast(@alignCast(context.?));
907 const gpa = std.testing.allocator;
908 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} };
909 for (envelope.recipients) |recipient| {
910 h.recipients.appendSlice(gpa, recipient) catch return .{ .reject = .{} };
911 h.recipients.append(gpa, ';') catch return .{ .reject = .{} };
912 }
913 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} };
914 h.messages_accepted += 1;
915 h.declared_size = envelope.declared_size;
916 h.body = envelope.body;
917 return .accept;
918 }
919};
920
921fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 {
922 var reader: Io.Reader = .fixed(input);
923 var writer: Io.Writer = .fixed(out_buf);
924 var session: Server = .init(&reader, &writer, handler, options);
925 try session.run(std.testing.allocator);
926 return writer.buffered();
927}
928
929test run {
930 var h: TestHandler = .{};
931 defer h.deinit();
932
933 var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++
934 "MAIL FROM:<alice@example.com>\r\n" ++
935 "RCPT TO:<bob@example.net>\r\n" ++
936 "RCPT TO:<carol@example.net>\r\n" ++
937 "DATA\r\n" ++
938 "Subject: hi\r\n" ++
939 "\r\n" ++
940 "..stuffed line\r\n" ++
941 "body\r\n" ++
942 ".\r\n" ++
943 "QUIT\r\n");
944 var out_buf: [1024]u8 = undefined;
945 var writer: Io.Writer = .fixed(&out_buf);
946
947 var session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" });
948 try session.run(std.testing.allocator);
949 const output = writer.buffered();
950
951 try std.testing.expectEqualStrings("alice@example.com", h.from.items);
952 try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items);
953 try std.testing.expectEqualStrings("Subject: hi\r\n\r\n.stuffed line\r\nbody\r\n", h.data.items);
954 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
955
956 try std.testing.expectEqualStrings(
957 "220 mx.test ESMTP ready\r\n" ++
958 "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++
959 "250 2.1.0 Ok\r\n" ++
960 "250 2.1.5 Ok\r\n" ++
961 "250 2.1.5 Ok\r\n" ++
962 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
963 "250 2.0.0 Ok, message accepted\r\n" ++
964 "221 2.0.0 Bye\r\n",
965 output,
966 );
967}
968
969test "command sequencing is enforced" {
970 var h: TestHandler = .{};
971 defer h.deinit();
972
973 var out_buf: [1024]u8 = undefined;
974 const output = try runScript(
975 "MAIL FROM:<early@example.com>\r\n" ++
976 "EHLO client.example.org\r\n" ++
977 "RCPT TO:<bob@example.net>\r\n" ++
978 "DATA\r\n" ++
979 "QUIT\r\n",
980 &out_buf,
981 h.handler(),
982 .{},
983 );
984
985 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
986 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Send EHLO first") != null);
987 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null);
988 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null);
989}
990
991test "handler can reject a recipient" {
992 var h: TestHandler = .{ .reject_recipient = "nobody@example.net" };
993 defer h.deinit();
994
995 var out_buf: [1024]u8 = undefined;
996 const output = try runScript(
997 "EHLO client.example.org\r\n" ++
998 "MAIL FROM:<alice@example.com>\r\n" ++
999 "RCPT TO:<nobody@example.net>\r\n" ++
1000 "RCPT TO:<bob@example.net>\r\n" ++
1001 "DATA\r\n" ++
1002 "hello\r\n" ++
1003 ".\r\n" ++
1004 "QUIT\r\n",
1005 &out_buf,
1006 h.handler(),
1007 .{},
1008 );
1009
1010 try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null);
1011 try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items);
1012 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
1013}
1014
1015test "AUTH PLAIN with initial response" {
1016 var h: TestHandler = .{ .password = "secret" };
1017 defer h.deinit();
1018
1019 var out_buf: [1024]u8 = undefined;
1020 // base64("\x00alice\x00secret")
1021 const output = try runScript(
1022 "EHLO client.example.org\r\n" ++
1023 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++
1024 "MAIL FROM:<alice@example.com>\r\n" ++
1025 "RCPT TO:<bob@example.net>\r\n" ++
1026 "DATA\r\nauthed mail\r\n.\r\n" ++
1027 "QUIT\r\n",
1028 &out_buf,
1029 h.handler(),
1030 .{ .require_auth = true },
1031 );
1032
1033 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null);
1034 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
1035 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
1036}
1037
1038test "AUTH LOGIN challenge exchange" {
1039 var h: TestHandler = .{ .password = "secret" };
1040 defer h.deinit();
1041
1042 var out_buf: [1024]u8 = undefined;
1043 // base64("alice"), base64("secret")
1044 const output = try runScript(
1045 "EHLO client.example.org\r\n" ++
1046 "AUTH LOGIN\r\n" ++
1047 "YWxpY2U=\r\n" ++
1048 "c2VjcmV0\r\n" ++
1049 "QUIT\r\n",
1050 &out_buf,
1051 h.handler(),
1052 .{},
1053 );
1054
1055 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null);
1056 try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null);
1057 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
1058}
1059
1060test "AUTH failures and sequencing" {
1061 var h: TestHandler = .{ .password = "secret" };
1062 defer h.deinit();
1063
1064 var out_buf: [2048]u8 = undefined;
1065 const output = try runScript(
1066 "EHLO client.example.org\r\n" ++
1067 "MAIL FROM:<alice@example.com>\r\n" ++ // before auth: 530
1068 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // wrong password: 535
1069 "AUTH GSSAPI\r\n" ++ // unsupported: 504
1070 "AUTH PLAIN not!base64\r\n" ++ // 501
1071 "AUTH LOGIN\r\n" ++
1072 "*\r\n" ++ // cancelled: 501
1073 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // correct: 235
1074 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // again: 503
1075 "QUIT\r\n",
1076 &out_buf,
1077 h.handler(),
1078 .{ .require_auth = true },
1079 );
1080
1081 try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null);
1082 try std.testing.expect(std.mem.indexOf(u8, output, "535 5.7.8") != null);
1083 try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null);
1084 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid base64") != null);
1085 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.7.0 Authentication cancelled") != null);
1086 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
1087 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Already authenticated") != null);
1088}
1089
1090test "AUTH without a handler is refused" {
1091 var h: TestHandler = .{};
1092 defer h.deinit();
1093
1094 var out_buf: [1024]u8 = undefined;
1095 const output = try runScript(
1096 "EHLO client.example.org\r\nAUTH PLAIN AGEAYg==\r\nQUIT\r\n",
1097 &out_buf,
1098 h.handler(),
1099 .{},
1100 );
1101
1102 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH") == null);
1103 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null);
1104}
1105
1106test "oversize message is rejected but session continues" {
1107 var h: TestHandler = .{};
1108 defer h.deinit();
1109
1110 var out_buf: [1024]u8 = undefined;
1111 const output = try runScript(
1112 "EHLO client.example.org\r\n" ++
1113 "MAIL FROM:<alice@example.com>\r\n" ++
1114 "RCPT TO:<bob@example.net>\r\n" ++
1115 "DATA\r\n" ++
1116 "0123456789012345678901234567890123456789\r\n" ++
1117 ".\r\n" ++
1118 "NOOP\r\n" ++
1119 "QUIT\r\n",
1120 &out_buf,
1121 h.handler(),
1122 .{ .max_message_size = 16 },
1123 );
1124
1125 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
1126 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null);
1127 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null);
1128}
1129
1130const StreamTestHandler = struct {
1131 collected: std.ArrayList(u8) = .empty,
1132 take_only: ?usize = null,
1133
1134 fn handler(h: *StreamTestHandler) Handler {
1135 return .{ .context = h, .vtable = &.{
1136 .messageReader = onMessageReader,
1137 } };
1138 }
1139
1140 fn onMessageReader(context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision {
1141 const h: *StreamTestHandler = @ptrCast(@alignCast(context.?));
1142 _ = envelope;
1143 const gpa = std.testing.allocator;
1144 if (h.take_only) |n| {
1145 const bytes = message.take(n) catch return .{ .reject = .{} };
1146 h.collected.appendSlice(gpa, bytes) catch return .{ .reject = .{} };
1147 return .accept;
1148 }
1149 message.appendRemaining(gpa, &h.collected, .unlimited) catch return .{ .reject = .{} };
1150 return .accept;
1151 }
1152};
1153
1154test "streaming message handler receives unstuffed content" {
1155 var h: StreamTestHandler = .{};
1156 defer h.collected.deinit(std.testing.allocator);
1157
1158 var out_buf: [1024]u8 = undefined;
1159 const output = try runScript(
1160 "EHLO client.example.org\r\n" ++
1161 "MAIL FROM:<alice@example.com>\r\n" ++
1162 "RCPT TO:<bob@example.net>\r\n" ++
1163 "DATA\r\n" ++
1164 "Subject: streamed\r\n" ++
1165 "\r\n" ++
1166 "..dot line\r\n" ++
1167 "body\r\n" ++
1168 ".\r\n" ++
1169 "QUIT\r\n",
1170 &out_buf,
1171 h.handler(),
1172 .{},
1173 );
1174
1175 try std.testing.expectEqualStrings(
1176 "Subject: streamed\r\n\r\n.dot line\r\nbody\r\n",
1177 h.collected.items,
1178 );
1179 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null);
1180}
1181
1182test "session drains what a streaming handler leaves unread" {
1183 var h: StreamTestHandler = .{ .take_only = 7 };
1184 defer h.collected.deinit(std.testing.allocator);
1185
1186 var out_buf: [1024]u8 = undefined;
1187 const output = try runScript(
1188 "EHLO client.example.org\r\n" ++
1189 "MAIL FROM:<alice@example.com>\r\n" ++
1190 "RCPT TO:<bob@example.net>\r\n" ++
1191 "DATA\r\n" ++
1192 "Subject: mostly unread\r\n" ++
1193 "lots of body\r\n" ++
1194 ".\r\n" ++
1195 "NOOP\r\n" ++
1196 "QUIT\r\n",
1197 &out_buf,
1198 h.handler(),
1199 .{},
1200 );
1201
1202 try std.testing.expectEqualStrings("Subject", h.collected.items);
1203 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null);
1204 // The NOOP after DATA proves the terminator was consumed.
1205 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null);
1206}
1207
1208test "fuzz session with arbitrary client input" {
1209 try std.testing.fuzz({}, fuzzSession, .{});
1210}
1211
1212fn fuzzSession(context: void, smith: *std.testing.Smith) !void {
1213 _ = context;
1214 var input_buf: [2048]u8 = undefined;
1215 const input = input_buf[0..smith.value(u11)];
1216 smith.bytes(input);
1217
1218 var h: TestHandler = .{ .password = "secret" };
1219 defer h.deinit();
1220
1221 var reader: Io.Reader = .fixed(input);
1222 var discarding: Io.Writer.Discarding = .init(&.{});
1223 var session: Server = .init(&reader, &discarding.writer, h.handler(), .{
1224 .max_message_size = 512,
1225 .max_recipients = 4,
1226 });
1227 // Whatever the "client" sends, the session must fail cleanly, never crash.
1228 session.run(std.testing.allocator) catch {};
1229}
1230
1231test "fuzz collecting and streaming DATA agree" {
1232 try std.testing.fuzz({}, fuzzDataEquivalence, .{});
1233}
1234
1235fn fuzzDataEquivalence(context: void, smith: *std.testing.Smith) !void {
1236 _ = context;
1237 var body_buf: [1024]u8 = undefined;
1238 const body = body_buf[0..smith.value(u10)];
1239 smith.bytes(body);
1240
1241 var script_buf: [1200]u8 = undefined;
1242 const script = std.fmt.bufPrint(
1243 &script_buf,
1244 "EHLO fuzz.example.org\r\n" ++
1245 "MAIL FROM:<a@example.com>\r\n" ++
1246 "RCPT TO:<b@example.net>\r\n" ++
1247 "DATA\r\n{s}\r\n.\r\nQUIT\r\n",
1248 .{body},
1249 ) catch unreachable;
1250
1251 var collecting: TestHandler = .{};
1252 defer collecting.deinit();
1253 var out_buf: [4096]u8 = undefined;
1254 _ = runScript(script, &out_buf, collecting.handler(), .{}) catch {};
1255
1256 var streaming: StreamTestHandler = .{};
1257 defer streaming.collected.deinit(std.testing.allocator);
1258 _ = runScript(script, &out_buf, streaming.handler(), .{}) catch {};
1259
1260 try std.testing.expectEqualSlices(u8, collecting.data.items, streaming.collected.items);
1261}
1262
1263test "MAIL parameters SIZE and BODY are honored" {
1264 var h: TestHandler = .{};
1265 defer h.deinit();
1266
1267 var out_buf: [1024]u8 = undefined;
1268 const output = try runScript(
1269 "EHLO client.example.org\r\n" ++
1270 "MAIL FROM:<alice@example.com> SIZE=42 BODY=8BITMIME\r\n" ++
1271 "RCPT TO:<bob@example.net>\r\n" ++
1272 "DATA\r\nsized body\r\n.\r\n" ++
1273 "QUIT\r\n",
1274 &out_buf,
1275 h.handler(),
1276 .{ .max_message_size = 1024 },
1277 );
1278
1279 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
1280 try std.testing.expectEqual(@as(?u64, 42), h.declared_size);
1281 try std.testing.expectEqual(Envelope.Body.eight_bit_mime, h.body);
1282 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null);
1283}
1284
1285test "invalid MAIL and RCPT parameters are rejected" {
1286 var h: TestHandler = .{};
1287 defer h.deinit();
1288
1289 var out_buf: [2048]u8 = undefined;
1290 const output = try runScript(
1291 "EHLO client.example.org\r\n" ++
1292 "MAIL FROM:<a@example.com> SIZE=9999\r\n" ++ // over the maximum: 552
1293 "RCPT TO:<b@example.net>\r\n" ++ // that MAIL never started: 503
1294 "MAIL FROM:<a@example.com> SIZE=banana\r\n" ++ // 501
1295 "MAIL FROM:<a@example.com> BODY=BINARYMIME\r\n" ++ // 555
1296 "MAIL FROM:<a@example.com> FUTURE=yes\r\n" ++ // 555
1297 "MAIL FROM:<a@example.com> BODY=7bit\r\n" ++ // ok
1298 "RCPT TO:<b@example.net> NOTIFY=SUCCESS\r\n" ++ // no RCPT params: 555
1299 "RCPT TO:<b@example.net>\r\n" ++
1300 "DATA\r\nbody\r\n.\r\nQUIT\r\n",
1301 &out_buf,
1302 h.handler(),
1303 .{ .max_message_size = 1024 },
1304 );
1305
1306 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null);
1307 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null);
1308 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid SIZE parameter") != null);
1309 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unsupported BODY value") != null);
1310 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null);
1311 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
1312 try std.testing.expectEqual(Envelope.Body.seven_bit, h.body);
1313 try std.testing.expectEqual(@as(?u64, null), h.declared_size);
1314}
1315
1316test init {
1317 var reader: Io.Reader = .fixed("");
1318 var out_buf: [16]u8 = undefined;
1319 var writer: Io.Writer = .fixed(&out_buf);
1320 var h: TestHandler = .{};
1321 const session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" });
1322 try std.testing.expectEqualStrings("mx.test", session.options.hostname);
1323 try std.testing.expect(!session.secured);
1324}
1325
1326test Options {
1327 const options: Options = .{};
1328 try std.testing.expectEqualStrings("localhost", options.hostname);
1329 try std.testing.expect(options.tls == null);
1330 try std.testing.expect(!options.require_auth);
1331}
1332
1333test Decision {
1334 const ok: Decision = .accept;
1335 try std.testing.expectEqual(Decision.accept, ok);
1336
1337 const no: Decision = .{ .reject = .{ .code = 451, .text = "4.3.0 Try again later" } };
1338 try std.testing.expectEqual(@as(u16, 451), no.reject.code);
1339}
1340
1341test Envelope {
1342 const envelope: Envelope = .{ .from = "", .recipients = &.{"a@example.com"} };
1343 try std.testing.expectEqual(@as(usize, 1), envelope.recipients.len);
1344 try std.testing.expectEqual(@as(?u64, null), envelope.declared_size);
1345 try std.testing.expectEqual(Envelope.Body.unspecified, envelope.body);
1346}
1347
1348test Handler {
1349 const Callbacks = struct {
1350 fn onMessage(context: ?*anyopaque, envelope: Envelope, message_data: []const u8) Decision {
1351 _ = context;
1352 _ = envelope;
1353 _ = message_data;
1354 return .accept;
1355 }
1356 };
1357 const handler: Handler = .{ .vtable = &.{ .message = Callbacks.onMessage } };
1358 const envelope: Envelope = .{ .from = "", .recipients = &.{} };
1359 try std.testing.expectEqual(Decision.accept, handler.vtable.message.?(null, envelope, ""));
1360}
1361
1362// SPDX-SnippetBegin
1363// SPDX-SnippetCopyrightText: © The Exim Maintainers
1364// SPDX-SnippetCopyrightText: © University of Cambridge
1365// SPDX-SnippetCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
1366// SPDX-License-Identifier: GPL-2.0-or-later
1367//
1368// The command dialogue and message lines below are adapted from exim's
1369// test suite (test/scripts/0000-Basic); the reply expectations are ours.
1370test "protocol gauntlet adapted from exim's test suite" {
1371 // Command sequences and dot-stuffing cases distilled from exim's
1372 // test/scripts/0000-Basic (notably 0019's SMTP syntax-error dialogue
1373 // and 0008/0100's dotted message lines), verified against this server
1374 // with exim's own scriptable test client.
1375 var h: TestHandler = .{};
1376 defer h.deinit();
1377
1378 var out_buf: [4096]u8 = undefined;
1379 const output = try runScript(
1380 "NOOP\r\n" ++
1381 "rhubarb\r\n" ++
1382 "mail from:<x@y>\r\n" ++
1383 "rcpt to:<a@b>\r\n" ++
1384 "ehlo test.client\r\n" ++
1385 "mail\r\n" ++
1386 "mail from:\r\n" ++
1387 "mail from:<>\r\n" ++
1388 "mail from:<x@y>\r\n" ++
1389 "rcpt to:\r\n" ++
1390 "data\r\n" ++
1391 "rset\r\n" ++
1392 "etrn abc\r\n" ++
1393 "vrfy userx\r\n" ++
1394 "help\r\n" ++
1395 "mail from:<ok@test1> SIZE=100 BODY=8BITMIME\r\n" ++
1396 "rcpt to:<userx@test.ex>\r\n" ++
1397 "rcpt to:<@relay.example:route@test.ex>\r\n" ++
1398 "data\r\n" ++
1399 "..that line started with a dot\r\n" ++
1400 ".. and one starting with two dots\r\n" ++
1401 "Message body\r\n" ++
1402 ".\r\n" ++
1403 "mail from:<a@b> SIZE=99999999\r\n" ++
1404 "mail from:<a@b> BODY=BINARYMIME\r\n" ++
1405 "mail from:<a@b> FOO=bar\r\n" ++
1406 "mail from:<a@b> SIZE=nan\r\n" ++
1407 "starttls\r\n" ++
1408 "BDAT 5\r\n" ++
1409 "abc\r\n" ++
1410 "mail from:<chunky@test.ex>\r\n" ++
1411 "rcpt to:<userx@test.ex>\r\n" ++
1412 "BDAT 7\r\n" ++
1413 "hello\r\n" ++
1414 "BDAT 23 LAST\r\n" ++
1415 "world of chunked mail\r\n" ++
1416 "quit\r\n",
1417 &out_buf,
1418 h.handler(),
1419 .{},
1420 );
1421
1422 try std.testing.expectEqualStrings(
1423 "220 localhost ESMTP ready\r\n" ++
1424 "250 2.0.0 Ok\r\n" ++
1425 "500 5.5.2 Command not recognized\r\n" ++
1426 "503 5.5.1 Send EHLO first\r\n" ++
1427 "503 5.5.1 Need MAIL command first\r\n" ++
1428 "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-CHUNKING\r\n" ++
1429 "250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++
1430 "501 5.5.4 Syntax error in parameters\r\n" ++
1431 "501 5.5.4 Syntax error in parameters\r\n" ++
1432 "250 2.1.0 Ok\r\n" ++
1433 "503 5.5.1 Nested MAIL command\r\n" ++
1434 "501 5.5.4 Syntax error in parameters\r\n" ++
1435 "503 5.5.1 Need RCPT command first\r\n" ++
1436 "250 2.0.0 Ok\r\n" ++
1437 "500 5.5.2 Command not recognized\r\n" ++
1438 "252 2.5.2 Cannot VRFY user\r\n" ++
1439 "214 2.0.0 See RFC 5321\r\n" ++
1440 "250 2.1.0 Ok\r\n" ++
1441 "250 2.1.5 Ok\r\n" ++
1442 "250 2.1.5 Ok\r\n" ++
1443 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
1444 "250 2.0.0 Ok, message accepted\r\n" ++
1445 "552 5.3.4 Message size exceeds fixed maximum\r\n" ++
1446 "555 5.5.4 Unsupported BODY value\r\n" ++
1447 "555 5.5.4 Unrecognized parameter\r\n" ++
1448 "501 5.5.2 Invalid SIZE parameter\r\n" ++
1449 "502 5.5.1 STARTTLS not supported\r\n" ++
1450 "503 5.5.1 Need RCPT command first\r\n" ++
1451 "250 2.1.0 Ok\r\n" ++
1452 "250 2.1.5 Ok\r\n" ++
1453 "250 2.0.0 Chunk received\r\n" ++
1454 "250 2.0.0 Ok, message accepted\r\n" ++
1455 "221 2.0.0 Bye\r\n",
1456 output,
1457 );
1458 try std.testing.expectEqual(@as(usize, 2), h.messages_accepted);
1459 try std.testing.expectEqualStrings("ok@test1chunky@test.ex", h.from.items);
1460 try std.testing.expectEqualStrings(
1461 "userx@test.ex;route@test.ex;userx@test.ex;",
1462 h.recipients.items,
1463 );
1464 try std.testing.expectEqualStrings(
1465 ".that line started with a dot\r\n. and one starting with two dots\r\nMessage body\r\n" ++
1466 "hello\r\nworld of chunked mail\r\n",
1467 h.data.items,
1468 );
1469}
1470// SPDX-SnippetEnd
1471
1472test "BDAT chunks are reassembled without unstuffing" {
1473 var h: TestHandler = .{};
1474 defer h.deinit();
1475
1476 var out_buf: [1024]u8 = undefined;
1477 const output = try runScript(
1478 "EHLO client.example.org\r\n" ++
1479 "MAIL FROM:<alice@example.com>\r\n" ++
1480 "RCPT TO:<bob@example.net>\r\n" ++
1481 "BDAT 20\r\n" ++
1482 "Subject: chunked\r\n\r\n" ++ // exactly 20 raw octets
1483 "BDAT 18\r\n" ++
1484 ".dots stay\nas-is\r\n" ++ // 18 raw octets, no unstuffing
1485 "BDAT 0 LAST\r\n" ++
1486 "QUIT\r\n",
1487 &out_buf,
1488 h.handler(),
1489 .{},
1490 );
1491
1492 try std.testing.expectEqualStrings(
1493 "Subject: chunked\r\n\r\n.dots stay\nas-is\r\n",
1494 h.data.items,
1495 );
1496 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
1497 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Chunk received") != null);
1498 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null);
1499}
1500
1501test "BDAT framing is length-based, not content-based" {
1502 var h: TestHandler = .{};
1503 defer h.deinit();
1504
1505 var out_buf: [1024]u8 = undefined;
1506 const output = try runScript(
1507 "EHLO client.example.org\r\n" ++
1508 // Without a transaction the chunk must still be consumed, or the
1509 // embedded commands would be executed.
1510 "BDAT 12\r\n" ++
1511 "QUIT\r\nRSET\r\n" ++
1512 "MAIL FROM:<alice@example.com>\r\n" ++
1513 "RCPT TO:<bob@example.net>\r\n" ++
1514 // A chunk whose payload looks like commands is still just data.
1515 "BDAT 23 LAST\r\n" ++
1516 "QUIT\r\nMAIL FROM:<x@y>\r\n" ++
1517 "QUIT\r\n",
1518 &out_buf,
1519 h.handler(),
1520 .{},
1521 );
1522
1523 try std.testing.expectEqualStrings("QUIT\r\nMAIL FROM:<x@y>\r\n", h.data.items);
1524 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null);
1525 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
1526 try std.testing.expect(std.mem.indexOf(u8, output, "221 2.0.0 Bye") != null);
1527}
1528
1529test "RSET between BDAT chunks aborts the message" {
1530 var h: TestHandler = .{};
1531 defer h.deinit();
1532
1533 var out_buf: [1024]u8 = undefined;
1534 const output = try runScript(
1535 "EHLO client.example.org\r\n" ++
1536 "MAIL FROM:<alice@example.com>\r\n" ++
1537 "RCPT TO:<bob@example.net>\r\n" ++
1538 "BDAT 5\r\n" ++
1539 "abc\r\n" ++
1540 "RSET\r\n" ++
1541 "NOOP\r\n" ++
1542 "QUIT\r\n",
1543 &out_buf,
1544 h.handler(),
1545 .{},
1546 );
1547
1548 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
1549 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Chunk received") != null);
1550 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n250 2.0.0 Ok\r\n221") != null);
1551}
1552
1553test "oversize BDAT message is rejected" {
1554 var h: TestHandler = .{};
1555 defer h.deinit();
1556
1557 var out_buf: [1024]u8 = undefined;
1558 const output = try runScript(
1559 "EHLO client.example.org\r\n" ++
1560 "MAIL FROM:<alice@example.com>\r\n" ++
1561 "RCPT TO:<bob@example.net>\r\n" ++
1562 "BDAT 40 LAST\r\n" ++
1563 "0123456789012345678901234567890123456789" ++
1564 "NOOP\r\n" ++
1565 "QUIT\r\n",
1566 &out_buf,
1567 h.handler(),
1568 .{ .max_message_size = 16 },
1569 );
1570
1571 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
1572 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null);
1573 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null);
1574}
1575
1576test "streaming handler receives BDAT chunks" {
1577 var h: StreamTestHandler = .{};
1578 defer h.collected.deinit(std.testing.allocator);
1579
1580 var out_buf: [1024]u8 = undefined;
1581 const output = try runScript(
1582 "EHLO client.example.org\r\n" ++
1583 "MAIL FROM:<alice@example.com>\r\n" ++
1584 "RCPT TO:<bob@example.net>\r\n" ++
1585 "BDAT 6\r\n" ++
1586 "part1\n" ++
1587 "BDAT 8 LAST\r\n" ++
1588 ".part2\r\n" ++
1589 "QUIT\r\n",
1590 &out_buf,
1591 h.handler(),
1592 .{},
1593 );
1594
1595 try std.testing.expectEqualStrings("part1\n.part2\r\n", h.collected.items);
1596 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null);
1597}
1598
1599test "session drains BDAT chunks a streaming handler leaves unread" {
1600 var h: StreamTestHandler = .{ .take_only = 4 };
1601 defer h.collected.deinit(std.testing.allocator);
1602
1603 var out_buf: [1024]u8 = undefined;
1604 const output = try runScript(
1605 "EHLO client.example.org\r\n" ++
1606 "MAIL FROM:<alice@example.com>\r\n" ++
1607 "RCPT TO:<bob@example.net>\r\n" ++
1608 "BDAT 10\r\n" ++
1609 "0123456789" ++
1610 "BDAT 10 LAST\r\n" ++
1611 "abcdefghij" ++
1612 "NOOP\r\n" ++
1613 "QUIT\r\n",
1614 &out_buf,
1615 h.handler(),
1616 .{},
1617 );
1618
1619 try std.testing.expectEqualStrings("0123", h.collected.items);
1620 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null);
1621 // The NOOP after the final chunk proves the stream stayed in sync.
1622 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null);
1623}