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