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