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