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