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