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