An SMTP client and server library for Zig implementing RFC 5321.
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};