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