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