An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

Select the types of activity you want to include in your feed.

Add Exim interop tests; switch client TLS to tls.zig

The VM test now also runs the zsmtp client against Exim (ports
2625/2626) over plaintext, STARTTLS, and implicit TLS, with taint-safe
appendfile delivery checked in /var/spool/exim-mail.

Exim exposed a standard-library TLS bug: std.crypto.tls.Client only
advances its record-decryption state upon receiving the TLS 1.3
middlebox-compatibility ChangeCipherSpec record, which is optional and
disabled by Exim's OpenSSL setup, so the handshake died with
TlsUnexpectedMessage. The client-side Tls wrapper now uses ianic/tls.zig
(already used server-side) instead: init is in-place (the connection
holds interior pointers), and the flush-through workaround is gone since
tls.zig flushes each record to the stream.

A sendmail interop test was built and passing but removed again since
nixpkgs does not package the sendmail MTA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx

+147 -151
+18 -11
README.md
··· 32 32 33 33 ### TLS 34 34 35 - `zsmtp.Tls` wraps `std.crypto.tls.Client` and verifies against the system 36 - trust store by default (a caller-managed CA bundle and an insecure mode are 37 - also available). The stream reader/writer handed to it need buffers of at 38 - least `zsmtp.Tls.min_buffer_len` bytes. 35 + `zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and 36 + verifies against the system trust store by default (a caller-managed CA 37 + bundle and an insecure mode are also available). The stream reader/writer 38 + handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and 39 + `init` must run at the value's final address (the connection holds interior 40 + pointers). The standard library's TLS client is deliberately not used: it 41 + requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec 42 + record, which servers like Exim disable. 39 43 40 44 Implicit TLS (port 465) — handshake first, then speak SMTP: 41 45 42 46 ```zig 43 - var tls: zsmtp.Tls = try .init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 47 + var tls: zsmtp.Tls = undefined; 48 + try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 44 49 .host = "smtp.example.com", 45 50 }); 46 51 defer tls.deinit(gpa); ··· 56 61 _ = try client.greet(); 57 62 _ = try client.hello("my-host.example.com"); // check .starttls in the result 58 63 try client.starttls(); 59 - var tls: zsmtp.Tls = try .init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 64 + var tls: zsmtp.Tls = undefined; 65 + try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 60 66 .host = "smtp.example.com", 61 67 }); 62 68 client.setTransport(tls.reader(), tls.writer()); ··· 122 128 123 129 ## Status 124 130 125 - TLS is supported on both sides: the client does implicit TLS and STARTTLS 126 - via `zsmtp.Tls` (`std.crypto.tls`), and the server accepts STARTTLS (TLS 1.3 127 - only) via [ianic/tls.zig](https://github.com/ianic/tls.zig). Not yet 131 + TLS is supported on both sides via 132 + [ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 133 + TLS and STARTTLS via `zsmtp.Tls`, and the server accepts STARTTLS (TLS 1.3 134 + only). Not yet 128 135 implemented: implicit TLS on the server side, streaming (non-slice) message 129 136 bodies, AUTH beyond PLAIN, and ESMTP parameter handling (SIZE=, BODY=) on 130 137 the server side. ··· 137 144 138 145 Interoperability against third-party implementations is covered by a NixOS 139 146 VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix 140 - over plaintext, STARTTLS, and implicit TLS, and swaks delivers to the zsmtp 141 - server over plaintext and STARTTLS. 147 + and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks 148 + delivers to the zsmtp server over plaintext and STARTTLS. 142 149 143 150 ```sh 144 151 nix build .#zsmtp # build the package
+70 -25
nix/interop-test.nix
··· 2 2 # SPDX-License-Identifier: MIT 3 3 4 4 # NixOS VM test exercising zsmtp against third-party implementations: 5 - # - zsmtp client -> Postfix: plaintext, STARTTLS (port 25), implicit TLS 6 - # (port 465), verified by checking local delivery to alice's spool 5 + # - zsmtp client -> Postfix (25/465) and Exim (2625/2626): plaintext, 6 + # STARTTLS, and implicit TLS against each, verified by checking local 7 + # delivery to alice's mailbox 7 8 # - swaks -> zsmtp server: plaintext and STARTTLS, verified by checking 8 9 # the received message in the server's journal 9 10 ··· 62 63 }; 63 64 }; 64 65 66 + # Exim: plaintext + STARTTLS on 2625, implicit TLS on 2626, delivering 67 + # to /var/spool/exim-mail/<user>. 68 + services.exim = { 69 + enable = true; 70 + config = '' 71 + primary_hostname = machine.test 72 + qualify_domain = localhost 73 + local_interfaces = 127.0.0.1 74 + daemon_smtp_ports = 2625 : 2626 75 + tls_on_connect_ports = 2626 76 + tls_advertise_hosts = * 77 + tls_certificate = ${snakeoil}/cert.pem 78 + tls_privatekey = ${snakeoil}/key.pem 79 + acl_smtp_rcpt = acl_rcpt 80 + 81 + begin acl 82 + 83 + acl_rcpt: 84 + accept 85 + 86 + begin routers 87 + 88 + local_users: 89 + driver = accept 90 + local_parts = alice : bob 91 + transport = local_delivery 92 + 93 + begin transports 94 + 95 + local_delivery: 96 + driver = appendfile 97 + file = /var/spool/exim-mail/$local_part_data 98 + user = exim 99 + delivery_date_add 100 + envelope_to_add 101 + return_path_add 102 + ''; 103 + }; 104 + 105 + systemd.tmpfiles.rules = [ 106 + "d /var/spool/exim-mail 0755 exim exim -" 107 + ]; 108 + 65 109 systemd.services.zsmtp-server = { 66 110 description = "zsmtp debug server (plaintext)"; 67 111 wantedBy = [ "multi-user.target" ]; ··· 85 129 machine.wait_for_unit("postfix.service") 86 130 machine.wait_for_open_port(25) 87 131 machine.wait_for_open_port(465) 132 + machine.wait_for_unit("exim.service") 133 + machine.wait_for_open_port(2625) 134 + machine.wait_for_open_port(2626) 88 135 machine.wait_for_unit("zsmtp-server.service") 89 136 machine.wait_for_unit("zsmtp-server-tls.service") 90 137 machine.wait_for_open_port(2525) 91 138 machine.wait_for_open_port(2526) 92 139 93 - with subtest("zsmtp client to postfix, plaintext"): 94 - machine.succeed( 95 - "printf 'Subject: interop\\r\\n\\r\\nzsmtp to postfix plain\\r\\n'" 96 - " | zsmtp send 127.0.0.1 25 bob@example.com alice@localhost" 97 - ) 98 - machine.wait_until_succeeds( 99 - "grep -r 'zsmtp to postfix plain' /var/spool/mail/alice/", timeout=60 100 - ) 101 140 102 - with subtest("zsmtp client to postfix, STARTTLS"): 141 + def deliver(flags, port, needle, mailbox): 103 142 machine.succeed( 104 - "printf 'Subject: interop\\r\\n\\r\\nzsmtp to postfix starttls\\r\\n'" 105 - " | zsmtp send --starttls --insecure 127.0.0.1 25" 143 + f"printf 'Subject: interop\\r\\n\\r\\n{needle}\\r\\n'" 144 + f" | zsmtp send {flags} 127.0.0.1 {port}" 106 145 " bob@example.com alice@localhost" 107 146 ) 108 - machine.wait_until_succeeds( 109 - "grep -r 'zsmtp to postfix starttls' /var/spool/mail/alice/", timeout=60 110 - ) 147 + machine.wait_until_succeeds(f"grep -r '{needle}' {mailbox}", timeout=60) 111 148 112 - with subtest("zsmtp client to postfix, implicit TLS"): 113 - machine.succeed( 114 - "printf 'Subject: interop\\r\\n\\r\\nzsmtp to postfix smtps\\r\\n'" 115 - " | zsmtp send --tls --insecure 127.0.0.1 465" 116 - " bob@example.com alice@localhost" 117 - ) 118 - machine.wait_until_succeeds( 119 - "grep -r 'zsmtp to postfix smtps' /var/spool/mail/alice/", timeout=60 120 - ) 149 + 150 + servers = { 151 + "postfix": (25, 465, "/var/spool/mail/alice/"), 152 + "exim": (2625, 2626, "/var/spool/exim-mail/alice"), 153 + } 154 + 155 + for name, (port, tls_port, mailbox) in servers.items(): 156 + with subtest(f"zsmtp client to {name}, plaintext"): 157 + deliver("", port, f"zsmtp to {name} plain", mailbox) 158 + 159 + with subtest(f"zsmtp client to {name}, STARTTLS"): 160 + deliver( 161 + "--starttls --insecure", port, f"zsmtp to {name} starttls", mailbox 162 + ) 163 + 164 + with subtest(f"zsmtp client to {name}, implicit TLS"): 165 + deliver("--tls --insecure", tls_port, f"zsmtp to {name} smtps", mailbox) 121 166 122 167 with subtest("swaks to zsmtp server, plaintext"): 123 168 machine.succeed(
+57 -113
src/Tls.zig
··· 1 1 // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 2 // SPDX-License-Identifier: MIT 3 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`). 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. 10 13 //! 11 14 //! The underlying stream reader and writer must each have a buffer of at 12 - //! least `min_buffer_len` bytes. 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(gpa, io, &stream_reader.interface, 21 + //! &stream_writer.interface, .{ .host = "smtp.example.com" }); 22 + //! ``` 13 23 14 24 const Tls = @This(); 15 25 16 26 const std = @import("std"); 17 27 const Io = std.Io; 28 + const tls = @import("tls"); 18 29 19 - tls_client: std.crypto.tls.Client, 30 + connection: tls.Connection, 31 + reader_state: tls.Connection.Reader, 32 + writer_state: tls.Connection.Writer, 20 33 read_buffer: []u8, 21 34 write_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). 27 - writer_state: Io.Writer, 28 35 29 36 /// Minimum buffer size for the underlying stream reader and writer. 30 - pub const min_buffer_len = std.crypto.tls.Client.min_buffer_len; 37 + pub const min_buffer_len = tls.input_buffer_len; 31 38 32 39 pub const Options = struct { 33 40 /// Host name the server's certificate must be valid for (also sent via ··· 39 46 /// Verify the server certificate against the system trust store, 40 47 /// loaded fresh for this connection. 41 48 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 - }, 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, 48 53 /// No certificate verification at all. This provides encryption but 49 54 /// no authentication — fine for tests, unsafe on real networks. 50 55 insecure, 51 56 }; 52 57 }; 53 58 54 - pub const InitError = std.crypto.tls.Client.InitError || error{ 55 - OutOfMemory, 56 - CertificateBundleLoadFailure, 57 - }; 58 - 59 59 /// Performs the TLS handshake over `input`/`output` (the stream's 60 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`. 61 + /// Allocates plaintext buffers from `gpa`; free them with `deinit`. 62 62 pub fn init( 63 + t: *Tls, 63 64 gpa: std.mem.Allocator, 64 65 io: Io, 65 66 input: *Io.Reader, 66 67 output: *Io.Writer, 67 68 options: Options, 68 - ) InitError!Tls { 69 - const read_buffer = try gpa.alloc(u8, min_buffer_len); 69 + ) !void { 70 + const read_buffer = try gpa.alloc(u8, 4096); 70 71 errdefer gpa.free(read_buffer); 71 - const write_buffer = try gpa.alloc(u8, min_buffer_len); 72 + const write_buffer = try gpa.alloc(u8, 4096); 72 73 errdefer gpa.free(write_buffer); 73 74 74 - var entropy: [std.crypto.tls.Client.Options.entropy_len]u8 = undefined; 75 - io.random(&entropy); 76 - const now = Io.Clock.real.now(io); 75 + var rng_source: std.Random.IoSource = .{ .io = io }; 77 76 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 - }); 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, 97 89 }, 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 - 132 - fn 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 - 148 - fn 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(); 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); 152 96 } 153 97 154 98 /// The decrypted stream from the server. 155 99 pub fn reader(t: *Tls) *Io.Reader { 156 - return &t.tls_client.reader; 100 + return &t.reader_state.interface; 157 101 } 158 102 159 - /// The plaintext stream to the server. Flushing encrypts and pushes the 103 + /// The plaintext stream to the server; each flush encrypts and pushes the 160 104 /// records through to the underlying stream. 161 105 pub fn writer(t: *Tls) *Io.Writer { 162 - return &t.writer_state; 106 + return &t.writer_state.interface; 163 107 } 164 108 165 109 /// Flushes pending data and sends a TLS close_notify alert, letting the 166 110 /// server distinguish a clean shutdown from a truncation attack. Call before 167 111 /// closing the underlying stream. 168 - pub fn end(t: *Tls) Io.Writer.Error!void { 169 - try t.tls_client.end(); 170 - try t.tls_client.output.flush(); 112 + pub fn end(t: *Tls) error{WriteFailed}!void { 113 + try t.writer_state.interface.flush(); 114 + t.connection.close() catch return error.WriteFailed; 171 115 } 172 116 173 117 pub fn deinit(t: *Tls, gpa: std.mem.Allocator) void { ··· 178 122 179 123 test { 180 124 // The handshake needs a live peer, so tests only force full semantic 181 - // analysis here; end-to-end coverage comes from the demo CLI. 125 + // analysis here; end-to-end coverage comes from the NixOS interop test. 182 126 std.testing.refAllDecls(Tls); 183 127 }
+2 -2
src/main.zig
··· 117 117 var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 118 118 119 119 if (config.mode == .tls) { 120 - tls = try .init(arena, io, &stream_reader.interface, &stream_writer.interface, tls_options); 120 + try tls.init(arena, io, &stream_reader.interface, &stream_writer.interface, tls_options); 121 121 tls_active = true; 122 122 client.setTransport(tls.reader(), tls.writer()); 123 123 } ··· 127 127 128 128 if (config.mode == .starttls) { 129 129 try client.starttls(); 130 - tls = try .init(arena, io, &stream_reader.interface, &stream_writer.interface, tls_options); 130 + try tls.init(arena, io, &stream_reader.interface, &stream_writer.interface, tls_options); 131 131 tls_active = true; 132 132 client.setTransport(tls.reader(), tls.writer()); 133 133 _ = try client.hello("localhost");