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
7.7 kB 207 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] <host> <port> <from> <to>... 7//! send a message read from stdin; --tls speaks TLS from the first 8//! byte (port 465 style), --starttls upgrades after EHLO (port 587 9//! style), --insecure skips certificate verification 10//! zsmtp serve [--tls-cert <pem> --tls-key <pem>] <port> 11//! run a debug server on 127.0.0.1 that prints received messages; 12//! with a certificate and key it advertises and accepts STARTTLS 13 14const std = @import("std"); 15const Io = std.Io; 16const zsmtp = @import("zsmtp"); 17 18pub fn main(init: std.process.Init) !void { 19 const arena = init.arena.allocator(); 20 const io = init.io; 21 const args = try init.minimal.args.toSlice(arena); 22 23 if (args.len >= 2 and std.mem.eql(u8, args[1], "send")) { 24 var config: SendConfig = .{}; 25 var rest = args[2..]; 26 while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) { 27 if (std.mem.eql(u8, rest[0], "--tls")) { 28 config.mode = .tls; 29 } else if (std.mem.eql(u8, rest[0], "--starttls")) { 30 config.mode = .starttls; 31 } else if (std.mem.eql(u8, rest[0], "--insecure")) { 32 config.insecure = true; 33 } else { 34 return usage(); 35 } 36 rest = rest[1..]; 37 } 38 if (rest.len < 4) return usage(); 39 return send(io, arena, config, rest[0], rest[1], rest[2], rest[3..]); 40 } 41 if (args.len >= 2 and std.mem.eql(u8, args[1], "serve")) { 42 var config: ServeConfig = .{}; 43 var rest = args[2..]; 44 while (rest.len >= 2 and std.mem.startsWith(u8, rest[0], "--")) { 45 if (std.mem.eql(u8, rest[0], "--tls-cert")) { 46 config.cert_path = rest[1]; 47 } else if (std.mem.eql(u8, rest[0], "--tls-key")) { 48 config.key_path = rest[1]; 49 } else { 50 return usage(); 51 } 52 rest = rest[2..]; 53 } 54 if (rest.len != 1) return usage(); 55 if ((config.cert_path == null) != (config.key_path == null)) return usage(); 56 return serve(io, arena, config, rest[0]); 57 } 58 return usage(); 59} 60 61const ServeConfig = struct { 62 cert_path: ?[]const u8 = null, 63 key_path: ?[]const u8 = null, 64}; 65 66const SendConfig = struct { 67 mode: enum { plain, tls, starttls } = .plain, 68 insecure: bool = false, 69}; 70 71fn usage() noreturn { 72 std.log.err( 73 \\usage: 74 \\ zsmtp send [--tls|--starttls] [--insecure] <host> <port> <from> <to>... 75 \\ (message is read from stdin) 76 \\ zsmtp serve [--tls-cert <pem> --tls-key <pem>] <port> 77 , .{}); 78 std.process.exit(1); 79} 80 81fn send( 82 io: Io, 83 arena: std.mem.Allocator, 84 config: SendConfig, 85 host_arg: []const u8, 86 port_arg: []const u8, 87 from: []const u8, 88 recipients: []const []const u8, 89) !void { 90 const host = try Io.net.HostName.init(host_arg); 91 const port = try std.fmt.parseInt(u16, port_arg, 10); 92 93 var stdin_buf: [4096]u8 = undefined; 94 var stdin: Io.File.Reader = .init(.stdin(), io, &stdin_buf); 95 const message = try stdin.interface.allocRemaining(arena, .unlimited); 96 97 const stream = try host.connect(io, port, .{ .mode = .stream }); 98 defer stream.close(io); 99 // The TLS layer requires stream buffers of at least min_buffer_len. 100 const read_buf = try arena.alloc(u8, zsmtp.Tls.min_buffer_len); 101 const write_buf = try arena.alloc(u8, zsmtp.Tls.min_buffer_len); 102 var stream_reader = stream.reader(io, read_buf); 103 var stream_writer = stream.writer(io, write_buf); 104 105 const tls_options: zsmtp.Tls.Options = .{ 106 .host = host_arg, 107 .ca = if (config.insecure) .insecure else .system, 108 }; 109 var tls: zsmtp.Tls = undefined; 110 var tls_active = false; 111 defer if (tls_active) { 112 tls.end() catch {}; 113 tls.deinit(arena); 114 }; 115 116 var reply_buf: [1024]u8 = undefined; 117 var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 118 119 if (config.mode == .tls) { 120 try tls.init(arena, io, &stream_reader.interface, &stream_writer.interface, tls_options); 121 tls_active = true; 122 client.setTransport(tls.reader(), tls.writer()); 123 } 124 125 _ = try client.greet(); 126 _ = try client.hello("localhost"); 127 128 if (config.mode == .starttls) { 129 try client.starttls(); 130 try tls.init(arena, io, &stream_reader.interface, &stream_writer.interface, tls_options); 131 tls_active = true; 132 client.setTransport(tls.reader(), tls.writer()); 133 _ = try client.hello("localhost"); 134 } 135 136 client.sendMail(from, recipients, message) catch |err| { 137 if (err == error.UnexpectedReply) { 138 const reply = client.last_reply.?; 139 std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text }); 140 } 141 return err; 142 }; 143 try client.quit(); 144 std.log.info("message sent to {d} recipient(s)", .{recipients.len}); 145} 146 147fn serve(io: Io, gpa: std.mem.Allocator, config: ServeConfig, port_arg: []const u8) !void { 148 const port = try std.fmt.parseInt(u16, port_arg, 10); 149 const address: Io.net.IpAddress = .{ .ip4 = .loopback(port) }; 150 var listener = try address.listen(io, .{}); 151 defer listener.deinit(io); 152 153 var auth: ?zsmtp.tls.config.CertKeyPair = if (config.cert_path) |cert_path| 154 try .fromFilePath(gpa, io, .cwd(), cert_path, config.key_path.?) 155 else 156 null; 157 const starttls: ?zsmtp.Server.StartTls = if (auth) |*a| .{ .io = io, .auth = a } else null; 158 std.log.info("listening on 127.0.0.1:{d}{s}", .{ 159 port, 160 if (starttls != null) " with STARTTLS" else "", 161 }); 162 163 var stdout_buf: [4096]u8 = undefined; 164 var stdout: Io.File.Writer = .init(.stdout(), io, &stdout_buf); 165 166 var printer: MessagePrinter = .{ .out = &stdout.interface }; 167 while (true) { 168 const stream = try listener.accept(io); 169 defer stream.close(io); 170 // Sized for the TLS handshake, which runs over the raw stream. 171 const read_buf = try gpa.alloc(u8, zsmtp.tls.input_buffer_len); 172 defer gpa.free(read_buf); 173 const write_buf = try gpa.alloc(u8, zsmtp.tls.output_buffer_len); 174 defer gpa.free(write_buf); 175 var stream_reader = stream.reader(io, read_buf); 176 var stream_writer = stream.writer(io, write_buf); 177 var session: zsmtp.Server = .init( 178 &stream_reader.interface, 179 &stream_writer.interface, 180 .{ .context = &printer, .vtable = &.{ .message = MessagePrinter.onMessage } }, 181 .{ .hostname = "localhost", .starttls = starttls }, 182 ); 183 session.run(gpa) catch |err| { 184 std.log.warn("session ended with error: {t}", .{err}); 185 }; 186 } 187} 188 189const MessagePrinter = struct { 190 out: *Io.Writer, 191 192 fn onMessage(context: ?*anyopaque, envelope: zsmtp.Server.Envelope, data: []const u8) zsmtp.Server.Decision { 193 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 194 printer.print(envelope, data) catch 195 return .{ .reject = .{ .code = 451, .text = "4.3.0 Local error" } }; 196 return .accept; 197 } 198 199 fn print(printer: *MessagePrinter, envelope: zsmtp.Server.Envelope, data: []const u8) !void { 200 try printer.out.print("--- message from <{s}> to", .{envelope.from}); 201 for (envelope.recipients) |recipient| { 202 try printer.out.print(" <{s}>", .{recipient}); 203 } 204 try printer.out.print(" ({d} bytes)\n{s}---\n", .{ data.len, data }); 205 try printer.out.flush(); 206 } 207};