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