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