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