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