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