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 /// Hostname announced in the greeting and the EHLO response.
39 hostname: []const u8 = "localhost",
40 /// Advertised via the SIZE extension and enforced during DATA.
41 max_message_size: usize = 16 * 1024 * 1024,
42 max_recipients: usize = 100,
43 /// When set, the session speaks TLS (see `TlsOptions.mode`). The
44 /// underlying stream reader/writer handed to `init` must then have
45 /// buffers of at least `tls.input_buffer_len` and
46 /// `tls.output_buffer_len` bytes, since the handshake and TLS records
47 /// run over them.
48 tls: ?TlsOptions = null,
49 /// Reject MAIL with 530 until the client has authenticated. Requires a
50 /// handler with an `authenticate` callback.
51 require_auth: bool = false,
52};
53
54pub const TlsOptions = struct {
55 io: Io,
56 /// Server certificate chain and private key presented to clients.
57 auth: *tls.config.CertKeyPair,
58 mode: Mode = .starttls,
59
60 pub const Mode = enum {
61 /// Advertise and accept the STARTTLS command
62 /// ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)).
63 starttls,
64 /// Perform the TLS handshake before the greeting (implicit TLS /
65 /// SMTPS, port 465 style; [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314)).
66 implicit,
67 };
68};
69
70/// A handler's verdict on an envelope step or a complete message.
71pub const Decision = union(enum) {
72 accept,
73 reject: Rejection,
74
75 pub const Rejection = struct {
76 /// Use 4xx for "try again later", 5xx for permanent rejection.
77 code: u16 = 550,
78 /// By convention prefixed with an enhanced status code
79 /// ([RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463)).
80 text: []const u8 = "5.7.1 Rejected",
81 };
82};
83
84pub const Envelope = struct {
85 /// Empty for the null reverse-path (`MAIL FROM:<>`).
86 from: []const u8,
87 recipients: []const []const u8,
88 /// Value of the MAIL SIZE= parameter
89 /// ([RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870)), if the client
90 /// declared one. Already validated against `Options.max_message_size`.
91 declared_size: ?u64 = null,
92 /// Value of the MAIL BODY= parameter
93 /// ([RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152)).
94 body: Body = .unspecified,
95
96 pub const Body = enum { unspecified, seven_bit, eight_bit_mime };
97};
98
99/// Callbacks invoked during a session. All slices passed to callbacks are
100/// only valid for the duration of the call.
101pub const Handler = struct {
102 context: ?*anyopaque = null,
103 vtable: *const VTable,
104
105 pub const VTable = struct {
106 /// Called for AUTH with the decoded credentials; return true to
107 /// accept. When set, AUTH PLAIN and AUTH LOGIN are advertised and
108 /// accepted ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)).
109 authenticate: ?*const fn (context: ?*anyopaque, username: []const u8, password: []const u8) bool = null,
110 /// Called for MAIL FROM. Null accepts every sender.
111 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null,
112 /// Called for each RCPT TO. Null accepts every recipient.
113 rcptTo: ?*const fn (context: ?*anyopaque, to: []const u8) Decision = null,
114 /// Called once the complete message has been received. The data has
115 /// CRLF line endings and dot-stuffing already removed. Exactly one
116 /// of `message` and `messageReader` must be set.
117 message: ?*const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision = null,
118 /// Streaming alternative to `message`: called after DATA with a
119 /// reader that yields the message content (dot-stuffing removed,
120 /// line endings normalized to CRLF) until end of stream. Anything
121 /// the callback leaves unread is drained by the session, so
122 /// returning early is fine. `Options.max_message_size` is not
123 /// enforced in this mode; individual message lines must fit the
124 /// session's stream reader buffer.
125 messageReader: ?*const fn (context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision = null,
126 };
127};
128
129pub fn init(reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options) Server {
130 return .{ .reader = reader, .writer = writer, .handler = handler, .options = options };
131}
132
133pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory, TlsHandshakeFailed };
134
135/// Serves the session until the client sends QUIT or disconnects. `gpa`
136/// backs per-transaction storage (envelope and message data); everything is
137/// freed on return.
138pub fn run(s: *Server, gpa: std.mem.Allocator) RunError!void {
139 var arena_state: std.heap.ArenaAllocator = .init(gpa);
140 defer arena_state.deinit();
141 const arena = arena_state.allocator();
142
143 std.debug.assert(!s.options.require_auth or s.handler.vtable.authenticate != null);
144 std.debug.assert((s.handler.vtable.message == null) != (s.handler.vtable.messageReader == null));
145
146 if (s.options.tls) |config| {
147 if (config.mode == .implicit and !s.secured) try s.upgradeToTls(config);
148 }
149
150 var greeted = false;
151 var authenticated = false;
152 var from: ?[]const u8 = null;
153 var recipients: std.ArrayList([]const u8) = .empty;
154 var declared_size: ?u64 = null;
155 var body: Envelope.Body = .unspecified;
156
157 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname});
158 try s.writer.flush();
159
160 while (true) {
161 const line = protocol.readLine(s.reader) catch |err| switch (err) {
162 error.EndOfStream => return, // Client disconnected.
163 error.ReadFailed => return error.ReadFailed,
164 error.LineTooLong => {
165 try s.discardLine();
166 try s.reply(500, "5.5.2 Line too long");
167 continue;
168 },
169 };
170 const command = protocol.Command.parse(line) catch {
171 try s.reply(501, "5.5.4 Syntax error in parameters");
172 continue;
173 };
174 switch (command) {
175 .helo => {
176 greeted = true;
177 from = null;
178 recipients = .empty;
179 declared_size = null;
180 body = .unspecified;
181 _ = arena_state.reset(.retain_capacity);
182 try s.reply(250, s.options.hostname);
183 },
184 .ehlo => {
185 greeted = true;
186 from = null;
187 recipients = .empty;
188 declared_size = null;
189 body = .unspecified;
190 _ = arena_state.reset(.retain_capacity);
191 // Every reply carries an enhanced status code (RFC 3463), so
192 // the ENHANCEDSTATUSCODES extension (RFC 2034) is advertised.
193 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-ENHANCEDSTATUSCODES\r\n", .{s.options.hostname});
194 if (s.options.tls) |config| {
195 if (config.mode == .starttls and !s.secured)
196 try s.writer.writeAll("250-STARTTLS\r\n");
197 }
198 if (s.handler.vtable.authenticate != null and !authenticated)
199 try s.writer.writeAll("250-AUTH PLAIN LOGIN\r\n");
200 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size});
201 try s.writer.flush();
202 },
203 .mail => |args| {
204 if (!greeted) {
205 try s.reply(503, "5.5.1 Send EHLO first");
206 continue;
207 }
208 if (s.options.require_auth and !authenticated) {
209 try s.reply(530, "5.7.0 Authentication required");
210 continue;
211 }
212 if (from != null) {
213 try s.reply(503, "5.5.1 Nested MAIL command");
214 continue;
215 }
216 var mail_declared_size: ?u64 = null;
217 var mail_body: Envelope.Body = .unspecified;
218 var params_ok = true;
219 var params = args.paramIterator();
220 while (params.next()) |param| {
221 if (std.ascii.eqlIgnoreCase(param.keyword, "SIZE")) {
222 const size = std.fmt.parseInt(u64, param.value, 10) catch {
223 try s.reply(501, "5.5.2 Invalid SIZE parameter");
224 params_ok = false;
225 break;
226 };
227 if (size > s.options.max_message_size) {
228 try s.reply(552, "5.3.4 Message size exceeds fixed maximum");
229 params_ok = false;
230 break;
231 }
232 mail_declared_size = size;
233 } else if (std.ascii.eqlIgnoreCase(param.keyword, "BODY")) {
234 if (std.ascii.eqlIgnoreCase(param.value, "7BIT")) {
235 mail_body = .seven_bit;
236 } else if (std.ascii.eqlIgnoreCase(param.value, "8BITMIME")) {
237 mail_body = .eight_bit_mime;
238 } else {
239 try s.reply(555, "5.5.4 Unsupported BODY value");
240 params_ok = false;
241 break;
242 }
243 } else {
244 try s.reply(555, "5.5.4 Unrecognized parameter");
245 params_ok = false;
246 break;
247 }
248 }
249 if (!params_ok) continue;
250 if (s.handler.vtable.mailFrom) |callback| {
251 switch (callback(s.handler.context, args.path)) {
252 .accept => {},
253 .reject => |r| {
254 try s.reply(r.code, r.text);
255 continue;
256 },
257 }
258 }
259 from = try arena.dupe(u8, args.path);
260 declared_size = mail_declared_size;
261 body = mail_body;
262 try s.reply(250, "2.1.0 Ok");
263 },
264 .rcpt => |args| {
265 if (from == null) {
266 try s.reply(503, "5.5.1 Need MAIL command first");
267 continue;
268 }
269 if (args.params.len != 0) {
270 try s.reply(555, "5.5.4 Unrecognized parameter");
271 continue;
272 }
273 if (recipients.items.len >= s.options.max_recipients) {
274 try s.reply(452, "4.5.3 Too many recipients");
275 continue;
276 }
277 if (s.handler.vtable.rcptTo) |callback| {
278 switch (callback(s.handler.context, args.path)) {
279 .accept => {},
280 .reject => |r| {
281 try s.reply(r.code, r.text);
282 continue;
283 },
284 }
285 }
286 try recipients.append(arena, try arena.dupe(u8, args.path));
287 try s.reply(250, "2.1.5 Ok");
288 },
289 .data => {
290 if (recipients.items.len == 0) {
291 try s.reply(503, "5.5.1 Need RCPT command first");
292 continue;
293 }
294 try s.receiveData(arena, .{
295 .from = from.?,
296 .recipients = recipients.items,
297 .declared_size = declared_size,
298 .body = body,
299 });
300 from = null;
301 recipients = .empty;
302 declared_size = null;
303 body = .unspecified;
304 _ = arena_state.reset(.retain_capacity);
305 },
306 .rset => {
307 from = null;
308 recipients = .empty;
309 declared_size = null;
310 body = .unspecified;
311 _ = arena_state.reset(.retain_capacity);
312 try s.reply(250, "2.0.0 Ok");
313 },
314 .noop => try s.reply(250, "2.0.0 Ok"),
315 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"),
316 .help => try s.reply(214, "2.0.0 See RFC 5321"),
317 .starttls => {
318 const config = s.options.tls orelse {
319 try s.reply(502, "5.5.1 STARTTLS not supported");
320 continue;
321 };
322 if (config.mode != .starttls) {
323 try s.reply(502, "5.5.1 STARTTLS not supported");
324 continue;
325 }
326 if (s.secured) {
327 try s.reply(503, "5.5.1 TLS already active");
328 continue;
329 }
330 try s.reply(220, "2.0.0 Ready to start TLS");
331 try s.upgradeToTls(config);
332 // RFC 3207 §4.2: both sides return to their initial state;
333 // the client must EHLO again.
334 greeted = false;
335 authenticated = false;
336 from = null;
337 recipients = .empty;
338 declared_size = null;
339 body = .unspecified;
340 _ = arena_state.reset(.retain_capacity);
341 },
342 .quit => {
343 try s.reply(221, "2.0.0 Bye");
344 if (s.secured) s.tls_connection.close() catch {};
345 return;
346 },
347 .auth => |args| {
348 if (s.handler.vtable.authenticate == null) {
349 try s.reply(503, "5.5.1 Authentication not enabled");
350 continue;
351 }
352 if (!greeted) {
353 try s.reply(503, "5.5.1 Send EHLO first");
354 continue;
355 }
356 if (authenticated) {
357 try s.reply(503, "5.5.1 Already authenticated");
358 continue;
359 }
360 if (from != null) {
361 try s.reply(503, "5.5.1 MAIL transaction in progress");
362 continue;
363 }
364 switch (try s.receiveAuth(args)) {
365 .authenticated => authenticated = true,
366 .rejected => {},
367 .disconnected => return,
368 }
369 },
370 .unknown => try s.reply(500, "5.5.2 Command not recognized"),
371 }
372 }
373}
374
375/// Performs the server-side TLS handshake over the current transport and
376/// swaps the session onto the encrypted connection.
377fn upgradeToTls(s: *Server, config: TlsOptions) error{TlsHandshakeFailed}!void {
378 var rng_source: std.Random.IoSource = .{ .io = config.io };
379 s.tls_connection = tls.server(s.reader, s.writer, .{
380 .auth = config.auth,
381 .rng = rng_source.interface(),
382 .now = Io.Clock.real.now(config.io),
383 }) catch return error.TlsHandshakeFailed;
384 s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer);
385 s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer);
386 s.reader = &s.tls_reader.interface;
387 s.writer = &s.tls_writer.interface;
388 s.secured = true;
389}
390
391const AuthOutcome = enum { authenticated, rejected, disconnected };
392
393/// Runs the challenge/response exchange for AUTH PLAIN or AUTH LOGIN
394/// (RFC 4954) and consults the handler's `authenticate` callback. Every
395/// outcome except `disconnected` has already sent its reply.
396fn receiveAuth(s: *Server, args: protocol.Command.AuthArgs) RunError!AuthOutcome {
397 const callback = s.handler.vtable.authenticate.?;
398
399 if (std.ascii.eqlIgnoreCase(args.mechanism, "PLAIN")) {
400 var decoded_buf: [576]u8 = undefined;
401 var response: []const u8 = args.initial;
402 if (response.len == 0) {
403 try s.reply(334, "");
404 response = switch (try s.takeAuthLine()) {
405 .line => |line| line,
406 .cancelled => return .rejected,
407 .disconnected => return .disconnected,
408 };
409 }
410 const decoded = decodeBase64(&decoded_buf, response) orelse {
411 try s.reply(501, "5.5.2 Invalid base64");
412 return .rejected;
413 };
414 // authzid NUL authcid NUL password; the authzid is ignored.
415 const first_nul = std.mem.indexOfScalar(u8, decoded, 0) orelse {
416 try s.reply(501, "5.5.2 Malformed PLAIN response");
417 return .rejected;
418 };
419 const after_authzid = decoded[first_nul + 1 ..];
420 const second_nul = std.mem.indexOfScalar(u8, after_authzid, 0) orelse {
421 try s.reply(501, "5.5.2 Malformed PLAIN response");
422 return .rejected;
423 };
424 return s.finishAuth(callback, after_authzid[0..second_nul], after_authzid[second_nul + 1 ..]);
425 }
426
427 if (std.ascii.eqlIgnoreCase(args.mechanism, "LOGIN")) {
428 var user_buf: [192]u8 = undefined;
429 var pass_buf: [192]u8 = undefined;
430
431 var username: []const u8 = undefined;
432 if (args.initial.len > 0) {
433 // Some clients send the username as an initial response.
434 username = decodeBase64(&user_buf, args.initial) orelse {
435 try s.reply(501, "5.5.2 Invalid base64");
436 return .rejected;
437 };
438 } else {
439 try s.reply(334, "VXNlcm5hbWU6"); // base64("Username:")
440 const line = switch (try s.takeAuthLine()) {
441 .line => |line| line,
442 .cancelled => return .rejected,
443 .disconnected => return .disconnected,
444 };
445 username = decodeBase64(&user_buf, line) orelse {
446 try s.reply(501, "5.5.2 Invalid base64");
447 return .rejected;
448 };
449 }
450 try s.reply(334, "UGFzc3dvcmQ6"); // base64("Password:")
451 const line = switch (try s.takeAuthLine()) {
452 .line => |line| line,
453 .cancelled => return .rejected,
454 .disconnected => return .disconnected,
455 };
456 const password = decodeBase64(&pass_buf, line) orelse {
457 try s.reply(501, "5.5.2 Invalid base64");
458 return .rejected;
459 };
460 return s.finishAuth(callback, username, password);
461 }
462
463 try s.reply(504, "5.5.4 Unrecognized authentication type");
464 return .rejected;
465}
466
467fn finishAuth(
468 s: *Server,
469 callback: *const fn (?*anyopaque, []const u8, []const u8) bool,
470 username: []const u8,
471 password: []const u8,
472) RunError!AuthOutcome {
473 if (callback(s.handler.context, username, password)) {
474 try s.reply(235, "2.7.0 Authentication successful");
475 return .authenticated;
476 }
477 try s.reply(535, "5.7.8 Authentication credentials invalid");
478 return .rejected;
479}
480
481const AuthLine = union(enum) { line: []u8, cancelled, disconnected };
482
483/// Reads one continuation line of an AUTH exchange. `cancelled` covers both
484/// an explicit "*" and an overlong line; its reply has already been sent.
485fn takeAuthLine(s: *Server) RunError!AuthLine {
486 const line = protocol.readLine(s.reader) catch |err| switch (err) {
487 error.EndOfStream => return .disconnected,
488 error.ReadFailed => return error.ReadFailed,
489 error.LineTooLong => {
490 try s.discardLine();
491 try s.reply(501, "5.5.2 Response too long");
492 return .cancelled;
493 },
494 };
495 if (std.mem.eql(u8, line, "*")) {
496 try s.reply(501, "5.7.0 Authentication cancelled");
497 return .cancelled;
498 }
499 return .{ .line = line };
500}
501
502/// Decodes a base64 AUTH argument; "=" denotes an empty response.
503fn decodeBase64(out: []u8, encoded: []const u8) ?[]u8 {
504 if (std.mem.eql(u8, encoded, "=")) return out[0..0];
505 const len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return null;
506 if (len > out.len) return null;
507 std.base64.standard.Decoder.decode(out[0..len], encoded) catch return null;
508 return out[0..len];
509}
510
511/// Reads message content after DATA up to the terminating ".\r\n",
512/// un-stuffing dots, then asks the handler to accept or reject.
513fn receiveData(s: *Server, arena: std.mem.Allocator, envelope: Envelope) RunError!void {
514 try s.reply(354, "End data with <CR><LF>.<CR><LF>");
515
516 if (s.handler.vtable.messageReader) |callback| {
517 var buffer: [1024]u8 = undefined;
518 var data_reader: DataReader = .{
519 .session_reader = s.reader,
520 .interface = .{
521 .buffer = &buffer,
522 .vtable = &.{ .stream = DataReader.stream },
523 .seek = 0,
524 .end = 0,
525 },
526 };
527 const decision = callback(s.handler.context, envelope, &data_reader.interface);
528 // Consume whatever the callback left unread, up to and including
529 // the terminating ".".
530 while (!data_reader.finished) {
531 const line = protocol.readLine(s.reader) catch |err| switch (err) {
532 error.EndOfStream => return, // Client disconnected mid-message.
533 error.ReadFailed => return error.ReadFailed,
534 error.LineTooLong => {
535 try s.discardLine();
536 continue;
537 },
538 };
539 if (std.mem.eql(u8, line, ".")) break;
540 }
541 switch (decision) {
542 .accept => try s.reply(250, "2.0.0 Ok, message accepted"),
543 .reject => |r| try s.reply(r.code, r.text),
544 }
545 return;
546 }
547
548 var data: std.ArrayList(u8) = .empty;
549 var oversize = false;
550 while (true) {
551 const line = protocol.readLine(s.reader) catch |err| switch (err) {
552 error.EndOfStream => return, // Client disconnected mid-message.
553 error.ReadFailed => return error.ReadFailed,
554 error.LineTooLong => {
555 // Longer than our reader buffer; RFC 5321 caps text lines at
556 // 1000 octets, so treat it as oversize but keep scanning for
557 // the terminator.
558 try s.discardLine();
559 oversize = true;
560 continue;
561 },
562 };
563 if (std.mem.eql(u8, line, ".")) break;
564 const content = if (line.len > 0 and line[0] == '.') line[1..] else line;
565 if (oversize) continue;
566 if (data.items.len + content.len + protocol.crlf.len > s.options.max_message_size) {
567 oversize = true;
568 continue;
569 }
570 try data.appendSlice(arena, content);
571 try data.appendSlice(arena, protocol.crlf);
572 }
573 if (oversize) {
574 try s.reply(552, "5.3.4 Message exceeds maximum size");
575 return;
576 }
577 switch (s.handler.vtable.message.?(s.handler.context, envelope, data.items)) {
578 .accept => try s.reply(250, "2.0.0 Ok, message accepted"),
579 .reject => |r| try s.reply(r.code, r.text),
580 }
581}
582
583/// Adapts the session's line-based DATA phase into an `Io.Reader` of the
584/// unstuffed message content for `Handler.VTable.messageReader`.
585const DataReader = struct {
586 session_reader: *Io.Reader,
587 interface: Io.Reader,
588 /// Unread remainder of the current line (points into the session
589 /// reader's buffer, which only this reader touches during DATA).
590 line: []const u8 = &.{},
591 line_ending: []const u8 = &.{},
592 finished: bool = false,
593
594 fn stream(io_r: *Io.Reader, w: *Io.Writer, limit: Io.Limit) Io.Reader.StreamError!usize {
595 const dr: *DataReader = @alignCast(@fieldParentPtr("interface", io_r));
596 if (dr.line.len == 0 and dr.line_ending.len == 0) {
597 if (dr.finished) return error.EndOfStream;
598 const raw = protocol.readLine(dr.session_reader) catch return error.ReadFailed;
599 if (std.mem.eql(u8, raw, ".")) {
600 dr.finished = true;
601 return error.EndOfStream;
602 }
603 dr.line = if (raw.len > 0 and raw[0] == '.') raw[1..] else raw;
604 dr.line_ending = protocol.crlf;
605 }
606 const dest = limit.slice(try w.writableSliceGreedy(1));
607 const line_n = @min(dest.len, dr.line.len);
608 @memcpy(dest[0..line_n], dr.line[0..line_n]);
609 dr.line = dr.line[line_n..];
610 var n = line_n;
611 if (dr.line.len == 0) {
612 const ending_n = @min(dest.len - n, dr.line_ending.len);
613 @memcpy(dest[n..][0..ending_n], dr.line_ending[0..ending_n]);
614 dr.line_ending = dr.line_ending[ending_n..];
615 n += ending_n;
616 }
617 w.advance(n);
618 return n;
619 }
620};
621
622fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void {
623 try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text });
624 try s.writer.flush();
625}
626
627/// Discards input through the next newline after `error.LineTooLong`, which
628/// leaves the reader positioned at the start of the oversized line.
629fn discardLine(s: *Server) error{ReadFailed}!void {
630 _ = s.reader.discardDelimiterInclusive('\n') catch |err| switch (err) {
631 error.EndOfStream => {},
632 error.ReadFailed => return error.ReadFailed,
633 };
634}
635
636const TestHandler = struct {
637 from: std.ArrayList(u8) = .empty,
638 recipients: std.ArrayList(u8) = .empty,
639 data: std.ArrayList(u8) = .empty,
640 messages_accepted: usize = 0,
641 reject_recipient: ?[]const u8 = null,
642 declared_size: ?u64 = null,
643 body: Envelope.Body = .unspecified,
644 /// When set, enables the authenticate callback accepting user "alice"
645 /// with this password.
646 password: ?[]const u8 = null,
647
648 fn deinit(h: *TestHandler) void {
649 h.from.deinit(std.testing.allocator);
650 h.recipients.deinit(std.testing.allocator);
651 h.data.deinit(std.testing.allocator);
652 }
653
654 fn handler(h: *TestHandler) Handler {
655 return .{ .context = h, .vtable = if (h.password != null) &.{
656 .authenticate = onAuthenticate,
657 .rcptTo = onRcptTo,
658 .message = onMessage,
659 } else &.{
660 .rcptTo = onRcptTo,
661 .message = onMessage,
662 } };
663 }
664
665 fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool {
666 const h: *TestHandler = @ptrCast(@alignCast(context.?));
667 return std.mem.eql(u8, username, "alice") and
668 std.mem.eql(u8, password, h.password.?);
669 }
670
671 fn onRcptTo(context: ?*anyopaque, to: []const u8) Decision {
672 const h: *TestHandler = @ptrCast(@alignCast(context.?));
673 if (h.reject_recipient) |rejected| {
674 if (std.mem.eql(u8, to, rejected)) return .{ .reject = .{
675 .code = 550,
676 .text = "5.1.1 No such user",
677 } };
678 }
679 return .accept;
680 }
681
682 fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision {
683 const h: *TestHandler = @ptrCast(@alignCast(context.?));
684 const gpa = std.testing.allocator;
685 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} };
686 for (envelope.recipients) |recipient| {
687 h.recipients.appendSlice(gpa, recipient) catch return .{ .reject = .{} };
688 h.recipients.append(gpa, ';') catch return .{ .reject = .{} };
689 }
690 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} };
691 h.messages_accepted += 1;
692 h.declared_size = envelope.declared_size;
693 h.body = envelope.body;
694 return .accept;
695 }
696};
697
698fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 {
699 var reader: Io.Reader = .fixed(input);
700 var writer: Io.Writer = .fixed(out_buf);
701 var session: Server = .init(&reader, &writer, handler, options);
702 try session.run(std.testing.allocator);
703 return writer.buffered();
704}
705
706test run {
707 var h: TestHandler = .{};
708 defer h.deinit();
709
710 var reader: Io.Reader = .fixed("EHLO client.example.org\r\n" ++
711 "MAIL FROM:<alice@example.com>\r\n" ++
712 "RCPT TO:<bob@example.net>\r\n" ++
713 "RCPT TO:<carol@example.net>\r\n" ++
714 "DATA\r\n" ++
715 "Subject: hi\r\n" ++
716 "\r\n" ++
717 "..stuffed line\r\n" ++
718 "body\r\n" ++
719 ".\r\n" ++
720 "QUIT\r\n");
721 var out_buf: [1024]u8 = undefined;
722 var writer: Io.Writer = .fixed(&out_buf);
723
724 var session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" });
725 try session.run(std.testing.allocator);
726 const output = writer.buffered();
727
728 try std.testing.expectEqualStrings("alice@example.com", h.from.items);
729 try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items);
730 try std.testing.expectEqualStrings("Subject: hi\r\n\r\n.stuffed line\r\nbody\r\n", h.data.items);
731 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
732
733 try std.testing.expectEqualStrings(
734 "220 mx.test ESMTP ready\r\n" ++
735 "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++
736 "250 2.1.0 Ok\r\n" ++
737 "250 2.1.5 Ok\r\n" ++
738 "250 2.1.5 Ok\r\n" ++
739 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
740 "250 2.0.0 Ok, message accepted\r\n" ++
741 "221 2.0.0 Bye\r\n",
742 output,
743 );
744}
745
746test "command sequencing is enforced" {
747 var h: TestHandler = .{};
748 defer h.deinit();
749
750 var out_buf: [1024]u8 = undefined;
751 const output = try runScript(
752 "MAIL FROM:<early@example.com>\r\n" ++
753 "EHLO client.example.org\r\n" ++
754 "RCPT TO:<bob@example.net>\r\n" ++
755 "DATA\r\n" ++
756 "QUIT\r\n",
757 &out_buf,
758 h.handler(),
759 .{},
760 );
761
762 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
763 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Send EHLO first") != null);
764 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null);
765 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null);
766}
767
768test "handler can reject a recipient" {
769 var h: TestHandler = .{ .reject_recipient = "nobody@example.net" };
770 defer h.deinit();
771
772 var out_buf: [1024]u8 = undefined;
773 const output = try runScript(
774 "EHLO client.example.org\r\n" ++
775 "MAIL FROM:<alice@example.com>\r\n" ++
776 "RCPT TO:<nobody@example.net>\r\n" ++
777 "RCPT TO:<bob@example.net>\r\n" ++
778 "DATA\r\n" ++
779 "hello\r\n" ++
780 ".\r\n" ++
781 "QUIT\r\n",
782 &out_buf,
783 h.handler(),
784 .{},
785 );
786
787 try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null);
788 try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items);
789 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
790}
791
792test "AUTH PLAIN with initial response" {
793 var h: TestHandler = .{ .password = "secret" };
794 defer h.deinit();
795
796 var out_buf: [1024]u8 = undefined;
797 // base64("\x00alice\x00secret")
798 const output = try runScript(
799 "EHLO client.example.org\r\n" ++
800 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++
801 "MAIL FROM:<alice@example.com>\r\n" ++
802 "RCPT TO:<bob@example.net>\r\n" ++
803 "DATA\r\nauthed mail\r\n.\r\n" ++
804 "QUIT\r\n",
805 &out_buf,
806 h.handler(),
807 .{ .require_auth = true },
808 );
809
810 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH PLAIN LOGIN\r\n") != null);
811 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
812 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
813}
814
815test "AUTH LOGIN challenge exchange" {
816 var h: TestHandler = .{ .password = "secret" };
817 defer h.deinit();
818
819 var out_buf: [1024]u8 = undefined;
820 // base64("alice"), base64("secret")
821 const output = try runScript(
822 "EHLO client.example.org\r\n" ++
823 "AUTH LOGIN\r\n" ++
824 "YWxpY2U=\r\n" ++
825 "c2VjcmV0\r\n" ++
826 "QUIT\r\n",
827 &out_buf,
828 h.handler(),
829 .{},
830 );
831
832 try std.testing.expect(std.mem.indexOf(u8, output, "334 VXNlcm5hbWU6\r\n") != null);
833 try std.testing.expect(std.mem.indexOf(u8, output, "334 UGFzc3dvcmQ6\r\n") != null);
834 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
835}
836
837test "AUTH failures and sequencing" {
838 var h: TestHandler = .{ .password = "secret" };
839 defer h.deinit();
840
841 var out_buf: [2048]u8 = undefined;
842 const output = try runScript(
843 "EHLO client.example.org\r\n" ++
844 "MAIL FROM:<alice@example.com>\r\n" ++ // before auth: 530
845 "AUTH PLAIN AGFsaWNlAHdyb25n\r\n" ++ // wrong password: 535
846 "AUTH GSSAPI\r\n" ++ // unsupported: 504
847 "AUTH PLAIN not!base64\r\n" ++ // 501
848 "AUTH LOGIN\r\n" ++
849 "*\r\n" ++ // cancelled: 501
850 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // correct: 235
851 "AUTH PLAIN AGFsaWNlAHNlY3JldA==\r\n" ++ // again: 503
852 "QUIT\r\n",
853 &out_buf,
854 h.handler(),
855 .{ .require_auth = true },
856 );
857
858 try std.testing.expect(std.mem.indexOf(u8, output, "530 5.7.0") != null);
859 try std.testing.expect(std.mem.indexOf(u8, output, "535 5.7.8") != null);
860 try std.testing.expect(std.mem.indexOf(u8, output, "504 5.5.4") != null);
861 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid base64") != null);
862 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.7.0 Authentication cancelled") != null);
863 try std.testing.expect(std.mem.indexOf(u8, output, "235 2.7.0") != null);
864 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Already authenticated") != null);
865}
866
867test "AUTH without a handler is refused" {
868 var h: TestHandler = .{};
869 defer h.deinit();
870
871 var out_buf: [1024]u8 = undefined;
872 const output = try runScript(
873 "EHLO client.example.org\r\nAUTH PLAIN AGEAYg==\r\nQUIT\r\n",
874 &out_buf,
875 h.handler(),
876 .{},
877 );
878
879 try std.testing.expect(std.mem.indexOf(u8, output, "250-AUTH") == null);
880 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Authentication not enabled") != null);
881}
882
883test "oversize message is rejected but session continues" {
884 var h: TestHandler = .{};
885 defer h.deinit();
886
887 var out_buf: [1024]u8 = undefined;
888 const output = try runScript(
889 "EHLO client.example.org\r\n" ++
890 "MAIL FROM:<alice@example.com>\r\n" ++
891 "RCPT TO:<bob@example.net>\r\n" ++
892 "DATA\r\n" ++
893 "0123456789012345678901234567890123456789\r\n" ++
894 ".\r\n" ++
895 "NOOP\r\n" ++
896 "QUIT\r\n",
897 &out_buf,
898 h.handler(),
899 .{ .max_message_size = 16 },
900 );
901
902 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted);
903 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null);
904 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null);
905}
906
907const StreamTestHandler = struct {
908 collected: std.ArrayList(u8) = .empty,
909 take_only: ?usize = null,
910
911 fn handler(h: *StreamTestHandler) Handler {
912 return .{ .context = h, .vtable = &.{
913 .messageReader = onMessageReader,
914 } };
915 }
916
917 fn onMessageReader(context: ?*anyopaque, envelope: Envelope, message: *Io.Reader) Decision {
918 const h: *StreamTestHandler = @ptrCast(@alignCast(context.?));
919 _ = envelope;
920 const gpa = std.testing.allocator;
921 if (h.take_only) |n| {
922 const bytes = message.take(n) catch return .{ .reject = .{} };
923 h.collected.appendSlice(gpa, bytes) catch return .{ .reject = .{} };
924 return .accept;
925 }
926 message.appendRemaining(gpa, &h.collected, .unlimited) catch return .{ .reject = .{} };
927 return .accept;
928 }
929};
930
931test "streaming message handler receives unstuffed content" {
932 var h: StreamTestHandler = .{};
933 defer h.collected.deinit(std.testing.allocator);
934
935 var out_buf: [1024]u8 = undefined;
936 const output = try runScript(
937 "EHLO client.example.org\r\n" ++
938 "MAIL FROM:<alice@example.com>\r\n" ++
939 "RCPT TO:<bob@example.net>\r\n" ++
940 "DATA\r\n" ++
941 "Subject: streamed\r\n" ++
942 "\r\n" ++
943 "..dot line\r\n" ++
944 "body\r\n" ++
945 ".\r\n" ++
946 "QUIT\r\n",
947 &out_buf,
948 h.handler(),
949 .{},
950 );
951
952 try std.testing.expectEqualStrings(
953 "Subject: streamed\r\n\r\n.dot line\r\nbody\r\n",
954 h.collected.items,
955 );
956 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null);
957}
958
959test "session drains what a streaming handler leaves unread" {
960 var h: StreamTestHandler = .{ .take_only = 7 };
961 defer h.collected.deinit(std.testing.allocator);
962
963 var out_buf: [1024]u8 = undefined;
964 const output = try runScript(
965 "EHLO client.example.org\r\n" ++
966 "MAIL FROM:<alice@example.com>\r\n" ++
967 "RCPT TO:<bob@example.net>\r\n" ++
968 "DATA\r\n" ++
969 "Subject: mostly unread\r\n" ++
970 "lots of body\r\n" ++
971 ".\r\n" ++
972 "NOOP\r\n" ++
973 "QUIT\r\n",
974 &out_buf,
975 h.handler(),
976 .{},
977 );
978
979 try std.testing.expectEqualStrings("Subject", h.collected.items);
980 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok, message accepted") != null);
981 // The NOOP after DATA proves the terminator was consumed.
982 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null);
983}
984
985test "fuzz session with arbitrary client input" {
986 try std.testing.fuzz({}, fuzzSession, .{});
987}
988
989fn fuzzSession(context: void, smith: *std.testing.Smith) !void {
990 _ = context;
991 var input_buf: [2048]u8 = undefined;
992 const input = input_buf[0..smith.value(u11)];
993 smith.bytes(input);
994
995 var h: TestHandler = .{ .password = "secret" };
996 defer h.deinit();
997
998 var reader: Io.Reader = .fixed(input);
999 var discarding: Io.Writer.Discarding = .init(&.{});
1000 var session: Server = .init(&reader, &discarding.writer, h.handler(), .{
1001 .max_message_size = 512,
1002 .max_recipients = 4,
1003 });
1004 // Whatever the "client" sends, the session must fail cleanly, never crash.
1005 session.run(std.testing.allocator) catch {};
1006}
1007
1008test "fuzz collecting and streaming DATA agree" {
1009 try std.testing.fuzz({}, fuzzDataEquivalence, .{});
1010}
1011
1012fn fuzzDataEquivalence(context: void, smith: *std.testing.Smith) !void {
1013 _ = context;
1014 var body_buf: [1024]u8 = undefined;
1015 const body = body_buf[0..smith.value(u10)];
1016 smith.bytes(body);
1017
1018 var script_buf: [1200]u8 = undefined;
1019 const script = std.fmt.bufPrint(
1020 &script_buf,
1021 "EHLO fuzz.example.org\r\n" ++
1022 "MAIL FROM:<a@example.com>\r\n" ++
1023 "RCPT TO:<b@example.net>\r\n" ++
1024 "DATA\r\n{s}\r\n.\r\nQUIT\r\n",
1025 .{body},
1026 ) catch unreachable;
1027
1028 var collecting: TestHandler = .{};
1029 defer collecting.deinit();
1030 var out_buf: [4096]u8 = undefined;
1031 _ = runScript(script, &out_buf, collecting.handler(), .{}) catch {};
1032
1033 var streaming: StreamTestHandler = .{};
1034 defer streaming.collected.deinit(std.testing.allocator);
1035 _ = runScript(script, &out_buf, streaming.handler(), .{}) catch {};
1036
1037 try std.testing.expectEqualSlices(u8, collecting.data.items, streaming.collected.items);
1038}
1039
1040test "MAIL parameters SIZE and BODY are honored" {
1041 var h: TestHandler = .{};
1042 defer h.deinit();
1043
1044 var out_buf: [1024]u8 = undefined;
1045 const output = try runScript(
1046 "EHLO client.example.org\r\n" ++
1047 "MAIL FROM:<alice@example.com> SIZE=42 BODY=8BITMIME\r\n" ++
1048 "RCPT TO:<bob@example.net>\r\n" ++
1049 "DATA\r\nsized body\r\n.\r\n" ++
1050 "QUIT\r\n",
1051 &out_buf,
1052 h.handler(),
1053 .{ .max_message_size = 1024 },
1054 );
1055
1056 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
1057 try std.testing.expectEqual(@as(?u64, 42), h.declared_size);
1058 try std.testing.expectEqual(Envelope.Body.eight_bit_mime, h.body);
1059 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.1.0 Ok") != null);
1060}
1061
1062test "invalid MAIL and RCPT parameters are rejected" {
1063 var h: TestHandler = .{};
1064 defer h.deinit();
1065
1066 var out_buf: [2048]u8 = undefined;
1067 const output = try runScript(
1068 "EHLO client.example.org\r\n" ++
1069 "MAIL FROM:<a@example.com> SIZE=9999\r\n" ++ // over the maximum: 552
1070 "RCPT TO:<b@example.net>\r\n" ++ // that MAIL never started: 503
1071 "MAIL FROM:<a@example.com> SIZE=banana\r\n" ++ // 501
1072 "MAIL FROM:<a@example.com> BODY=BINARYMIME\r\n" ++ // 555
1073 "MAIL FROM:<a@example.com> FUTURE=yes\r\n" ++ // 555
1074 "MAIL FROM:<a@example.com> BODY=7bit\r\n" ++ // ok
1075 "RCPT TO:<b@example.net> NOTIFY=SUCCESS\r\n" ++ // no RCPT params: 555
1076 "RCPT TO:<b@example.net>\r\n" ++
1077 "DATA\r\nbody\r\n.\r\nQUIT\r\n",
1078 &out_buf,
1079 h.handler(),
1080 .{ .max_message_size = 1024 },
1081 );
1082
1083 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null);
1084 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null);
1085 try std.testing.expect(std.mem.indexOf(u8, output, "501 5.5.2 Invalid SIZE parameter") != null);
1086 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unsupported BODY value") != null);
1087 try std.testing.expect(std.mem.indexOf(u8, output, "555 5.5.4 Unrecognized parameter") != null);
1088 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted);
1089 try std.testing.expectEqual(Envelope.Body.seven_bit, h.body);
1090 try std.testing.expectEqual(@as(?u64, null), h.declared_size);
1091}
1092
1093test init {
1094 var reader: Io.Reader = .fixed("");
1095 var out_buf: [16]u8 = undefined;
1096 var writer: Io.Writer = .fixed(&out_buf);
1097 var h: TestHandler = .{};
1098 const session: Server = .init(&reader, &writer, h.handler(), .{ .hostname = "mx.test" });
1099 try std.testing.expectEqualStrings("mx.test", session.options.hostname);
1100 try std.testing.expect(!session.secured);
1101}
1102
1103test Options {
1104 const options: Options = .{};
1105 try std.testing.expectEqualStrings("localhost", options.hostname);
1106 try std.testing.expect(options.tls == null);
1107 try std.testing.expect(!options.require_auth);
1108}
1109
1110test Decision {
1111 const ok: Decision = .accept;
1112 try std.testing.expectEqual(Decision.accept, ok);
1113
1114 const no: Decision = .{ .reject = .{ .code = 451, .text = "4.3.0 Try again later" } };
1115 try std.testing.expectEqual(@as(u16, 451), no.reject.code);
1116}
1117
1118test Envelope {
1119 const envelope: Envelope = .{ .from = "", .recipients = &.{"a@example.com"} };
1120 try std.testing.expectEqual(@as(usize, 1), envelope.recipients.len);
1121 try std.testing.expectEqual(@as(?u64, null), envelope.declared_size);
1122 try std.testing.expectEqual(Envelope.Body.unspecified, envelope.body);
1123}
1124
1125test Handler {
1126 const Callbacks = struct {
1127 fn onMessage(context: ?*anyopaque, envelope: Envelope, message_data: []const u8) Decision {
1128 _ = context;
1129 _ = envelope;
1130 _ = message_data;
1131 return .accept;
1132 }
1133 };
1134 const handler: Handler = .{ .vtable = &.{ .message = Callbacks.onMessage } };
1135 const envelope: Envelope = .{ .from = "", .recipients = &.{} };
1136 try std.testing.expectEqual(Decision.accept, handler.vtable.message.?(null, envelope, ""));
1137}
1138
1139test "protocol gauntlet adapted from exim's test suite" {
1140 // Command sequences and dot-stuffing cases distilled from exim's
1141 // test/scripts/0000-Basic (notably 0019's SMTP syntax-error dialogue
1142 // and 0008/0100's dotted message lines), verified against this server
1143 // with exim's own scriptable test client.
1144 var h: TestHandler = .{};
1145 defer h.deinit();
1146
1147 var out_buf: [4096]u8 = undefined;
1148 const output = try runScript(
1149 "NOOP\r\n" ++
1150 "rhubarb\r\n" ++
1151 "mail from:<x@y>\r\n" ++
1152 "rcpt to:<a@b>\r\n" ++
1153 "ehlo test.client\r\n" ++
1154 "mail\r\n" ++
1155 "mail from:\r\n" ++
1156 "mail from:<>\r\n" ++
1157 "mail from:<x@y>\r\n" ++
1158 "rcpt to:\r\n" ++
1159 "data\r\n" ++
1160 "rset\r\n" ++
1161 "etrn abc\r\n" ++
1162 "vrfy userx\r\n" ++
1163 "help\r\n" ++
1164 "mail from:<ok@test1> SIZE=100 BODY=8BITMIME\r\n" ++
1165 "rcpt to:<userx@test.ex>\r\n" ++
1166 "rcpt to:<@relay.example:route@test.ex>\r\n" ++
1167 "data\r\n" ++
1168 "..that line started with a dot\r\n" ++
1169 ".. and one starting with two dots\r\n" ++
1170 "Message body\r\n" ++
1171 ".\r\n" ++
1172 "mail from:<a@b> SIZE=99999999\r\n" ++
1173 "mail from:<a@b> BODY=BINARYMIME\r\n" ++
1174 "mail from:<a@b> FOO=bar\r\n" ++
1175 "mail from:<a@b> SIZE=nan\r\n" ++
1176 "starttls\r\n" ++
1177 "quit\r\n",
1178 &out_buf,
1179 h.handler(),
1180 .{},
1181 );
1182
1183 try std.testing.expectEqualStrings(
1184 "220 localhost ESMTP ready\r\n" ++
1185 "250 2.0.0 Ok\r\n" ++
1186 "500 5.5.2 Command not recognized\r\n" ++
1187 "503 5.5.1 Send EHLO first\r\n" ++
1188 "503 5.5.1 Need MAIL command first\r\n" ++
1189 "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n" ++
1190 "250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++
1191 "501 5.5.4 Syntax error in parameters\r\n" ++
1192 "501 5.5.4 Syntax error in parameters\r\n" ++
1193 "250 2.1.0 Ok\r\n" ++
1194 "503 5.5.1 Nested MAIL command\r\n" ++
1195 "501 5.5.4 Syntax error in parameters\r\n" ++
1196 "503 5.5.1 Need RCPT command first\r\n" ++
1197 "250 2.0.0 Ok\r\n" ++
1198 "500 5.5.2 Command not recognized\r\n" ++
1199 "252 2.5.2 Cannot VRFY user\r\n" ++
1200 "214 2.0.0 See RFC 5321\r\n" ++
1201 "250 2.1.0 Ok\r\n" ++
1202 "250 2.1.5 Ok\r\n" ++
1203 "250 2.1.5 Ok\r\n" ++
1204 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
1205 "250 2.0.0 Ok, message accepted\r\n" ++
1206 "552 5.3.4 Message size exceeds fixed maximum\r\n" ++
1207 "555 5.5.4 Unsupported BODY value\r\n" ++
1208 "555 5.5.4 Unrecognized parameter\r\n" ++
1209 "501 5.5.2 Invalid SIZE parameter\r\n" ++
1210 "502 5.5.1 STARTTLS not supported\r\n" ++
1211 "221 2.0.0 Bye\r\n",
1212 output,
1213 );
1214 try std.testing.expectEqualStrings("ok@test1", h.from.items);
1215 try std.testing.expectEqualStrings("userx@test.ex;route@test.ex;", h.recipients.items);
1216 try std.testing.expectEqualStrings(
1217 ".that line started with a dot\r\n. and one starting with two dots\r\nMessage body\r\n",
1218 h.data.items,
1219 );
1220 try std.testing.expectEqual(@as(?u64, 100), h.declared_size);
1221 try std.testing.expectEqual(Envelope.Body.eight_bit_mime, h.body);
1222}