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

] //! [--auth-method plain|login|cram-md5] ... //! 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 //! zsmtp serve [--tls-cert --tls-key [--implicit-tls]] //! [--auth :] //! 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 const std = @import("std"); const Io = std.Io; const zsmtp = @import("zsmtp"); 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 (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 { 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(); 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, 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, username: ?[]const u8 = null, password: ?[]const u8 = null, auth_method: enum { auto, plain, login, cram_md5 } = .auto, }; fn usage() noreturn { std.log.err( \\usage: \\ zsmtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth] \\ [--user --password

] \\ [--auth-method plain|login|cram-md5] ... \\ (message is read from stdin) \\ zsmtp serve [--tls-cert --tls-key [--implicit-tls]] \\ [--auth :] , .{}); 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, zsmtp.Tls.min_buffer_len); const write_buf = try arena.alloc(u8, zsmtp.Tls.min_buffer_len); var stream_reader = stream.reader(io, read_buf); var stream_writer = stream.writer(io, write_buf); const tls_options: zsmtp.Tls.Options = .{ .host = host_arg, .ca = if (config.insecure) .insecure else .system, }; var tls: zsmtp.Tls = undefined; var tls_active = false; defer if (tls_active) { tls.end() catch {}; tls.deinit(arena); }; var reply_buf: [1024]u8 = undefined; var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); client.allow_cleartext_auth = config.allow_cleartext_auth; 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.?; const result = switch (config.auth_method) { .auto => client.authenticate(extensions, username, password), .plain => client.authPlain("", username, password), .login => client.authLogin(username, password), .cram_md5 => client.authCramMd5(username, password), }; result catch |err| { switch (err) { error.AuthenticationFailed => { const reply = client.last_reply.?; std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text }); }, 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; } transact(&client, from, recipients, &stdin.interface, config.chunking, config.smtputf8) 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: *zsmtp.Client, from: []const u8, recipients: []const []const u8, message: *Io.Reader, chunking: bool, smtputf8: bool, ) (zsmtp.Client.Error || zsmtp.Client.ArgumentError)!void { if (smtputf8) try client.mailFromUtf8(from) else try client.mailFrom(from); for (recipients) |recipient| try client.rcptTo(recipient); if (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 { try client.sendMessageReader(message); } } 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: ?zsmtp.tls.config.CertKeyPair = if (config.cert_path) |cert_path| try .fromFilePath(gpa, io, .cwd(), cert_path, config.key_path.?) else null; const tls_options: ?zsmtp.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, }; while (true) { const stream = try listener.accept(io); defer stream.close(io); // Sized for the TLS handshake, which runs over the raw stream. const read_buf = try gpa.alloc(u8, zsmtp.tls.input_buffer_len); defer gpa.free(read_buf); const write_buf = try gpa.alloc(u8, zsmtp.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: zsmtp.Server = .init( &stream_reader.interface, &stream_writer.interface, .{ .context = &printer, .vtable = if (config.username != null) &.{ .authenticate = MessagePrinter.onAuthenticate, .message = MessagePrinter.onMessage, } else &.{ .message = MessagePrinter.onMessage, } }, .{ .hostname = "localhost", .tls = tls_options, .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, fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool { const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); return std.mem.eql(u8, username, printer.username.?) and std.mem.eql(u8, password, printer.password.?); } fn onMessage(context: ?*anyopaque, envelope: zsmtp.Server.Envelope, data: []const u8) zsmtp.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; } fn print(printer: *MessagePrinter, envelope: zsmtp.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}); } try printer.out.print(" ({d} bytes)\n{s}---\n", .{ data.len, data }); try printer.out.flush(); } };