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, using
5//! ianic/tls.zig (the same library that backs server-side STARTTLS). Works
6//! for both implicit TLS (connect, then `init` before any SMTP traffic) and
7//! STARTTLS (after `Client.starttls` succeeds, `init` over the same stream,
8//! then `Client.setTransport` and a fresh `hello`).
9//!
10//! The standard library's TLS client is not used because it requires the
11//! optional TLS 1.3 middlebox-compatibility ChangeCipherSpec record from the
12//! server; servers that disable it (e.g. Exim) break its handshake.
13//!
14//! The underlying stream reader and writer must each have a buffer of at
15//! least `min_buffer_len` bytes. `init` must be called on the `Tls` at its
16//! final resting address (the connection holds interior pointers), e.g.:
17//!
18//! ```
19//! var tls_transport: Tls = undefined;
20//! try tls_transport.init(io, gpa, &stream_reader.interface,
21//! &stream_writer.interface, .{ .host = "smtp.example.com" });
22//! ```
23
24const Tls = @This();
25
26const std = @import("std");
27const Io = std.Io;
28const tls = @import("tls");
29
30connection: tls.Connection,
31reader_state: tls.Connection.Reader,
32writer_state: tls.Connection.Writer,
33read_buffer: []u8,
34write_buffer: []u8,
35
36/// Minimum buffer size for the underlying stream reader and writer.
37pub const min_buffer_len = tls.input_buffer_len;
38
39pub const Options = struct {
40 /// Host name the server's certificate must be valid for (also sent via
41 /// SNI). Ignored with `ca = .insecure`.
42 host: []const u8,
43 ca: Ca = .system,
44
45 pub const Ca = union(enum) {
46 /// Verify the server certificate against the system trust store,
47 /// loaded fresh for this connection.
48 system,
49 /// Verify against a caller-provided bundle (reusable across
50 /// connections; see `tls.config.cert.fromSystem`). Not deinitialized
51 /// by this transport.
52 bundle: std.crypto.Certificate.Bundle,
53 /// No certificate verification at all. This provides encryption but
54 /// no authentication — fine for tests, unsafe on real networks.
55 insecure,
56 };
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 plaintext buffers from `gpa`; free them with `deinit`.
62pub fn init(
63 t: *Tls,
64 io: Io,
65 gpa: std.mem.Allocator,
66 input: *Io.Reader,
67 output: *Io.Writer,
68 options: Options,
69) !void {
70 const read_buffer = try gpa.alloc(u8, 4096);
71 errdefer gpa.free(read_buffer);
72 const write_buffer = try gpa.alloc(u8, 4096);
73 errdefer gpa.free(write_buffer);
74
75 var rng_source: std.Random.IoSource = .{ .io = io };
76
77 var system_ca: ?std.crypto.Certificate.Bundle = null;
78 defer if (system_ca) |*bundle| bundle.deinit(gpa);
79 if (options.ca == .system) system_ca = try tls.config.cert.fromSystem(gpa, io);
80
81 t.connection = try tls.client(input, output, .{
82 .rng = rng_source.interface(),
83 .now = Io.Clock.real.now(io),
84 .host = if (options.ca == .insecure) "" else options.host,
85 .root_ca = switch (options.ca) {
86 .system => system_ca.?,
87 .bundle => |bundle| bundle,
88 .insecure => .empty,
89 },
90 .insecure_skip_verify = options.ca == .insecure,
91 });
92 t.read_buffer = read_buffer;
93 t.write_buffer = write_buffer;
94 t.reader_state = t.connection.reader(read_buffer);
95 t.writer_state = t.connection.writer(write_buffer);
96}
97
98/// The decrypted stream from the server.
99pub fn reader(t: *Tls) *Io.Reader {
100 return &t.reader_state.interface;
101}
102
103/// The plaintext stream to the server; each flush encrypts and pushes the
104/// records through to the underlying stream.
105pub fn writer(t: *Tls) *Io.Writer {
106 return &t.writer_state.interface;
107}
108
109/// Flushes pending data and sends a TLS close_notify alert, letting the
110/// server distinguish a clean shutdown from a truncation attack. Call before
111/// closing the underlying stream.
112pub fn end(t: *Tls) error{WriteFailed}!void {
113 try t.writer_state.interface.flush();
114 t.connection.close() catch return error.WriteFailed;
115}
116
117pub fn deinit(t: *Tls, gpa: std.mem.Allocator) void {
118 gpa.free(t.read_buffer);
119 gpa.free(t.write_buffer);
120 t.* = undefined;
121}
122
123test {
124 // The handshake needs a live peer, so tests only force full semantic
125 // analysis here; end-to-end coverage comes from the NixOS interop test.
126 std.testing.refAllDecls(Tls);
127}