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