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//! Demo CLI for the zig-smtp library.
5//!
6//! zig-smtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth]
7//! [--chunking] [--smtputf8] [--user <u> --password <p>]
8//! [--auth-method plain|login|cram-md5]
9//! [--ret full|hdrs] [--envid <id>]
10//! [--notify never|success,failure,delay] [--orcpt <address>]
11//! [--submitter <mailbox>|<>]
12//! [--lmtp] [--binarymime] <host> <port> <from> <to>...
13//! send a message read from stdin; --tls speaks TLS from the first
14//! byte (port 465 style), --starttls upgrades after EHLO (port 587
15//! style), --insecure skips certificate verification, --user/--password
16//! authenticate with the best advertised mechanism (or the one forced
17//! by --auth-method), and --allow-cleartext-auth permits a mechanism
18//! that sends the password over an unencrypted connection; the DSN
19//! options (RFC 3461) are --ret and --envid on the message and
20//! --notify and --orcpt on every recipient; --binarymime sends the
21//! input as BODY=BINARYMIME over BDAT, byte for byte
22//! zig-smtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]]
23//! [--auth <user>:<pass>] [--lmtp [--fail-delivery <address>]]
24//! <port>
25//! run a debug server on 127.0.0.1 that prints received messages;
26//! --auth requires authentication with the given credentials; with a
27//! certificate and key it advertises and accepts STARTTLS, or speaks
28//! TLS from the first byte with --implicit-tls; --lmtp speaks LMTP
29//! instead of SMTP, where --fail-delivery names one recipient to
30//! report as undeliverable at the end of the message
31
32const std = @import("std");
33const Io = std.Io;
34const smtp = @import("smtp");
35
36pub fn main(init: std.process.Init) !void {
37 const arena = init.arena.allocator();
38 const io = init.io;
39 const args = try init.minimal.args.toSlice(arena);
40
41 if (args.len >= 2 and std.mem.eql(u8, args[1], "send")) {
42 var config: SendConfig = .{};
43 var rest = args[2..];
44 while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) {
45 if (std.mem.eql(u8, rest[0], "--tls")) {
46 config.mode = .tls;
47 } else if (std.mem.eql(u8, rest[0], "--starttls")) {
48 config.mode = .starttls;
49 } else if (std.mem.eql(u8, rest[0], "--insecure")) {
50 config.insecure = true;
51 } else if (std.mem.eql(u8, rest[0], "--allow-cleartext-auth")) {
52 config.allow_cleartext_auth = true;
53 } else if (std.mem.eql(u8, rest[0], "--chunking")) {
54 config.chunking = true;
55 } else if (std.mem.eql(u8, rest[0], "--smtputf8")) {
56 config.smtputf8 = true;
57 } else if (std.mem.eql(u8, rest[0], "--lmtp")) {
58 config.protocol = .lmtp;
59 } else if (std.mem.eql(u8, rest[0], "--binarymime")) {
60 // Binary content can only be framed by BDAT, so this is
61 // chunking plus a declaration of what the chunks hold.
62 config.body = .binary_mime;
63 config.chunking = true;
64 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--submitter")) {
65 // RFC 4954 §5. "<>" is the two characters that say "I do not
66 // know", which is a different claim from not asking.
67 config.submitter = if (std.mem.eql(u8, rest[1], "<>"))
68 .unknown
69 else
70 .{ .mailbox = rest[1] };
71 rest = rest[1..];
72 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--ret")) {
73 config.ret = smtp.protocol.Ret.parse(rest[1]) catch return usage();
74 rest = rest[1..];
75 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--envid")) {
76 config.envid = rest[1];
77 rest = rest[1..];
78 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--notify")) {
79 config.notify = smtp.protocol.Notify.parse(rest[1]) catch return usage();
80 rest = rest[1..];
81 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--orcpt")) {
82 // Applied to every recipient, which is all a one-shot
83 // sender can sensibly do with it.
84 config.orcpt = rest[1];
85 rest = rest[1..];
86 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--user")) {
87 config.username = rest[1];
88 rest = rest[1..];
89 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--password")) {
90 config.password = rest[1];
91 rest = rest[1..];
92 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--auth-method")) {
93 config.auth_method = std.meta.stringToEnum(
94 @TypeOf(config.auth_method),
95 rest[1],
96 ) orelse if (std.mem.eql(u8, rest[1], "cram-md5")) .cram_md5 else return usage();
97 rest = rest[1..];
98 } else {
99 return usage();
100 }
101 rest = rest[1..];
102 }
103 if ((config.username == null) != (config.password == null)) return usage();
104 if (rest.len < 4) return usage();
105 return send(io, arena, config, rest[0], rest[1], rest[2], rest[3..]);
106 }
107 if (args.len >= 2 and std.mem.eql(u8, args[1], "serve")) {
108 var config: ServeConfig = .{};
109 var rest = args[2..];
110 while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) {
111 if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--tls-cert")) {
112 config.cert_path = rest[1];
113 rest = rest[1..];
114 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--tls-key")) {
115 config.key_path = rest[1];
116 rest = rest[1..];
117 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--auth")) {
118 const sep = std.mem.indexOfScalar(u8, rest[1], ':') orelse return usage();
119 config.username = rest[1][0..sep];
120 config.password = rest[1][sep + 1 ..];
121 rest = rest[1..];
122 } else if (std.mem.eql(u8, rest[0], "--implicit-tls")) {
123 config.implicit_tls = true;
124 } else if (std.mem.eql(u8, rest[0], "--lmtp")) {
125 config.protocol = .lmtp;
126 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--fail-delivery")) {
127 config.fail_delivery = rest[1];
128 rest = rest[1..];
129 } else {
130 return usage();
131 }
132 rest = rest[1..];
133 }
134 if (rest.len != 1) return usage();
135 if ((config.cert_path == null) != (config.key_path == null)) return usage();
136 if (config.implicit_tls and config.cert_path == null) return usage();
137 if (config.fail_delivery != null and config.protocol != .lmtp) return usage();
138 return serve(io, arena, config, rest[0]);
139 }
140 return usage();
141}
142
143const ServeConfig = struct {
144 cert_path: ?[]const u8 = null,
145 key_path: ?[]const u8 = null,
146 implicit_tls: bool = false,
147 protocol: smtp.Server.Protocol = .smtp,
148 /// Accepted at RCPT time and then failed at the end of the message,
149 /// which only LMTP can say.
150 fail_delivery: ?[]const u8 = null,
151 username: ?[]const u8 = null,
152 password: ?[]const u8 = null,
153};
154
155const SendConfig = struct {
156 mode: enum { plain, tls, starttls } = .plain,
157 insecure: bool = false,
158 allow_cleartext_auth: bool = false,
159 chunking: bool = false,
160 smtputf8: bool = false,
161 protocol: smtp.Client.Protocol = .smtp,
162 body: ?smtp.protocol.Body = null,
163 submitter: ?smtp.protocol.Submitter = null,
164 ret: ?smtp.protocol.Ret = null,
165 envid: ?[]const u8 = null,
166 notify: ?smtp.protocol.Notify = null,
167 orcpt: ?[]const u8 = null,
168 username: ?[]const u8 = null,
169 password: ?[]const u8 = null,
170 auth_method: enum { auto, plain, login, cram_md5 } = .auto,
171};
172
173fn usage() noreturn {
174 std.log.err(
175 \\usage:
176 \\ zig-smtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth]
177 \\ [--user <u> --password <p>]
178 \\ [--auth-method plain|login|cram-md5]
179 \\ [--ret full|hdrs] [--envid <id>]
180 \\ [--notify never|success,failure,delay] [--orcpt <address>]
181 \\ [--submitter <mailbox>|<>]
182 \\ [--lmtp] [--binarymime] <host> <port> <from> <to>...
183 \\ (message is read from stdin)
184 \\ zig-smtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]]
185 \\ [--auth <user>:<pass>] [--lmtp [--fail-delivery <address>]]
186 \\ <port>
187 , .{});
188 std.process.exit(1);
189}
190
191fn send(
192 io: Io,
193 arena: std.mem.Allocator,
194 config: SendConfig,
195 host_arg: []const u8,
196 port_arg: []const u8,
197 from: []const u8,
198 recipients: []const []const u8,
199) !void {
200 const host = try Io.net.HostName.init(host_arg);
201 const port = try std.fmt.parseInt(u16, port_arg, 10);
202
203 var stdin_buf: [4096]u8 = undefined;
204 var stdin: Io.File.Reader = .init(.stdin(), io, &stdin_buf);
205
206 const stream = try host.connect(io, port, .{ .mode = .stream });
207 defer stream.close(io);
208 // The TLS layer requires stream buffers of at least min_buffer_len.
209 const read_buf = try arena.alloc(u8, smtp.Tls.min_buffer_len);
210 const write_buf = try arena.alloc(u8, smtp.Tls.min_buffer_len);
211 var stream_reader = stream.reader(io, read_buf);
212 var stream_writer = stream.writer(io, write_buf);
213
214 const tls_options: smtp.Tls.Options = .{
215 .host = host_arg,
216 .ca = if (config.insecure) .insecure else .system,
217 };
218 var tls: smtp.Tls = undefined;
219 var tls_active = false;
220 defer if (tls_active) {
221 tls.end() catch {};
222 tls.deinit(arena);
223 };
224
225 var reply_buf: [1024]u8 = undefined;
226 var client: smtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf);
227 client.allow_cleartext_auth = config.allow_cleartext_auth;
228 // The SASL scratch is the caller's; a mechanism never allocates one for
229 // itself and nothing puts one on the stack behind your back.
230 var sasl_scratch: [smtp.Client.sasl_buffer_suggested]u8 = undefined;
231 client.sasl_buffer = &sasl_scratch;
232 client.mode = config.protocol;
233
234 if (config.mode == .tls) {
235 try tls.init(io, arena, &stream_reader.interface, &stream_writer.interface, tls_options);
236 tls_active = true;
237 client.setTransport(tls.reader(), tls.writer(), .encrypted);
238 }
239
240 _ = try client.greet();
241 var extensions = try client.hello("localhost");
242
243 if (config.mode == .starttls) {
244 try client.starttls();
245 try tls.init(io, arena, &stream_reader.interface, &stream_writer.interface, tls_options);
246 tls_active = true;
247 client.setTransport(tls.reader(), tls.writer(), .encrypted);
248 extensions = try client.hello("localhost");
249 }
250
251 if (config.username) |username| {
252 const password = config.password.?;
253 // The mechanisms come from zig-sasl; what is chosen from them is the
254 // caller's business, and this one lets --auth-method force it.
255 var plain: smtp.sasl.Plain = .init(username, password);
256 var login: smtp.sasl.Login = .init(username, password);
257 var cram_md5: smtp.sasl.CramMd5 = .init(username, password);
258 const offered: []const smtp.sasl.Client = switch (config.auth_method) {
259 // In order of preference, which `selectFromList` reads as such:
260 // PLAIN because every server implements it correctly, CRAM-MD5
261 // last because it is the oldest. On a carrier with no encryption
262 // the first two are skipped and it is the only one left.
263 .auto => &.{ plain.client(), login.client(), cram_md5.client() },
264 .plain => &.{plain.client()},
265 .login => &.{login.client()},
266 .cram_md5 => &.{cram_md5.client()},
267 };
268 const mechanism = smtp.sasl.Client.selectFromList(
269 offered,
270 extensions.auth,
271 client.security == .encrypted or client.allow_cleartext_auth,
272 ) orelse {
273 std.log.err(
274 "no usable mechanism; the server offers: {s}{s}",
275 .{
276 if (extensions.auth.len == 0) "(none)" else extensions.auth,
277 // The common case by far: everything on offer sends the
278 // password, and this connection is not encrypted.
279 if (client.security == .plaintext and !client.allow_cleartext_auth)
280 ", and this connection is not encrypted " ++
281 "(use --starttls or --tls, or --allow-cleartext-auth)"
282 else
283 "",
284 },
285 );
286 return error.NoSupportedMechanism;
287 };
288 client.authenticate(mechanism) catch |err| {
289 switch (err) {
290 error.AuthenticationFailed => {
291 const reply = client.last_reply.?;
292 // 5.7.8 is "bad credentials" where 4.7.0 is "try again";
293 // the three-digit code says neither.
294 if (reply.enhanced()) |status| {
295 std.log.err("authentication failed: {d} {f} {s}", .{
296 reply.code,
297 status,
298 reply.message(),
299 });
300 } else {
301 std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text });
302 }
303 },
304 error.ServerNotAuthenticated => std.log.err(
305 "the server accepted the login without proving itself; " ++
306 "this is not the server it claims to be",
307 .{},
308 ),
309 error.InsecureTransport => std.log.err(
310 "refusing to send credentials over an unencrypted connection; " ++
311 "use --starttls or --tls, or pass --allow-cleartext-auth",
312 .{},
313 ),
314 else => {},
315 }
316 return err;
317 };
318 }
319
320 if (config.chunking and !extensions.chunking) {
321 std.log.err("server does not advertise CHUNKING", .{});
322 return error.ChunkingNotAdvertised;
323 }
324 if (config.smtputf8 and !extensions.smtputf8) {
325 std.log.err("server does not advertise SMTPUTF8", .{});
326 return error.SmtpUtf8NotAdvertised;
327 }
328 if (config.body == .binary_mime and !extensions.binary_mime) {
329 // RFC 3030 is absolute about this one: without the advertisement,
330 // binary must not be sent under any circumstances.
331 std.log.err("server does not advertise BINARYMIME", .{});
332 return error.BinaryMimeNotAdvertised;
333 }
334 if (config.submitter != null and extensions.auth.len == 0) {
335 // RFC 4954 §5 obliges a server to take the parameter only if it
336 // advertised AUTH; one that did not will answer 555.
337 std.log.err("server does not advertise AUTH, so it will not take AUTH=", .{});
338 return error.AuthNotAdvertised;
339 }
340 const wants_dsn = config.ret != null or config.envid != null or
341 config.notify != null or config.orcpt != null;
342 if (wants_dsn and !extensions.dsn) {
343 // A conforming server answers an unrecognized parameter with 555,
344 // so this is only a clearer way to say the same thing.
345 std.log.err("server does not advertise DSN", .{});
346 return error.DsnNotAdvertised;
347 }
348 transact(&client, config, from, recipients, &stdin.interface) catch |err| {
349 if (err == error.UnexpectedReply) {
350 const reply = client.last_reply.?;
351 if (reply.enhanced()) |status| {
352 std.log.err("server rejected: {d} {f} {s}", .{
353 reply.code,
354 status,
355 reply.message(),
356 });
357 } else {
358 std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text });
359 }
360 }
361 return err;
362 };
363 try client.quit();
364 std.log.info("message sent to {d} recipient(s)", .{recipients.len});
365}
366
367/// Runs the mail transaction, streaming the message from `message` so
368/// arbitrarily large input never has to fit in memory.
369fn transact(
370 client: *smtp.Client,
371 config: SendConfig,
372 from: []const u8,
373 recipients: []const []const u8,
374 message: *Io.Reader,
375) (smtp.Client.Error || smtp.Client.ArgumentError)!void {
376 try client.mail(from, .{
377 .smtputf8 = config.smtputf8,
378 .auth = config.submitter,
379 .body = config.body,
380 .ret = config.ret,
381 .envid = config.envid,
382 });
383 for (recipients) |recipient| try client.rcpt(recipient, .{
384 .notify = config.notify,
385 .orcpt = if (config.orcpt) |address|
386 .{ .addr_type = "rfc822", .address = address }
387 else
388 null,
389 });
390 if (config.chunking) {
391 // BDAT sends the input verbatim (no line-ending normalization).
392 while (true) {
393 const chunk = message.peekGreedy(1) catch |err| switch (err) {
394 error.EndOfStream => break,
395 error.ReadFailed => return error.ReadFailed,
396 };
397 try client.bdat(chunk, false);
398 message.toss(chunk.len);
399 }
400 try client.bdat("", true);
401 } else {
402 var data_writer = try client.data();
403 while (true) {
404 const chunk = message.peekGreedy(1) catch |err| switch (err) {
405 error.EndOfStream => break,
406 error.ReadFailed => return error.ReadFailed,
407 };
408 try data_writer.interface.writeAll(chunk);
409 message.toss(chunk.len);
410 }
411 // In LMTP there is one verdict per recipient rather than one for
412 // the message, and reporting them individually is the only reason
413 // to be speaking it.
414 var verdicts = try data_writer.endResults();
415 var failed = false;
416 while (try verdicts.next()) |reply| {
417 if (config.protocol == .lmtp) {
418 std.log.info("{s}: {d} {s}", .{
419 recipients[verdicts.index - 1],
420 reply.code,
421 reply.text,
422 });
423 }
424 if (!reply.isPositiveCompletion()) failed = true;
425 }
426 // Each verdict was reported above, so the error only has to say
427 // that one of them was a refusal.
428 if (failed) return if (config.protocol == .lmtp)
429 error.RecipientRejected
430 else
431 error.UnexpectedReply;
432 }
433}
434
435fn serve(io: Io, gpa: std.mem.Allocator, config: ServeConfig, port_arg: []const u8) !void {
436 const port = try std.fmt.parseInt(u16, port_arg, 10);
437 const address: Io.net.IpAddress = .{ .ip4 = .loopback(port) };
438 var listener = try address.listen(io, .{});
439 defer listener.deinit(io);
440
441 var auth: ?smtp.tls.config.CertKeyPair = if (config.cert_path) |cert_path|
442 try .fromFilePath(gpa, io, .cwd(), cert_path, config.key_path.?)
443 else
444 null;
445 const tls_options: ?smtp.Server.TlsOptions = if (auth) |*a| .{
446 .io = io,
447 .auth = a,
448 .mode = if (config.implicit_tls) .implicit else .starttls,
449 } else null;
450 std.log.info("listening on 127.0.0.1:{d}{s}", .{
451 port,
452 if (tls_options) |t| switch (t.mode) {
453 .starttls => " with STARTTLS",
454 .implicit => " with implicit TLS",
455 } else "",
456 });
457
458 var stdout_buf: [4096]u8 = undefined;
459 var stdout: Io.File.Writer = .init(.stdout(), io, &stdout_buf);
460
461 var printer: MessagePrinter = .{
462 .out = &stdout.interface,
463 .username = config.username,
464 .password = config.password,
465 .fail_delivery = config.fail_delivery,
466 };
467 // The credential check, which PLAIN and LOGIN share, and the password
468 // lookup CRAM-MD5 needs instead. Both close over the same one account.
469 const check: smtp.sasl.Server.PasswordCheck = .{
470 .context = &printer,
471 .verify = MessagePrinter.verify,
472 };
473 const passwords: smtp.sasl.Server.PasswordLookup = .{
474 .context = &printer,
475 .lookup = MessagePrinter.lookup,
476 };
477
478 var connections: usize = 0;
479 while (true) {
480 const stream = try listener.accept(io);
481 defer stream.close(io);
482 connections += 1;
483
484 // A fresh set per connection: the mechanisms hold per-exchange state,
485 // and CRAM-MD5's challenge must not repeat between them.
486 // RFC 2195 wants a challenge that never repeats. A counter and the
487 // clock is what a real server would use, plus its hostname.
488 var challenge_buf: [128]u8 = undefined;
489 const challenge = std.fmt.bufPrint(
490 &challenge_buf,
491 "<{d}.{d}@localhost>",
492 .{ connections, Io.Clock.real.now(io).nanoseconds },
493 ) catch unreachable;
494 var sasl_scratch: [smtp.Server.sasl_buffer_suggested]u8 = undefined;
495 var plain: smtp.sasl.PlainServer = .init(check);
496 var login: smtp.sasl.LoginServer = .init(check);
497 var cram_md5: smtp.sasl.CramMd5Server = .init(challenge, passwords);
498 const mechanisms: []const smtp.sasl.Server = if (config.username == null)
499 &.{}
500 else
501 &.{ plain.server(), login.server(), cram_md5.server() };
502
503 // Sized for the TLS handshake, which runs over the raw stream.
504 const read_buf = try gpa.alloc(u8, smtp.tls.input_buffer_len);
505 defer gpa.free(read_buf);
506 const write_buf = try gpa.alloc(u8, smtp.tls.output_buffer_len);
507 defer gpa.free(write_buf);
508 var stream_reader = stream.reader(io, read_buf);
509 var stream_writer = stream.writer(io, write_buf);
510 var session: smtp.Server = .init(
511 &stream_reader.interface,
512 &stream_writer.interface,
513 .{ .context = &printer, .vtable = &.{
514 .message = MessagePrinter.onMessage,
515 .recipientResult = MessagePrinter.onRecipientResult,
516 } },
517 .{
518 .protocol = config.protocol,
519 .hostname = "localhost",
520 .tls = tls_options,
521 .auth_mechanisms = mechanisms,
522 .sasl_buffer = &sasl_scratch,
523 .require_auth = config.username != null,
524 },
525 );
526 session.run(gpa) catch |err| {
527 std.log.warn("session ended with error: {t}", .{err});
528 };
529 }
530}
531
532const MessagePrinter = struct {
533 out: *Io.Writer,
534 username: ?[]const u8 = null,
535 password: ?[]const u8 = null,
536 fail_delivery: ?[]const u8 = null,
537
538 /// What PLAIN and LOGIN ask: is this password right? The answer is the
539 /// identity to report, which for this one-account server is the username.
540 fn verify(
541 context: ?*anyopaque,
542 authzid: []const u8,
543 authcid: []const u8,
544 password: []const u8,
545 ) ?[]const u8 {
546 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?));
547 // Acting as somebody else is not a thing this server does.
548 if (authzid.len != 0) return null;
549 if (!std.mem.eql(u8, authcid, printer.username.?)) return null;
550 if (!std.mem.eql(u8, password, printer.password.?)) return null;
551 return printer.username.?;
552 }
553
554 /// What CRAM-MD5 asks instead: the password itself, because it has to
555 /// compute the same HMAC the client did.
556 fn lookup(context: ?*anyopaque, username: []const u8) ?[]const u8 {
557 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?));
558 if (!std.mem.eql(u8, username, printer.username.?)) return null;
559 return printer.password.?;
560 }
561
562 fn onMessage(context: ?*anyopaque, envelope: smtp.Server.Envelope, data: []const u8) smtp.Server.Decision {
563 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?));
564 printer.print(envelope, data) catch
565 return .{ .reject = .{ .code = 451, .text = "4.3.0 Local error" } };
566 return .accept;
567 }
568
569 /// LMTP's per-recipient verdict. Everything was already printed by
570 /// `onMessage`; this only reports the one address `--fail-delivery`
571 /// names as undeliverable, which is the outcome SMTP has no way to
572 /// express for one recipient out of several.
573 fn onRecipientResult(
574 context: ?*anyopaque,
575 envelope: smtp.Server.Envelope,
576 index: usize,
577 ) smtp.Server.Decision {
578 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?));
579 const failing = printer.fail_delivery orelse return .accept;
580 if (std.mem.eql(u8, envelope.recipients[index].address, failing))
581 return .{ .reject = .{ .code = 550, .text = "5.2.1 Mailbox disabled" } };
582 return .accept;
583 }
584
585 fn print(printer: *MessagePrinter, envelope: smtp.Server.Envelope, data: []const u8) !void {
586 try printer.out.print("--- message from <{s}> to", .{envelope.from});
587 for (envelope.recipients) |recipient| {
588 try printer.out.print(" <{s}>", .{recipient.address});
589 // DSN parameters, printed so that a session can be checked from
590 // the outside (which is what the interop test does).
591 if (recipient.notify) |notify| try printer.out.print(" NOTIFY={f}", .{notify});
592 if (recipient.orcpt) |orcpt| try printer.out.print(" ORCPT={f}", .{orcpt});
593 }
594 if (envelope.ret) |ret| try printer.out.print(" RET={f}", .{ret});
595 if (envelope.envid) |envid| try printer.out.print(" ENVID={s}", .{envid});
596 // The decoded mailbox rather than `{f}`, which would print the xtext
597 // that went over the wire.
598 if (envelope.submitter) |who| switch (who) {
599 .unknown => try printer.out.writeAll(" AUTH=<>"),
600 .mailbox => |mailbox| try printer.out.print(" AUTH={s}", .{mailbox}),
601 };
602 if (envelope.authenticated_as) |who| try printer.out.print(" (authenticated as {s})", .{who});
603 try printer.out.print(" ({d} bytes)\n{s}---\n", .{ data.len, data });
604 try printer.out.flush();
605 }
606};