// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Demo CLI for the zig-smtp library. //! //! zig-smtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth] //! [--chunking] [--smtputf8] [--user --password

] //! [--auth-method plain|login|cram-md5] //! [--ret full|hdrs] [--envid ] //! [--notify never|success,failure,delay] [--orcpt

] //! [--submitter |<>] //! [--lmtp] [--binarymime] ... //! send a message read from stdin; --tls speaks TLS from the first //! byte (port 465 style), --starttls upgrades after EHLO (port 587 //! style), --insecure skips certificate verification, --user/--password //! authenticate with the best advertised mechanism (or the one forced //! by --auth-method), and --allow-cleartext-auth permits a mechanism //! that sends the password over an unencrypted connection; the DSN //! options (RFC 3461) are --ret and --envid on the message and //! --notify and --orcpt on every recipient; --binarymime sends the //! input as BODY=BINARYMIME over BDAT, byte for byte //! zig-smtp serve [--tls-cert --tls-key [--implicit-tls]] //! [--auth :] [--lmtp [--fail-delivery
]] //! //! run a debug server on 127.0.0.1 that prints received messages; //! --auth requires authentication with the given credentials; with a //! certificate and key it advertises and accepts STARTTLS, or speaks //! TLS from the first byte with --implicit-tls; --lmtp speaks LMTP //! instead of SMTP, where --fail-delivery names one recipient to //! report as undeliverable at the end of the message const std = @import("std"); const Io = std.Io; const smtp = @import("smtp"); pub fn main(init: std.process.Init) !void { const arena = init.arena.allocator(); const io = init.io; const args = try init.minimal.args.toSlice(arena); if (args.len >= 2 and std.mem.eql(u8, args[1], "send")) { var config: SendConfig = .{}; var rest = args[2..]; while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) { if (std.mem.eql(u8, rest[0], "--tls")) { config.mode = .tls; } else if (std.mem.eql(u8, rest[0], "--starttls")) { config.mode = .starttls; } else if (std.mem.eql(u8, rest[0], "--insecure")) { config.insecure = true; } else if (std.mem.eql(u8, rest[0], "--allow-cleartext-auth")) { config.allow_cleartext_auth = true; } else if (std.mem.eql(u8, rest[0], "--chunking")) { config.chunking = true; } else if (std.mem.eql(u8, rest[0], "--smtputf8")) { config.smtputf8 = true; } else if (std.mem.eql(u8, rest[0], "--lmtp")) { config.protocol = .lmtp; } else if (std.mem.eql(u8, rest[0], "--binarymime")) { // Binary content can only be framed by BDAT, so this is // chunking plus a declaration of what the chunks hold. config.body = .binary_mime; config.chunking = true; } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--submitter")) { // RFC 4954 §5. "<>" is the two characters that say "I do not // know", which is a different claim from not asking. config.submitter = if (std.mem.eql(u8, rest[1], "<>")) .unknown else .{ .mailbox = rest[1] }; rest = rest[1..]; } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--ret")) { config.ret = smtp.protocol.Ret.parse(rest[1]) catch return usage(); rest = rest[1..]; } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--envid")) { config.envid = rest[1]; rest = rest[1..]; } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--notify")) { config.notify = smtp.protocol.Notify.parse(rest[1]) catch return usage(); rest = rest[1..]; } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--orcpt")) { // Applied to every recipient, which is all a one-shot // sender can sensibly do with it. config.orcpt = rest[1]; rest = rest[1..]; } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--user")) { config.username = rest[1]; rest = rest[1..]; } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--password")) { config.password = rest[1]; rest = rest[1..]; } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--auth-method")) { config.auth_method = std.meta.stringToEnum( @TypeOf(config.auth_method), rest[1], ) orelse if (std.mem.eql(u8, rest[1], "cram-md5")) .cram_md5 else return usage(); rest = rest[1..]; } else { return usage(); } rest = rest[1..]; } if ((config.username == null) != (config.password == null)) return usage(); if (rest.len < 4) return usage(); return send(io, arena, config, rest[0], rest[1], rest[2], rest[3..]); } if (args.len >= 2 and std.mem.eql(u8, args[1], "serve")) { var config: ServeConfig = .{}; var rest = args[2..]; while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) { if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--tls-cert")) { config.cert_path = rest[1]; rest = rest[1..]; } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--tls-key")) { config.key_path = rest[1]; rest = rest[1..]; } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--auth")) { const sep = std.mem.indexOfScalar(u8, rest[1], ':') orelse return usage(); config.username = rest[1][0..sep]; config.password = rest[1][sep + 1 ..]; rest = rest[1..]; } else if (std.mem.eql(u8, rest[0], "--implicit-tls")) { config.implicit_tls = true; } else if (std.mem.eql(u8, rest[0], "--lmtp")) { config.protocol = .lmtp; } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--fail-delivery")) { config.fail_delivery = rest[1]; rest = rest[1..]; } else { return usage(); } rest = rest[1..]; } if (rest.len != 1) return usage(); if ((config.cert_path == null) != (config.key_path == null)) return usage(); if (config.implicit_tls and config.cert_path == null) return usage(); if (config.fail_delivery != null and config.protocol != .lmtp) return usage(); return serve(io, arena, config, rest[0]); } return usage(); } const ServeConfig = struct { cert_path: ?[]const u8 = null, key_path: ?[]const u8 = null, implicit_tls: bool = false, protocol: smtp.Server.Protocol = .smtp, /// Accepted at RCPT time and then failed at the end of the message, /// which only LMTP can say. fail_delivery: ?[]const u8 = null, username: ?[]const u8 = null, password: ?[]const u8 = null, }; const SendConfig = struct { mode: enum { plain, tls, starttls } = .plain, insecure: bool = false, allow_cleartext_auth: bool = false, chunking: bool = false, smtputf8: bool = false, protocol: smtp.Client.Protocol = .smtp, body: ?smtp.protocol.Body = null, submitter: ?smtp.protocol.Submitter = null, ret: ?smtp.protocol.Ret = null, envid: ?[]const u8 = null, notify: ?smtp.protocol.Notify = null, orcpt: ?[]const u8 = null, username: ?[]const u8 = null, password: ?[]const u8 = null, auth_method: enum { auto, plain, login, cram_md5 } = .auto, }; fn usage() noreturn { std.log.err( \\usage: \\ zig-smtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth] \\ [--user --password

] \\ [--auth-method plain|login|cram-md5] \\ [--ret full|hdrs] [--envid ] \\ [--notify never|success,failure,delay] [--orcpt

] \\ [--submitter |<>] \\ [--lmtp] [--binarymime] ... \\ (message is read from stdin) \\ zig-smtp serve [--tls-cert --tls-key [--implicit-tls]] \\ [--auth :] [--lmtp [--fail-delivery
]] \\ , .{}); std.process.exit(1); } fn send( io: Io, arena: std.mem.Allocator, config: SendConfig, host_arg: []const u8, port_arg: []const u8, from: []const u8, recipients: []const []const u8, ) !void { const host = try Io.net.HostName.init(host_arg); const port = try std.fmt.parseInt(u16, port_arg, 10); var stdin_buf: [4096]u8 = undefined; var stdin: Io.File.Reader = .init(.stdin(), io, &stdin_buf); const stream = try host.connect(io, port, .{ .mode = .stream }); defer stream.close(io); // The TLS layer requires stream buffers of at least min_buffer_len. const read_buf = try arena.alloc(u8, smtp.Tls.min_buffer_len); const write_buf = try arena.alloc(u8, smtp.Tls.min_buffer_len); var stream_reader = stream.reader(io, read_buf); var stream_writer = stream.writer(io, write_buf); const tls_options: smtp.Tls.Options = .{ .host = host_arg, .ca = if (config.insecure) .insecure else .system, }; var tls: smtp.Tls = undefined; var tls_active = false; defer if (tls_active) { tls.end() catch {}; tls.deinit(arena); }; var reply_buf: [1024]u8 = undefined; var client: smtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); client.allow_cleartext_auth = config.allow_cleartext_auth; // The SASL scratch is the caller's; a mechanism never allocates one for // itself and nothing puts one on the stack behind your back. var sasl_scratch: [smtp.Client.sasl_buffer_suggested]u8 = undefined; client.sasl_buffer = &sasl_scratch; client.mode = config.protocol; if (config.mode == .tls) { try tls.init(io, arena, &stream_reader.interface, &stream_writer.interface, tls_options); tls_active = true; client.setTransport(tls.reader(), tls.writer(), .encrypted); } _ = try client.greet(); var extensions = try client.hello("localhost"); if (config.mode == .starttls) { try client.starttls(); try tls.init(io, arena, &stream_reader.interface, &stream_writer.interface, tls_options); tls_active = true; client.setTransport(tls.reader(), tls.writer(), .encrypted); extensions = try client.hello("localhost"); } if (config.username) |username| { const password = config.password.?; // The mechanisms come from zig-sasl; what is chosen from them is the // caller's business, and this one lets --auth-method force it. var plain: smtp.sasl.Plain = .init(username, password); var login: smtp.sasl.Login = .init(username, password); var cram_md5: smtp.sasl.CramMd5 = .init(username, password); const offered: []const smtp.sasl.Client = switch (config.auth_method) { // In order of preference, which `selectFromList` reads as such: // PLAIN because every server implements it correctly, CRAM-MD5 // last because it is the oldest. On a carrier with no encryption // the first two are skipped and it is the only one left. .auto => &.{ plain.client(), login.client(), cram_md5.client() }, .plain => &.{plain.client()}, .login => &.{login.client()}, .cram_md5 => &.{cram_md5.client()}, }; const mechanism = smtp.sasl.Client.selectFromList( offered, extensions.auth, client.security == .encrypted or client.allow_cleartext_auth, ) orelse { std.log.err( "no usable mechanism; the server offers: {s}{s}", .{ if (extensions.auth.len == 0) "(none)" else extensions.auth, // The common case by far: everything on offer sends the // password, and this connection is not encrypted. if (client.security == .plaintext and !client.allow_cleartext_auth) ", and this connection is not encrypted " ++ "(use --starttls or --tls, or --allow-cleartext-auth)" else "", }, ); return error.NoSupportedMechanism; }; client.authenticate(mechanism) catch |err| { switch (err) { error.AuthenticationFailed => { const reply = client.last_reply.?; std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text }); }, error.ServerNotAuthenticated => std.log.err( "the server accepted the login without proving itself; " ++ "this is not the server it claims to be", .{}, ), error.InsecureTransport => std.log.err( "refusing to send credentials over an unencrypted connection; " ++ "use --starttls or --tls, or pass --allow-cleartext-auth", .{}, ), else => {}, } return err; }; } if (config.chunking and !extensions.chunking) { std.log.err("server does not advertise CHUNKING", .{}); return error.ChunkingNotAdvertised; } if (config.smtputf8 and !extensions.smtputf8) { std.log.err("server does not advertise SMTPUTF8", .{}); return error.SmtpUtf8NotAdvertised; } if (config.body == .binary_mime and !extensions.binary_mime) { // RFC 3030 is absolute about this one: without the advertisement, // binary must not be sent under any circumstances. std.log.err("server does not advertise BINARYMIME", .{}); return error.BinaryMimeNotAdvertised; } if (config.submitter != null and extensions.auth.len == 0) { // RFC 4954 §5 obliges a server to take the parameter only if it // advertised AUTH; one that did not will answer 555. std.log.err("server does not advertise AUTH, so it will not take AUTH=", .{}); return error.AuthNotAdvertised; } const wants_dsn = config.ret != null or config.envid != null or config.notify != null or config.orcpt != null; if (wants_dsn and !extensions.dsn) { // A conforming server answers an unrecognized parameter with 555, // so this is only a clearer way to say the same thing. std.log.err("server does not advertise DSN", .{}); return error.DsnNotAdvertised; } transact(&client, config, from, recipients, &stdin.interface) catch |err| { if (err == error.UnexpectedReply) { const reply = client.last_reply.?; std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text }); } return err; }; try client.quit(); std.log.info("message sent to {d} recipient(s)", .{recipients.len}); } /// Runs the mail transaction, streaming the message from `message` so /// arbitrarily large input never has to fit in memory. fn transact( client: *smtp.Client, config: SendConfig, from: []const u8, recipients: []const []const u8, message: *Io.Reader, ) (smtp.Client.Error || smtp.Client.ArgumentError)!void { try client.mail(from, .{ .smtputf8 = config.smtputf8, .auth = config.submitter, .body = config.body, .ret = config.ret, .envid = config.envid, }); for (recipients) |recipient| try client.rcpt(recipient, .{ .notify = config.notify, .orcpt = if (config.orcpt) |address| .{ .addr_type = "rfc822", .address = address } else null, }); if (config.chunking) { // BDAT sends the input verbatim (no line-ending normalization). while (true) { const chunk = message.peekGreedy(1) catch |err| switch (err) { error.EndOfStream => break, error.ReadFailed => return error.ReadFailed, }; try client.bdat(chunk, false); message.toss(chunk.len); } try client.bdat("", true); } else { var data_writer = try client.data(); while (true) { const chunk = message.peekGreedy(1) catch |err| switch (err) { error.EndOfStream => break, error.ReadFailed => return error.ReadFailed, }; try data_writer.interface.writeAll(chunk); message.toss(chunk.len); } // In LMTP there is one verdict per recipient rather than one for // the message, and reporting them individually is the only reason // to be speaking it. var verdicts = try data_writer.endResults(); var failed = false; while (try verdicts.next()) |reply| { if (config.protocol == .lmtp) { std.log.info("{s}: {d} {s}", .{ recipients[verdicts.index - 1], reply.code, reply.text, }); } if (!reply.isPositiveCompletion()) failed = true; } // Each verdict was reported above, so the error only has to say // that one of them was a refusal. if (failed) return if (config.protocol == .lmtp) error.RecipientRejected else error.UnexpectedReply; } } fn serve(io: Io, gpa: std.mem.Allocator, config: ServeConfig, port_arg: []const u8) !void { const port = try std.fmt.parseInt(u16, port_arg, 10); const address: Io.net.IpAddress = .{ .ip4 = .loopback(port) }; var listener = try address.listen(io, .{}); defer listener.deinit(io); var auth: ?smtp.tls.config.CertKeyPair = if (config.cert_path) |cert_path| try .fromFilePath(gpa, io, .cwd(), cert_path, config.key_path.?) else null; const tls_options: ?smtp.Server.TlsOptions = if (auth) |*a| .{ .io = io, .auth = a, .mode = if (config.implicit_tls) .implicit else .starttls, } else null; std.log.info("listening on 127.0.0.1:{d}{s}", .{ port, if (tls_options) |t| switch (t.mode) { .starttls => " with STARTTLS", .implicit => " with implicit TLS", } else "", }); var stdout_buf: [4096]u8 = undefined; var stdout: Io.File.Writer = .init(.stdout(), io, &stdout_buf); var printer: MessagePrinter = .{ .out = &stdout.interface, .username = config.username, .password = config.password, .fail_delivery = config.fail_delivery, }; // The credential check, which PLAIN and LOGIN share, and the password // lookup CRAM-MD5 needs instead. Both close over the same one account. const check: smtp.sasl.Server.PasswordCheck = .{ .context = &printer, .verify = MessagePrinter.verify, }; const passwords: smtp.sasl.Server.PasswordLookup = .{ .context = &printer, .lookup = MessagePrinter.lookup, }; var connections: usize = 0; while (true) { const stream = try listener.accept(io); defer stream.close(io); connections += 1; // A fresh set per connection: the mechanisms hold per-exchange state, // and CRAM-MD5's challenge must not repeat between them. // RFC 2195 wants a challenge that never repeats. A counter and the // clock is what a real server would use, plus its hostname. var challenge_buf: [128]u8 = undefined; const challenge = std.fmt.bufPrint( &challenge_buf, "<{d}.{d}@localhost>", .{ connections, Io.Clock.real.now(io).nanoseconds }, ) catch unreachable; var sasl_scratch: [smtp.Server.sasl_buffer_suggested]u8 = undefined; var plain: smtp.sasl.PlainServer = .init(check); var login: smtp.sasl.LoginServer = .init(check); var cram_md5: smtp.sasl.CramMd5Server = .init(challenge, passwords); const mechanisms: []const smtp.sasl.Server = if (config.username == null) &.{} else &.{ plain.server(), login.server(), cram_md5.server() }; // Sized for the TLS handshake, which runs over the raw stream. const read_buf = try gpa.alloc(u8, smtp.tls.input_buffer_len); defer gpa.free(read_buf); const write_buf = try gpa.alloc(u8, smtp.tls.output_buffer_len); defer gpa.free(write_buf); var stream_reader = stream.reader(io, read_buf); var stream_writer = stream.writer(io, write_buf); var session: smtp.Server = .init( &stream_reader.interface, &stream_writer.interface, .{ .context = &printer, .vtable = &.{ .message = MessagePrinter.onMessage, .recipientResult = MessagePrinter.onRecipientResult, } }, .{ .protocol = config.protocol, .hostname = "localhost", .tls = tls_options, .auth_mechanisms = mechanisms, .sasl_buffer = &sasl_scratch, .require_auth = config.username != null, }, ); session.run(gpa) catch |err| { std.log.warn("session ended with error: {t}", .{err}); }; } } const MessagePrinter = struct { out: *Io.Writer, username: ?[]const u8 = null, password: ?[]const u8 = null, fail_delivery: ?[]const u8 = null, /// What PLAIN and LOGIN ask: is this password right? The answer is the /// identity to report, which for this one-account server is the username. fn verify( context: ?*anyopaque, authzid: []const u8, authcid: []const u8, password: []const u8, ) ?[]const u8 { const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); // Acting as somebody else is not a thing this server does. if (authzid.len != 0) return null; if (!std.mem.eql(u8, authcid, printer.username.?)) return null; if (!std.mem.eql(u8, password, printer.password.?)) return null; return printer.username.?; } /// What CRAM-MD5 asks instead: the password itself, because it has to /// compute the same HMAC the client did. fn lookup(context: ?*anyopaque, username: []const u8) ?[]const u8 { const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); if (!std.mem.eql(u8, username, printer.username.?)) return null; return printer.password.?; } fn onMessage(context: ?*anyopaque, envelope: smtp.Server.Envelope, data: []const u8) smtp.Server.Decision { const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); printer.print(envelope, data) catch return .{ .reject = .{ .code = 451, .text = "4.3.0 Local error" } }; return .accept; } /// LMTP's per-recipient verdict. Everything was already printed by /// `onMessage`; this only reports the one address `--fail-delivery` /// names as undeliverable, which is the outcome SMTP has no way to /// express for one recipient out of several. fn onRecipientResult( context: ?*anyopaque, envelope: smtp.Server.Envelope, index: usize, ) smtp.Server.Decision { const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); const failing = printer.fail_delivery orelse return .accept; if (std.mem.eql(u8, envelope.recipients[index].address, failing)) return .{ .reject = .{ .code = 550, .text = "5.2.1 Mailbox disabled" } }; return .accept; } fn print(printer: *MessagePrinter, envelope: smtp.Server.Envelope, data: []const u8) !void { try printer.out.print("--- message from <{s}> to", .{envelope.from}); for (envelope.recipients) |recipient| { try printer.out.print(" <{s}>", .{recipient.address}); // DSN parameters, printed so that a session can be checked from // the outside (which is what the interop test does). if (recipient.notify) |notify| try printer.out.print(" NOTIFY={f}", .{notify}); if (recipient.orcpt) |orcpt| try printer.out.print(" ORCPT={f}", .{orcpt}); } if (envelope.ret) |ret| try printer.out.print(" RET={f}", .{ret}); if (envelope.envid) |envid| try printer.out.print(" ENVID={s}", .{envid}); // The decoded mailbox rather than `{f}`, which would print the xtext // that went over the wire. if (envelope.submitter) |who| switch (who) { .unknown => try printer.out.writeAll(" AUTH=<>"), .mailbox => |mailbox| try printer.out.print(" AUTH={s}", .{mailbox}), }; if (envelope.authenticated_as) |who| try printer.out.print(" (authenticated as {s})", .{who}); try printer.out.print(" ({d} bytes)\n{s}---\n", .{ data.len, data }); try printer.out.flush(); } };