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//! Client-side TLS transport layered over an existing stream, wrapping
5//! `std.crypto.tls.Client` with sensible defaults (system trust store,
6//! entropy, and clock wiring). Works for both implicit TLS (connect, then
7//! `init` before any SMTP traffic) and STARTTLS (after `Client.starttls`
8//! succeeds, `init` over the same stream, then `Client.setTransport` and a
9//! fresh `hello`).
10//!
11//! The underlying stream reader and writer must each have a buffer of at
12//! least `min_buffer_len` bytes.
13
14const Tls = @This();
15
16const std = @import("std");
17const Io = std.Io;
18
19tls_client: std.crypto.tls.Client,
20read_buffer: []u8,
21write_buffer: []u8,
22/// Pass-through writer handed out by `writer`. `std.crypto.tls.Client`'s own
23/// writer encrypts on flush but leaves the records in the underlying stream
24/// writer's buffer; this wrapper's flush pushes them all the way to the
25/// stream, which line-oriented protocols like SMTP depend on (each command
26/// must reach the server before its reply can arrive).
27writer_state: Io.Writer,
28
29/// Minimum buffer size for the underlying stream reader and writer.
30pub const min_buffer_len = std.crypto.tls.Client.min_buffer_len;
31
32pub const Options = struct {
33 /// Host name the server's certificate must be valid for (also sent via
34 /// SNI). Ignored with `ca = .insecure`.
35 host: []const u8,
36 ca: Ca = .system,
37
38 pub const Ca = union(enum) {
39 /// Verify the server certificate against the system trust store,
40 /// loaded fresh for this connection.
41 system,
42 /// Verify against a caller-managed bundle (reusable across
43 /// connections; see `std.crypto.Certificate.Bundle.rescan`).
44 bundle: struct {
45 lock: *Io.RwLock,
46 bundle: *std.crypto.Certificate.Bundle,
47 },
48 /// No certificate verification at all. This provides encryption but
49 /// no authentication — fine for tests, unsafe on real networks.
50 insecure,
51 };
52};
53
54pub const InitError = std.crypto.tls.Client.InitError || error{
55 OutOfMemory,
56 CertificateBundleLoadFailure,
57};
58
59/// Performs the TLS handshake over `input`/`output` (the stream's
60/// reader/writer, each with a buffer of at least `min_buffer_len` bytes).
61/// Allocates the TLS record buffers from `gpa`; free them with `deinit`.
62pub fn init(
63 gpa: std.mem.Allocator,
64 io: Io,
65 input: *Io.Reader,
66 output: *Io.Writer,
67 options: Options,
68) InitError!Tls {
69 const read_buffer = try gpa.alloc(u8, min_buffer_len);
70 errdefer gpa.free(read_buffer);
71 const write_buffer = try gpa.alloc(u8, min_buffer_len);
72 errdefer gpa.free(write_buffer);
73
74 var entropy: [std.crypto.tls.Client.Options.entropy_len]u8 = undefined;
75 io.random(&entropy);
76 const now = Io.Clock.real.now(io);
77
78 const tls_client = switch (options.ca) {
79 .system => system: {
80 var lock: Io.RwLock = .init;
81 var bundle: std.crypto.Certificate.Bundle = .empty;
82 defer bundle.deinit(gpa);
83 bundle.rescan(gpa, io, now) catch return error.CertificateBundleLoadFailure;
84 break :system try std.crypto.tls.Client.init(input, output, .{
85 .host = .{ .explicit = options.host },
86 .ca = .{ .bundle = .{
87 .gpa = gpa,
88 .io = io,
89 .lock = &lock,
90 .bundle = &bundle,
91 } },
92 .read_buffer = read_buffer,
93 .write_buffer = write_buffer,
94 .entropy = &entropy,
95 .realtime_now = now,
96 });
97 },
98 .bundle => |ca| try std.crypto.tls.Client.init(input, output, .{
99 .host = .{ .explicit = options.host },
100 .ca = .{ .bundle = .{
101 .gpa = gpa,
102 .io = io,
103 .lock = ca.lock,
104 .bundle = ca.bundle,
105 } },
106 .read_buffer = read_buffer,
107 .write_buffer = write_buffer,
108 .entropy = &entropy,
109 .realtime_now = now,
110 }),
111 .insecure => try std.crypto.tls.Client.init(input, output, .{
112 .host = .no_verification,
113 .ca = .no_verification,
114 .read_buffer = read_buffer,
115 .write_buffer = write_buffer,
116 .entropy = &entropy,
117 .realtime_now = now,
118 }),
119 };
120
121 return .{
122 .tls_client = tls_client,
123 .read_buffer = read_buffer,
124 .write_buffer = write_buffer,
125 .writer_state = .{
126 .vtable = &.{ .drain = drain, .flush = flushThrough },
127 .buffer = &.{},
128 },
129 };
130}
131
132fn drain(w: *Io.Writer, data: []const []const u8, splat: usize) Io.Writer.Error!usize {
133 const t: *Tls = @alignCast(@fieldParentPtr("writer_state", w));
134 if (data.len == 0) return 0;
135 var n: usize = 0;
136 for (data[0 .. data.len - 1]) |bytes| {
137 try t.tls_client.writer.writeAll(bytes);
138 n += bytes.len;
139 }
140 const pattern = data[data.len - 1];
141 for (0..splat) |_| {
142 try t.tls_client.writer.writeAll(pattern);
143 n += pattern.len;
144 }
145 return n;
146}
147
148fn flushThrough(w: *Io.Writer) Io.Writer.Error!void {
149 const t: *Tls = @alignCast(@fieldParentPtr("writer_state", w));
150 try t.tls_client.writer.flush();
151 try t.tls_client.output.flush();
152}
153
154/// The decrypted stream from the server.
155pub fn reader(t: *Tls) *Io.Reader {
156 return &t.tls_client.reader;
157}
158
159/// The plaintext stream to the server. Flushing encrypts and pushes the
160/// records through to the underlying stream.
161pub fn writer(t: *Tls) *Io.Writer {
162 return &t.writer_state;
163}
164
165/// Flushes pending data and sends a TLS close_notify alert, letting the
166/// server distinguish a clean shutdown from a truncation attack. Call before
167/// closing the underlying stream.
168pub fn end(t: *Tls) Io.Writer.Error!void {
169 try t.tls_client.end();
170 try t.tls_client.output.flush();
171}
172
173pub fn deinit(t: *Tls, gpa: std.mem.Allocator) void {
174 gpa.free(t.read_buffer);
175 gpa.free(t.write_buffer);
176 t.* = undefined;
177}
178
179test {
180 // The handshake needs a live peer, so tests only force full semantic
181 // analysis here; end-to-end coverage comes from the demo CLI.
182 std.testing.refAllDecls(Tls);
183}