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