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