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