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