An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-smtp / src / main.zig
11 kB 284 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! Demo CLI for the zsmtp library. 5//! 6//! zsmtp send [--tls|--starttls] [--insecure] [--user <u> --password <p>] 7//! [--auth-method plain|login|cram-md5] <host> <port> <from> <to>... 8//! send a message read from stdin; --tls speaks TLS from the first 9//! byte (port 465 style), --starttls upgrades after EHLO (port 587 10//! style), --insecure skips certificate verification, --user/--password 11//! authenticate with the best advertised mechanism (or the one forced 12//! by --auth-method) 13//! zsmtp serve [--tls-cert <pem> --tls-key <pem>] [--auth <user>:<pass>] <port> 14//! run a debug server on 127.0.0.1 that prints received messages; 15//! --auth requires authentication with the given credentials; 16//! with a certificate and key it advertises and accepts STARTTLS 17 18const std = @import("std"); 19const Io = std.Io; 20const zsmtp = @import("zsmtp"); 21 22pub fn main(init: std.process.Init) !void { 23 const arena = init.arena.allocator(); 24 const io = init.io; 25 const args = try init.minimal.args.toSlice(arena); 26 27 if (args.len >= 2 and std.mem.eql(u8, args[1], "send")) { 28 var config: SendConfig = .{}; 29 var rest = args[2..]; 30 while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) { 31 if (std.mem.eql(u8, rest[0], "--tls")) { 32 config.mode = .tls; 33 } else if (std.mem.eql(u8, rest[0], "--starttls")) { 34 config.mode = .starttls; 35 } else if (std.mem.eql(u8, rest[0], "--insecure")) { 36 config.insecure = true; 37 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--user")) { 38 config.username = rest[1]; 39 rest = rest[1..]; 40 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--password")) { 41 config.password = rest[1]; 42 rest = rest[1..]; 43 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--auth-method")) { 44 config.auth_method = std.meta.stringToEnum( 45 @TypeOf(config.auth_method), 46 rest[1], 47 ) orelse if (std.mem.eql(u8, rest[1], "cram-md5")) .cram_md5 else return usage(); 48 rest = rest[1..]; 49 } else { 50 return usage(); 51 } 52 rest = rest[1..]; 53 } 54 if ((config.username == null) != (config.password == null)) return usage(); 55 if (rest.len < 4) return usage(); 56 return send(io, arena, config, rest[0], rest[1], rest[2], rest[3..]); 57 } 58 if (args.len >= 2 and std.mem.eql(u8, args[1], "serve")) { 59 var config: ServeConfig = .{}; 60 var rest = args[2..]; 61 while (rest.len >= 2 and std.mem.startsWith(u8, rest[0], "--")) { 62 if (std.mem.eql(u8, rest[0], "--tls-cert")) { 63 config.cert_path = rest[1]; 64 } else if (std.mem.eql(u8, rest[0], "--tls-key")) { 65 config.key_path = rest[1]; 66 } else if (std.mem.eql(u8, rest[0], "--auth")) { 67 const sep = std.mem.indexOfScalar(u8, rest[1], ':') orelse return usage(); 68 config.username = rest[1][0..sep]; 69 config.password = rest[1][sep + 1 ..]; 70 } else { 71 return usage(); 72 } 73 rest = rest[2..]; 74 } 75 if (rest.len != 1) return usage(); 76 if ((config.cert_path == null) != (config.key_path == null)) return usage(); 77 return serve(io, arena, config, rest[0]); 78 } 79 return usage(); 80} 81 82const ServeConfig = struct { 83 cert_path: ?[]const u8 = null, 84 key_path: ?[]const u8 = null, 85 username: ?[]const u8 = null, 86 password: ?[]const u8 = null, 87}; 88 89const SendConfig = struct { 90 mode: enum { plain, tls, starttls } = .plain, 91 insecure: bool = false, 92 username: ?[]const u8 = null, 93 password: ?[]const u8 = null, 94 auth_method: enum { auto, plain, login, cram_md5 } = .auto, 95}; 96 97fn usage() noreturn { 98 std.log.err( 99 \\usage: 100 \\ zsmtp send [--tls|--starttls] [--insecure] [--user <u> --password <p>] 101 \\ [--auth-method plain|login|cram-md5] <host> <port> <from> <to>... 102 \\ (message is read from stdin) 103 \\ zsmtp serve [--tls-cert <pem> --tls-key <pem>] [--auth <user>:<pass>] <port> 104 , .{}); 105 std.process.exit(1); 106} 107 108fn send( 109 io: Io, 110 arena: std.mem.Allocator, 111 config: SendConfig, 112 host_arg: []const u8, 113 port_arg: []const u8, 114 from: []const u8, 115 recipients: []const []const u8, 116) !void { 117 const host = try Io.net.HostName.init(host_arg); 118 const port = try std.fmt.parseInt(u16, port_arg, 10); 119 120 var stdin_buf: [4096]u8 = undefined; 121 var stdin: Io.File.Reader = .init(.stdin(), io, &stdin_buf); 122 123 const stream = try host.connect(io, port, .{ .mode = .stream }); 124 defer stream.close(io); 125 // The TLS layer requires stream buffers of at least min_buffer_len. 126 const read_buf = try arena.alloc(u8, zsmtp.Tls.min_buffer_len); 127 const write_buf = try arena.alloc(u8, zsmtp.Tls.min_buffer_len); 128 var stream_reader = stream.reader(io, read_buf); 129 var stream_writer = stream.writer(io, write_buf); 130 131 const tls_options: zsmtp.Tls.Options = .{ 132 .host = host_arg, 133 .ca = if (config.insecure) .insecure else .system, 134 }; 135 var tls: zsmtp.Tls = undefined; 136 var tls_active = false; 137 defer if (tls_active) { 138 tls.end() catch {}; 139 tls.deinit(arena); 140 }; 141 142 var reply_buf: [1024]u8 = undefined; 143 var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 144 145 if (config.mode == .tls) { 146 try tls.init(arena, io, &stream_reader.interface, &stream_writer.interface, tls_options); 147 tls_active = true; 148 client.setTransport(tls.reader(), tls.writer()); 149 } 150 151 _ = try client.greet(); 152 var extensions = try client.hello("localhost"); 153 154 if (config.mode == .starttls) { 155 try client.starttls(); 156 try tls.init(arena, io, &stream_reader.interface, &stream_writer.interface, tls_options); 157 tls_active = true; 158 client.setTransport(tls.reader(), tls.writer()); 159 extensions = try client.hello("localhost"); 160 } 161 162 if (config.username) |username| { 163 const password = config.password.?; 164 const result = switch (config.auth_method) { 165 .auto => client.authenticate(extensions, username, password), 166 .plain => client.authPlain("", username, password), 167 .login => client.authLogin(username, password), 168 .cram_md5 => client.authCramMd5(username, password), 169 }; 170 result catch |err| { 171 if (err == error.AuthenticationFailed) { 172 const reply = client.last_reply.?; 173 std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text }); 174 } 175 return err; 176 }; 177 } 178 179 transact(&client, from, recipients, &stdin.interface) catch |err| { 180 if (err == error.UnexpectedReply) { 181 const reply = client.last_reply.?; 182 std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text }); 183 } 184 return err; 185 }; 186 try client.quit(); 187 std.log.info("message sent to {d} recipient(s)", .{recipients.len}); 188} 189 190/// Runs the mail transaction, streaming the message from `message` so 191/// arbitrarily large input never has to fit in memory. 192fn transact( 193 client: *zsmtp.Client, 194 from: []const u8, 195 recipients: []const []const u8, 196 message: *Io.Reader, 197) zsmtp.Client.Error!void { 198 try client.mailFrom(from); 199 for (recipients) |recipient| try client.rcptTo(recipient); 200 try client.sendMessageReader(message); 201} 202 203fn serve(io: Io, gpa: std.mem.Allocator, config: ServeConfig, port_arg: []const u8) !void { 204 const port = try std.fmt.parseInt(u16, port_arg, 10); 205 const address: Io.net.IpAddress = .{ .ip4 = .loopback(port) }; 206 var listener = try address.listen(io, .{}); 207 defer listener.deinit(io); 208 209 var auth: ?zsmtp.tls.config.CertKeyPair = if (config.cert_path) |cert_path| 210 try .fromFilePath(gpa, io, .cwd(), cert_path, config.key_path.?) 211 else 212 null; 213 const starttls: ?zsmtp.Server.StartTls = if (auth) |*a| .{ .io = io, .auth = a } else null; 214 std.log.info("listening on 127.0.0.1:{d}{s}", .{ 215 port, 216 if (starttls != null) " with STARTTLS" else "", 217 }); 218 219 var stdout_buf: [4096]u8 = undefined; 220 var stdout: Io.File.Writer = .init(.stdout(), io, &stdout_buf); 221 222 var printer: MessagePrinter = .{ 223 .out = &stdout.interface, 224 .username = config.username, 225 .password = config.password, 226 }; 227 while (true) { 228 const stream = try listener.accept(io); 229 defer stream.close(io); 230 // Sized for the TLS handshake, which runs over the raw stream. 231 const read_buf = try gpa.alloc(u8, zsmtp.tls.input_buffer_len); 232 defer gpa.free(read_buf); 233 const write_buf = try gpa.alloc(u8, zsmtp.tls.output_buffer_len); 234 defer gpa.free(write_buf); 235 var stream_reader = stream.reader(io, read_buf); 236 var stream_writer = stream.writer(io, write_buf); 237 var session: zsmtp.Server = .init( 238 &stream_reader.interface, 239 &stream_writer.interface, 240 .{ .context = &printer, .vtable = if (config.username != null) &.{ 241 .authenticate = MessagePrinter.onAuthenticate, 242 .message = MessagePrinter.onMessage, 243 } else &.{ 244 .message = MessagePrinter.onMessage, 245 } }, 246 .{ 247 .hostname = "localhost", 248 .starttls = starttls, 249 .require_auth = config.username != null, 250 }, 251 ); 252 session.run(gpa) catch |err| { 253 std.log.warn("session ended with error: {t}", .{err}); 254 }; 255 } 256} 257 258const MessagePrinter = struct { 259 out: *Io.Writer, 260 username: ?[]const u8 = null, 261 password: ?[]const u8 = null, 262 263 fn onAuthenticate(context: ?*anyopaque, username: []const u8, password: []const u8) bool { 264 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 265 return std.mem.eql(u8, username, printer.username.?) and 266 std.mem.eql(u8, password, printer.password.?); 267 } 268 269 fn onMessage(context: ?*anyopaque, envelope: zsmtp.Server.Envelope, data: []const u8) zsmtp.Server.Decision { 270 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 271 printer.print(envelope, data) catch 272 return .{ .reject = .{ .code = 451, .text = "4.3.0 Local error" } }; 273 return .accept; 274 } 275 276 fn print(printer: *MessagePrinter, envelope: zsmtp.Server.Envelope, data: []const u8) !void { 277 try printer.out.print("--- message from <{s}> to", .{envelope.from}); 278 for (envelope.recipients) |recipient| { 279 try printer.out.print(" <{s}>", .{recipient}); 280 } 281 try printer.out.print(" ({d} bytes)\n{s}---\n", .{ data.len, data }); 282 try printer.out.flush(); 283 } 284};