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