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