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 zig-smtp library.
5//!
6//! zig-smtp 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] [--binarymime] <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; --binarymime sends the
20//! input as BODY=BINARYMIME over BDAT, byte for byte
21//! zig-smtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]]
22//! [--auth <user>:<pass>] [--lmtp [--fail-delivery <address>]]
23//! <port>
24//! run a debug server on 127.0.0.1 that prints received messages;
25//! --auth requires authentication with the given credentials; with a
26//! certificate and key it advertises and accepts STARTTLS, or speaks
27//! TLS from the first byte with --implicit-tls; --lmtp speaks LMTP
28//! instead of SMTP, where --fail-delivery names one recipient to
29//! report as undeliverable at the end of the message
30
31const std = @import("std");
32const Io = std.Io;
33const smtp = @import("smtp");
34
35pub fn main(init: std.process.Init) !void {
36 const arena = init.arena.allocator();
37 const io = init.io;
38 const args = try init.minimal.args.toSlice(arena);
39
40 if (args.len >= 2 and std.mem.eql(u8, args[1], "send")) {
41 var config: SendConfig = .{};
42 var rest = args[2..];
43 while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) {
44 if (std.mem.eql(u8, rest[0], "--tls")) {
45 config.mode = .tls;
46 } else if (std.mem.eql(u8, rest[0], "--starttls")) {
47 config.mode = .starttls;
48 } else if (std.mem.eql(u8, rest[0], "--insecure")) {
49 config.insecure = true;
50 } else if (std.mem.eql(u8, rest[0], "--allow-cleartext-auth")) {
51 config.allow_cleartext_auth = true;
52 } else if (std.mem.eql(u8, rest[0], "--chunking")) {
53 config.chunking = true;
54 } else if (std.mem.eql(u8, rest[0], "--smtputf8")) {
55 config.smtputf8 = true;
56 } else if (std.mem.eql(u8, rest[0], "--lmtp")) {
57 config.protocol = .lmtp;
58 } else if (std.mem.eql(u8, rest[0], "--binarymime")) {
59 // Binary content can only be framed by BDAT, so this is
60 // chunking plus a declaration of what the chunks hold.
61 config.body = .binary_mime;
62 config.chunking = true;
63 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--ret")) {
64 config.ret = smtp.protocol.Ret.parse(rest[1]) catch return usage();
65 rest = rest[1..];
66 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--envid")) {
67 config.envid = rest[1];
68 rest = rest[1..];
69 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--notify")) {
70 config.notify = smtp.protocol.Notify.parse(rest[1]) catch return usage();
71 rest = rest[1..];
72 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--orcpt")) {
73 // Applied to every recipient, which is all a one-shot
74 // sender can sensibly do with it.
75 config.orcpt = rest[1];
76 rest = rest[1..];
77 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--user")) {
78 config.username = rest[1];
79 rest = rest[1..];
80 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--password")) {
81 config.password = rest[1];
82 rest = rest[1..];
83 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--auth-method")) {
84 config.auth_method = std.meta.stringToEnum(
85 @TypeOf(config.auth_method),
86 rest[1],
87 ) orelse if (std.mem.eql(u8, rest[1], "cram-md5")) .cram_md5 else return usage();
88 rest = rest[1..];
89 } else {
90 return usage();
91 }
92 rest = rest[1..];
93 }
94 if ((config.username == null) != (config.password == null)) return usage();
95 if (rest.len < 4) return usage();
96 return send(io, arena, config, rest[0], rest[1], rest[2], rest[3..]);
97 }
98 if (args.len >= 2 and std.mem.eql(u8, args[1], "serve")) {
99 var config: ServeConfig = .{};
100 var rest = args[2..];
101 while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) {
102 if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--tls-cert")) {
103 config.cert_path = rest[1];
104 rest = rest[1..];
105 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--tls-key")) {
106 config.key_path = rest[1];
107 rest = rest[1..];
108 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--auth")) {
109 const sep = std.mem.indexOfScalar(u8, rest[1], ':') orelse return usage();
110 config.username = rest[1][0..sep];
111 config.password = rest[1][sep + 1 ..];
112 rest = rest[1..];
113 } else if (std.mem.eql(u8, rest[0], "--implicit-tls")) {
114 config.implicit_tls = true;
115 } else if (std.mem.eql(u8, rest[0], "--lmtp")) {
116 config.protocol = .lmtp;
117 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--fail-delivery")) {
118 config.fail_delivery = rest[1];
119 rest = rest[1..];
120 } else {
121 return usage();
122 }
123 rest = rest[1..];
124 }
125 if (rest.len != 1) return usage();
126 if ((config.cert_path == null) != (config.key_path == null)) return usage();
127 if (config.implicit_tls and config.cert_path == null) return usage();
128 if (config.fail_delivery != null and config.protocol != .lmtp) return usage();
129 return serve(io, arena, config, rest[0]);
130 }
131 return usage();
132}
133
134const ServeConfig = struct {
135 cert_path: ?[]const u8 = null,
136 key_path: ?[]const u8 = null,
137 implicit_tls: bool = false,
138 protocol: smtp.Server.Protocol = .smtp,
139 /// Accepted at RCPT time and then failed at the end of the message,
140 /// which only LMTP can say.
141 fail_delivery: ?[]const u8 = null,
142 username: ?[]const u8 = null,
143 password: ?[]const u8 = null,
144};
145
146const SendConfig = struct {
147 mode: enum { plain, tls, starttls } = .plain,
148 insecure: bool = false,
149 allow_cleartext_auth: bool = false,
150 chunking: bool = false,
151 smtputf8: bool = false,
152 protocol: smtp.Client.Protocol = .smtp,
153 body: ?smtp.protocol.Body = null,
154 ret: ?smtp.protocol.Ret = null,
155 envid: ?[]const u8 = null,
156 notify: ?smtp.protocol.Notify = null,
157 orcpt: ?[]const u8 = null,
158 username: ?[]const u8 = null,
159 password: ?[]const u8 = null,
160 auth_method: enum { auto, plain, login, cram_md5 } = .auto,
161};
162
163fn usage() noreturn {
164 std.log.err(
165 \\usage:
166 \\ zig-smtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth]
167 \\ [--user <u> --password <p>]
168 \\ [--auth-method plain|login|cram-md5]
169 \\ [--ret full|hdrs] [--envid <id>]
170 \\ [--notify never|success,failure,delay] [--orcpt <address>]
171 \\ [--lmtp] [--binarymime] <host> <port> <from> <to>...
172 \\ (message is read from stdin)
173 \\ zig-smtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]]
174 \\ [--auth <user>:<pass>] [--lmtp [--fail-delivery <address>]]
175 \\ <port>
176 , .{});
177 std.process.exit(1);
178}
179
180fn send(
181 io: Io,
182 arena: std.mem.Allocator,
183 config: SendConfig,
184 host_arg: []const u8,
185 port_arg: []const u8,
186 from: []const u8,
187 recipients: []const []const u8,
188) !void {
189 const host = try Io.net.HostName.init(host_arg);
190 const port = try std.fmt.parseInt(u16, port_arg, 10);
191
192 var stdin_buf: [4096]u8 = undefined;
193 var stdin: Io.File.Reader = .init(.stdin(), io, &stdin_buf);
194
195 const stream = try host.connect(io, port, .{ .mode = .stream });
196 defer stream.close(io);
197 // The TLS layer requires stream buffers of at least min_buffer_len.
198 const read_buf = try arena.alloc(u8, smtp.Tls.min_buffer_len);
199 const write_buf = try arena.alloc(u8, smtp.Tls.min_buffer_len);
200 var stream_reader = stream.reader(io, read_buf);
201 var stream_writer = stream.writer(io, write_buf);
202
203 const tls_options: smtp.Tls.Options = .{
204 .host = host_arg,
205 .ca = if (config.insecure) .insecure else .system,
206 };
207 var tls: smtp.Tls = undefined;
208 var tls_active = false;
209 defer if (tls_active) {
210 tls.end() catch {};
211 tls.deinit(arena);
212 };
213
214 var reply_buf: [1024]u8 = undefined;
215 var client: smtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf);
216 client.allow_cleartext_auth = config.allow_cleartext_auth;
217 // The SASL scratch is the caller's; a mechanism never allocates one for
218 // itself and nothing puts one on the stack behind your back.
219 var sasl_scratch: [smtp.Client.sasl_buffer_suggested]u8 = undefined;
220 client.sasl_buffer = &sasl_scratch;
221 client.mode = config.protocol;
222
223 if (config.mode == .tls) {
224 try tls.init(io, arena, &stream_reader.interface, &stream_writer.interface, tls_options);
225 tls_active = true;
226 client.setTransport(tls.reader(), tls.writer(), .encrypted);
227 }
228
229 _ = try client.greet();
230 var extensions = try client.hello("localhost");
231
232 if (config.mode == .starttls) {
233 try client.starttls();
234 try tls.init(io, arena, &stream_reader.interface, &stream_writer.interface, tls_options);
235 tls_active = true;
236 client.setTransport(tls.reader(), tls.writer(), .encrypted);
237 extensions = try client.hello("localhost");
238 }
239
240 if (config.username) |username| {
241 const password = config.password.?;
242 // The mechanisms come from zig-sasl; what is chosen from them is the
243 // caller's business, and this one lets --auth-method force it.
244 var plain: smtp.sasl.Plain = .init(username, password);
245 var login: smtp.sasl.Login = .init(username, password);
246 var cram_md5: smtp.sasl.CramMd5 = .init(username, password);
247 const offered: []const smtp.sasl.Client = switch (config.auth_method) {
248 // In order of preference, which `selectFromList` reads as such:
249 // PLAIN because every server implements it correctly, CRAM-MD5
250 // last because it is the oldest. On a carrier with no encryption
251 // the first two are skipped and it is the only one left.
252 .auto => &.{ plain.client(), login.client(), cram_md5.client() },
253 .plain => &.{plain.client()},
254 .login => &.{login.client()},
255 .cram_md5 => &.{cram_md5.client()},
256 };
257 const mechanism = smtp.sasl.Client.selectFromList(
258 offered,
259 extensions.auth,
260 client.security == .encrypted or client.allow_cleartext_auth,
261 ) orelse {
262 std.log.err(
263 "no usable mechanism; the server offers: {s}{s}",
264 .{
265 if (extensions.auth.len == 0) "(none)" else extensions.auth,
266 // The common case by far: everything on offer sends the
267 // password, and this connection is not encrypted.
268 if (client.security == .plaintext and !client.allow_cleartext_auth)
269 ", and this connection is not encrypted " ++
270 "(use --starttls or --tls, or --allow-cleartext-auth)"
271 else
272 "",
273 },
274 );
275 return error.NoSupportedMechanism;
276 };
277 client.authenticate(mechanism) catch |err| {
278 switch (err) {
279 error.AuthenticationFailed => {
280 const reply = client.last_reply.?;
281 std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text });
282 },
283 error.ServerNotAuthenticated => std.log.err(
284 "the server accepted the login without proving itself; " ++
285 "this is not the server it claims to be",
286 .{},
287 ),
288 error.InsecureTransport => std.log.err(
289 "refusing to send credentials over an unencrypted connection; " ++
290 "use --starttls or --tls, or pass --allow-cleartext-auth",
291 .{},
292 ),
293 else => {},
294 }
295 return err;
296 };
297 }
298
299 if (config.chunking and !extensions.chunking) {
300 std.log.err("server does not advertise CHUNKING", .{});
301 return error.ChunkingNotAdvertised;
302 }
303 if (config.smtputf8 and !extensions.smtputf8) {
304 std.log.err("server does not advertise SMTPUTF8", .{});
305 return error.SmtpUtf8NotAdvertised;
306 }
307 if (config.body == .binary_mime and !extensions.binary_mime) {
308 // RFC 3030 is absolute about this one: without the advertisement,
309 // binary must not be sent under any circumstances.
310 std.log.err("server does not advertise BINARYMIME", .{});
311 return error.BinaryMimeNotAdvertised;
312 }
313 const wants_dsn = config.ret != null or config.envid != null or
314 config.notify != null or config.orcpt != null;
315 if (wants_dsn and !extensions.dsn) {
316 // A conforming server answers an unrecognized parameter with 555,
317 // so this is only a clearer way to say the same thing.
318 std.log.err("server does not advertise DSN", .{});
319 return error.DsnNotAdvertised;
320 }
321 transact(&client, config, from, recipients, &stdin.interface) catch |err| {
322 if (err == error.UnexpectedReply) {
323 const reply = client.last_reply.?;
324 std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text });
325 }
326 return err;
327 };
328 try client.quit();
329 std.log.info("message sent to {d} recipient(s)", .{recipients.len});
330}
331
332/// Runs the mail transaction, streaming the message from `message` so
333/// arbitrarily large input never has to fit in memory.
334fn transact(
335 client: *smtp.Client,
336 config: SendConfig,
337 from: []const u8,
338 recipients: []const []const u8,
339 message: *Io.Reader,
340) (smtp.Client.Error || smtp.Client.ArgumentError)!void {
341 try client.mail(from, .{
342 .smtputf8 = config.smtputf8,
343 .body = config.body,
344 .ret = config.ret,
345 .envid = config.envid,
346 });
347 for (recipients) |recipient| try client.rcpt(recipient, .{
348 .notify = config.notify,
349 .orcpt = if (config.orcpt) |address|
350 .{ .addr_type = "rfc822", .address = address }
351 else
352 null,
353 });
354 if (config.chunking) {
355 // BDAT sends the input verbatim (no line-ending normalization).
356 while (true) {
357 const chunk = message.peekGreedy(1) catch |err| switch (err) {
358 error.EndOfStream => break,
359 error.ReadFailed => return error.ReadFailed,
360 };
361 try client.bdat(chunk, false);
362 message.toss(chunk.len);
363 }
364 try client.bdat("", true);
365 } else {
366 var data_writer = try client.data();
367 while (true) {
368 const chunk = message.peekGreedy(1) catch |err| switch (err) {
369 error.EndOfStream => break,
370 error.ReadFailed => return error.ReadFailed,
371 };
372 try data_writer.interface.writeAll(chunk);
373 message.toss(chunk.len);
374 }
375 // In LMTP there is one verdict per recipient rather than one for
376 // the message, and reporting them individually is the only reason
377 // to be speaking it.
378 var verdicts = try data_writer.endResults();
379 var failed = false;
380 while (try verdicts.next()) |reply| {
381 if (config.protocol == .lmtp) {
382 std.log.info("{s}: {d} {s}", .{
383 recipients[verdicts.index - 1],
384 reply.code,
385 reply.text,
386 });
387 }
388 if (!reply.isPositiveCompletion()) failed = true;
389 }
390 // Each verdict was reported above, so the error only has to say
391 // that one of them was a refusal.
392 if (failed) return if (config.protocol == .lmtp)
393 error.RecipientRejected
394 else
395 error.UnexpectedReply;
396 }
397}
398
399fn serve(io: Io, gpa: std.mem.Allocator, config: ServeConfig, port_arg: []const u8) !void {
400 const port = try std.fmt.parseInt(u16, port_arg, 10);
401 const address: Io.net.IpAddress = .{ .ip4 = .loopback(port) };
402 var listener = try address.listen(io, .{});
403 defer listener.deinit(io);
404
405 var auth: ?smtp.tls.config.CertKeyPair = if (config.cert_path) |cert_path|
406 try .fromFilePath(gpa, io, .cwd(), cert_path, config.key_path.?)
407 else
408 null;
409 const tls_options: ?smtp.Server.TlsOptions = if (auth) |*a| .{
410 .io = io,
411 .auth = a,
412 .mode = if (config.implicit_tls) .implicit else .starttls,
413 } else null;
414 std.log.info("listening on 127.0.0.1:{d}{s}", .{
415 port,
416 if (tls_options) |t| switch (t.mode) {
417 .starttls => " with STARTTLS",
418 .implicit => " with implicit TLS",
419 } else "",
420 });
421
422 var stdout_buf: [4096]u8 = undefined;
423 var stdout: Io.File.Writer = .init(.stdout(), io, &stdout_buf);
424
425 var printer: MessagePrinter = .{
426 .out = &stdout.interface,
427 .username = config.username,
428 .password = config.password,
429 .fail_delivery = config.fail_delivery,
430 };
431 // The credential check, which PLAIN and LOGIN share, and the password
432 // lookup CRAM-MD5 needs instead. Both close over the same one account.
433 const check: smtp.sasl.Server.PasswordCheck = .{
434 .context = &printer,
435 .verify = MessagePrinter.verify,
436 };
437 const passwords: smtp.sasl.Server.PasswordLookup = .{
438 .context = &printer,
439 .lookup = MessagePrinter.lookup,
440 };
441
442 var connections: usize = 0;
443 while (true) {
444 const stream = try listener.accept(io);
445 defer stream.close(io);
446 connections += 1;
447
448 // A fresh set per connection: the mechanisms hold per-exchange state,
449 // and CRAM-MD5's challenge must not repeat between them.
450 // RFC 2195 wants a challenge that never repeats. A counter and the
451 // clock is what a real server would use, plus its hostname.
452 var challenge_buf: [128]u8 = undefined;
453 const challenge = std.fmt.bufPrint(
454 &challenge_buf,
455 "<{d}.{d}@localhost>",
456 .{ connections, Io.Clock.real.now(io).nanoseconds },
457 ) catch unreachable;
458 var sasl_scratch: [smtp.Server.sasl_buffer_suggested]u8 = undefined;
459 var plain: smtp.sasl.PlainServer = .init(check);
460 var login: smtp.sasl.LoginServer = .init(check);
461 var cram_md5: smtp.sasl.CramMd5Server = .init(challenge, passwords);
462 const mechanisms: []const smtp.sasl.Server = if (config.username == null)
463 &.{}
464 else
465 &.{ plain.server(), login.server(), cram_md5.server() };
466
467 // Sized for the TLS handshake, which runs over the raw stream.
468 const read_buf = try gpa.alloc(u8, smtp.tls.input_buffer_len);
469 defer gpa.free(read_buf);
470 const write_buf = try gpa.alloc(u8, smtp.tls.output_buffer_len);
471 defer gpa.free(write_buf);
472 var stream_reader = stream.reader(io, read_buf);
473 var stream_writer = stream.writer(io, write_buf);
474 var session: smtp.Server = .init(
475 &stream_reader.interface,
476 &stream_writer.interface,
477 .{ .context = &printer, .vtable = &.{
478 .message = MessagePrinter.onMessage,
479 .recipientResult = MessagePrinter.onRecipientResult,
480 } },
481 .{
482 .protocol = config.protocol,
483 .hostname = "localhost",
484 .tls = tls_options,
485 .auth_mechanisms = mechanisms,
486 .sasl_buffer = &sasl_scratch,
487 .require_auth = config.username != null,
488 },
489 );
490 session.run(gpa) catch |err| {
491 std.log.warn("session ended with error: {t}", .{err});
492 };
493 }
494}
495
496const MessagePrinter = struct {
497 out: *Io.Writer,
498 username: ?[]const u8 = null,
499 password: ?[]const u8 = null,
500 fail_delivery: ?[]const u8 = null,
501
502 /// What PLAIN and LOGIN ask: is this password right? The answer is the
503 /// identity to report, which for this one-account server is the username.
504 fn verify(
505 context: ?*anyopaque,
506 authzid: []const u8,
507 authcid: []const u8,
508 password: []const u8,
509 ) ?[]const u8 {
510 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?));
511 // Acting as somebody else is not a thing this server does.
512 if (authzid.len != 0) return null;
513 if (!std.mem.eql(u8, authcid, printer.username.?)) return null;
514 if (!std.mem.eql(u8, password, printer.password.?)) return null;
515 return printer.username.?;
516 }
517
518 /// What CRAM-MD5 asks instead: the password itself, because it has to
519 /// compute the same HMAC the client did.
520 fn lookup(context: ?*anyopaque, username: []const u8) ?[]const u8 {
521 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?));
522 if (!std.mem.eql(u8, username, printer.username.?)) return null;
523 return printer.password.?;
524 }
525
526 fn onMessage(context: ?*anyopaque, envelope: smtp.Server.Envelope, data: []const u8) smtp.Server.Decision {
527 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?));
528 printer.print(envelope, data) catch
529 return .{ .reject = .{ .code = 451, .text = "4.3.0 Local error" } };
530 return .accept;
531 }
532
533 /// LMTP's per-recipient verdict. Everything was already printed by
534 /// `onMessage`; this only reports the one address `--fail-delivery`
535 /// names as undeliverable, which is the outcome SMTP has no way to
536 /// express for one recipient out of several.
537 fn onRecipientResult(
538 context: ?*anyopaque,
539 envelope: smtp.Server.Envelope,
540 index: usize,
541 ) smtp.Server.Decision {
542 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?));
543 const failing = printer.fail_delivery orelse return .accept;
544 if (std.mem.eql(u8, envelope.recipients[index].address, failing))
545 return .{ .reject = .{ .code = 550, .text = "5.2.1 Mailbox disabled" } };
546 return .accept;
547 }
548
549 fn print(printer: *MessagePrinter, envelope: smtp.Server.Envelope, data: []const u8) !void {
550 try printer.out.print("--- message from <{s}> to", .{envelope.from});
551 for (envelope.recipients) |recipient| {
552 try printer.out.print(" <{s}>", .{recipient.address});
553 // DSN parameters, printed so that a session can be checked from
554 // the outside (which is what the interop test does).
555 if (recipient.notify) |notify| try printer.out.print(" NOTIFY={f}", .{notify});
556 if (recipient.orcpt) |orcpt| try printer.out.print(" ORCPT={f}", .{orcpt});
557 }
558 if (envelope.ret) |ret| try printer.out.print(" RET={f}", .{ret});
559 if (envelope.envid) |envid| try printer.out.print(" ENVID={s}", .{envid});
560 try printer.out.print(" ({d} bytes)\n{s}---\n", .{ data.len, data });
561 try printer.out.flush();
562 }
563};