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