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 TLS support to client and server

Client (std.crypto.tls):
- Tls.zig wraps std.crypto.tls.Client for implicit TLS and STARTTLS,
verifying against the system trust store by default (caller-managed
bundle and insecure modes available)
- Client.starttls() does the RFC 3207 exchange; setTransport() swaps in
the encrypted reader/writer
- Tls.writer() is a flush-through wrapper: std's TLS writer encrypts on
flush but leaves records in the stream writer's buffer, which deadlocks
request/reply protocols like SMTP

Server (ianic/tls.zig, pinned to zig-0.16.x head):
- Options.starttls advertises and accepts STARTTLS (TLS 1.3 only): 220,
server handshake over the raw stream, transport swap, RFC 3207 state
reset; 503 on a second STARTTLS, close_notify on QUIT
- tls dependency re-exported as zsmtp.tls for CertKeyPair loading

CLI: send grew --tls/--starttls/--insecure, serve grew
--tls-cert/--tls-key. Verified end to end over real sockets: zsmtp
client <-> zsmtp server STARTTLS, openssl s_client -starttls smtp
against the server, and openssl s_server against the client.

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

+481 -67
+1
.gitignore
··· 3 3 4 4 .zig-cache/ 5 5 zig-out/ 6 + zig-pkg/
+67 -5
README.md
··· 30 30 the server's actual code and text. `mailFrom`/`rcptTo`/`sendMessage` are also 31 31 available individually, as is `authPlain`. 32 32 33 + ### TLS 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. 39 + 40 + Implicit TLS (port 465) — handshake first, then speak SMTP: 41 + 42 + ```zig 43 + var tls: zsmtp.Tls = try .init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 44 + .host = "smtp.example.com", 45 + }); 46 + defer tls.deinit(gpa); 47 + var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); 48 + // ... greet, hello, sendMail ... 49 + try client.quit(); 50 + try tls.end(); // close_notify, before closing the socket 51 + ``` 52 + 53 + STARTTLS (port 587) — upgrade mid-session, then EHLO again: 54 + 55 + ```zig 56 + _ = try client.greet(); 57 + _ = try client.hello("my-host.example.com"); // check .starttls in the result 58 + try client.starttls(); 59 + var tls: zsmtp.Tls = try .init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{ 60 + .host = "smtp.example.com", 61 + }); 62 + client.setTransport(tls.reader(), tls.writer()); 63 + _ = try client.hello("my-host.example.com"); // server state was reset 64 + ``` 65 + 33 66 ## Server 34 67 35 68 ```zig ··· 47 80 sequencing, recipient and message-size limits, and un-stuffing message data. 48 81 Listening, accepting, and concurrency are up to the caller. 49 82 83 + To advertise and accept STARTTLS (TLS 1.3, via 84 + [ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key 85 + pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` / 86 + `zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them: 87 + 88 + ```zig 89 + var auth: zsmtp.tls.config.CertKeyPair = 90 + try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem"); 91 + defer auth.deinit(gpa); 92 + 93 + var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 94 + .hostname = "mx.example.com", 95 + .starttls = .{ .io = io, .auth = &auth }, 96 + }); 97 + try session.run(gpa); 98 + ``` 99 + 100 + On STARTTLS the session answers 220, performs the server handshake, swaps 101 + its transport to the encrypted connection, and resets state per RFC 3207 (the 102 + client must EHLO again). 103 + 50 104 ## Demo CLI 51 105 52 106 ```sh 53 107 zig build 54 108 55 - # Debug server that prints received messages to stdout: 109 + # Debug server that prints received messages to stdout 110 + # (with a cert/key pair it advertises and accepts STARTTLS): 56 111 ./zig-out/bin/zsmtp serve 2525 112 + ./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525 57 113 58 114 # Send a message read from stdin: 59 115 printf 'Subject: hi\r\n\r\nhello\r\n' | \ 60 116 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net 117 + 118 + # Same, over implicit TLS or STARTTLS (--insecure skips cert verification): 119 + zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 120 + zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 61 121 ``` 62 122 63 123 ## Status 64 124 65 - Plaintext SMTP only for now — STARTTLS/implicit TLS is the next planned step 66 - (the transport-agnostic design is meant to make that a drop-in layer). 67 - Not yet implemented: TLS, streaming (non-slice) message bodies, AUTH beyond 68 - PLAIN, and ESMTP parameter handling (SIZE=, BODY=) on the server side. 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 128 + implemented: implicit TLS on the server side, streaming (non-slice) message 129 + bodies, AUTH beyond PLAIN, and ESMTP parameter handling (SIZE=, BODY=) on 130 + the server side. 69 131 70 132 ## Tests 71 133
+8
build.zig
··· 31 31 // to our consumers. We must give it a name because a Zig package can expose 32 32 // multiple modules and consumers will need to be able to specify which 33 33 // module they want to access. 34 + const tls_dep = b.dependency("tls", .{ 35 + .target = target, 36 + .optimize = optimize, 37 + }); 38 + 34 39 const mod = b.addModule("zsmtp", .{ 35 40 // The root source file is the "entry point" of this module. Users of 36 41 // this module will only be able to access public declarations contained ··· 42 47 // Later on we'll use this module as the root module of a test executable 43 48 // which requires us to specify a target. 44 49 .target = target, 50 + .imports = &.{ 51 + .{ .name = "tls", .module = tls_dep.module("tls") }, 52 + }, 45 53 }); 46 54 47 55 // Here we define an executable. An executable needs to have a root module
+4 -37
build.zig.zon
··· 35 35 // Once all dependencies are fetched, `zig build` no longer requires 36 36 // internet connectivity. 37 37 .dependencies = .{ 38 - // See `zig fetch --save <url>` for a command-line interface for adding dependencies. 39 - //.example = .{ 40 - // // When updating this field to a new URL, be sure to delete the corresponding 41 - // // `hash`, otherwise you are communicating that you expect to find the old hash at 42 - // // the new URL. If the contents of a URL change this will result in a hash mismatch 43 - // // which will prevent zig from using it. 44 - // .url = "https://example.com/foo.tar.gz", 45 - // 46 - // // This is computed from the file contents of the directory of files that is 47 - // // obtained after fetching `url` and applying the inclusion rules given by 48 - // // `paths`. 49 - // // 50 - // // This field is the source of truth; packages do not come from a `url`; they 51 - // // come from a `hash`. `url` is just one of many possible mirrors for how to 52 - // // obtain a package matching this `hash`. 53 - // // 54 - // // Uses the [multihash](https://multiformats.io/multihash/) format. 55 - // .hash = "...", 56 - // 57 - // // When this is provided, the package is found in a directory relative to the 58 - // // build root. In this case the package's hash is irrelevant and therefore not 59 - // // computed. This field and `url` are mutually exclusive. 60 - // .path = "foo", 61 - // 62 - // // When this is set to `true`, a package is declared to be lazily 63 - // // fetched. This makes the dependency only get fetched if it is 64 - // // actually used. 65 - // .lazy = false, 66 - //}, 38 + .tls = .{ 39 + .url = "https://github.com/ianic/tls.zig/archive/e04ae448ce7ee70c136d4d48b059314543203809.tar.gz", 40 + .hash = "tls-0.1.0-ER2e0jGpBgCkVC-Yp12NgSdHNUtZr52MleJ8roHlUa54", 41 + }, 67 42 }, 68 - // Specifies the set of files and directories that are included in this package. 69 - // Only files and directories listed here are included in the `hash` that 70 - // is computed for this package. Only files listed here will remain on disk 71 - // when using the zig package manager. As a rule of thumb, one should list 72 - // files required for compilation plus any license(s). 73 - // Paths are relative to the build root. Use the empty string (`""`) to refer to 74 - // the build root itself. 75 - // A directory listed here means that all files within, recursively, are included. 76 43 .paths = .{ 77 44 "build.zig", 78 45 "build.zig.zon",
+50
src/Client.zig
··· 110 110 return error.UnexpectedReply; 111 111 } 112 112 113 + /// Sends STARTTLS (RFC 3207) and reads the server's 220 go-ahead. On 114 + /// success, perform a TLS handshake over the underlying stream (see `Tls`), 115 + /// switch to the encrypted transport with `setTransport`, and then call 116 + /// `hello` again — the server discards everything it learned before the 117 + /// handshake, including the EHLO state. 118 + pub fn starttls(c: *Client) Error!void { 119 + try c.send("STARTTLS", .{}); 120 + _ = try c.expect(220); 121 + } 122 + 123 + /// Replaces the session's transport, typically with a TLS reader/writer 124 + /// after `starttls`. 125 + pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer) void { 126 + c.reader = reader; 127 + c.writer = writer; 128 + } 129 + 113 130 /// Authenticates with AUTH PLAIN (RFC 4616). Pass an empty `authzid` unless 114 131 /// you need to act on behalf of another identity. Note that sending 115 132 /// credentials over an unencrypted connection exposes them to the network. ··· 264 281 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com")); 265 282 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); 266 283 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text); 284 + } 285 + 286 + test "starttls handshake handoff" { 287 + const plain_responses = "220 mx.example.com ESMTP\r\n" ++ 288 + "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++ 289 + "220 2.0.0 Ready to start TLS\r\n"; 290 + var reader: Io.Reader = .fixed(plain_responses); 291 + var out_buf: [256]u8 = undefined; 292 + var writer: Io.Writer = .fixed(&out_buf); 293 + var reply_buf: [256]u8 = undefined; 294 + var client: Client = .init(&reader, &writer, &reply_buf); 295 + 296 + _ = try client.greet(); 297 + const ext = try client.hello("client.example.org"); 298 + try std.testing.expect(ext.starttls); 299 + try client.starttls(); 300 + 301 + // Simulate the post-handshake encrypted transport with fresh buffers; 302 + // the session must re-EHLO on it. 303 + const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n"; 304 + var tls_reader: Io.Reader = .fixed(tls_responses); 305 + var tls_out_buf: [256]u8 = undefined; 306 + var tls_writer: Io.Writer = .fixed(&tls_out_buf); 307 + client.setTransport(&tls_reader, &tls_writer); 308 + 309 + const tls_ext = try client.hello("client.example.org"); 310 + try std.testing.expect(!tls_ext.starttls); 311 + try std.testing.expect(tls_ext.eight_bit_mime); 312 + try std.testing.expectEqualStrings( 313 + "EHLO client.example.org\r\nSTARTTLS\r\n", 314 + writer.buffered(), 315 + ); 316 + try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered()); 267 317 } 268 318 269 319 test "authPlain encodes credentials" {
+53 -5
src/Server.zig
··· 19 19 20 20 const std = @import("std"); 21 21 const Io = std.Io; 22 + const tls = @import("tls"); 22 23 const protocol = @import("protocol.zig"); 23 24 24 25 reader: *Io.Reader, 25 26 writer: *Io.Writer, 26 27 handler: Handler, 27 28 options: Options, 29 + /// True once a STARTTLS handshake has completed for this session. 30 + secured: bool = false, 31 + tls_connection: tls.Connection = undefined, 32 + tls_reader: tls.Connection.Reader = undefined, 33 + tls_writer: tls.Connection.Writer = undefined, 34 + tls_read_buffer: [4096]u8 = undefined, 35 + tls_write_buffer: [4096]u8 = undefined, 28 36 29 37 pub const Options = struct { 30 38 /// Hostname announced in the greeting and the EHLO response. ··· 32 40 /// Advertised via the SIZE extension and enforced during DATA. 33 41 max_message_size: usize = 16 * 1024 * 1024, 34 42 max_recipients: usize = 100, 43 + /// When set, STARTTLS is advertised and accepted. The underlying stream 44 + /// reader/writer handed to `init` must then have buffers of at least 45 + /// `tls.input_buffer_len` and `tls.output_buffer_len` bytes, since the 46 + /// handshake and TLS records run over them. 47 + starttls: ?StartTls = null, 48 + }; 49 + 50 + pub const StartTls = struct { 51 + io: Io, 52 + /// Server certificate chain and private key presented to clients. 53 + auth: *tls.config.CertKeyPair, 35 54 }; 36 55 37 56 /// A handler's verdict on an envelope step or a complete message. ··· 73 92 return .{ .reader = reader, .writer = writer, .handler = handler, .options = options }; 74 93 } 75 94 76 - pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory }; 95 + pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory, TlsHandshakeFailed }; 77 96 78 97 /// Serves the session until the client sends QUIT or disconnects. `gpa` 79 98 /// backs per-transaction storage (envelope and message data); everything is ··· 117 136 from = null; 118 137 recipients = .empty; 119 138 _ = arena_state.reset(.retain_capacity); 120 - try s.writer.print( 121 - "250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE {d}\r\n", 122 - .{ s.options.hostname, s.options.max_message_size }, 123 - ); 139 + try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n", .{s.options.hostname}); 140 + if (s.options.starttls != null and !s.secured) 141 + try s.writer.writeAll("250-STARTTLS\r\n"); 142 + try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); 124 143 try s.writer.flush(); 125 144 }, 126 145 .mail => |args| { ··· 187 206 .noop => try s.reply(250, "2.0.0 Ok"), 188 207 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"), 189 208 .help => try s.reply(214, "2.0.0 See RFC 5321"), 209 + .starttls => { 210 + const config = s.options.starttls orelse { 211 + try s.reply(502, "5.5.1 STARTTLS not supported"); 212 + continue; 213 + }; 214 + if (s.secured) { 215 + try s.reply(503, "5.5.1 TLS already active"); 216 + continue; 217 + } 218 + try s.reply(220, "2.0.0 Ready to start TLS"); 219 + var rng_source: std.Random.IoSource = .{ .io = config.io }; 220 + s.tls_connection = tls.server(s.reader, s.writer, .{ 221 + .auth = config.auth, 222 + .rng = rng_source.interface(), 223 + .now = Io.Clock.real.now(config.io), 224 + }) catch return error.TlsHandshakeFailed; 225 + s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer); 226 + s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer); 227 + s.reader = &s.tls_reader.interface; 228 + s.writer = &s.tls_writer.interface; 229 + s.secured = true; 230 + // RFC 3207 §4.2: both sides return to their initial state; 231 + // the client must EHLO again. 232 + greeted = false; 233 + from = null; 234 + recipients = .empty; 235 + _ = arena_state.reset(.retain_capacity); 236 + }, 190 237 .quit => { 191 238 try s.reply(221, "2.0.0 Bye"); 239 + if (s.secured) s.tls_connection.close() catch {}; 192 240 return; 193 241 }, 194 242 .unknown => try s.reply(500, "5.5.2 Command not recognized"),
+183
src/Tls.zig
··· 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 + 14 + const Tls = @This(); 15 + 16 + const std = @import("std"); 17 + const Io = std.Io; 18 + 19 + tls_client: std.crypto.tls.Client, 20 + read_buffer: []u8, 21 + 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 + 29 + /// Minimum buffer size for the underlying stream reader and writer. 30 + pub const min_buffer_len = std.crypto.tls.Client.min_buffer_len; 31 + 32 + pub 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 + 54 + pub 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`. 62 + pub 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 + 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(); 152 + } 153 + 154 + /// The decrypted stream from the server. 155 + pub 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. 161 + pub 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. 168 + pub fn end(t: *Tls) Io.Writer.Error!void { 169 + try t.tls_client.end(); 170 + try t.tls_client.output.flush(); 171 + } 172 + 173 + pub 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 + 179 + test { 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 + }
+108 -20
src/main.zig
··· 3 3 4 4 //! Demo CLI for the zsmtp library. 5 5 //! 6 - //! zsmtp send <host> <port> <from> <to>... send a message read from stdin 7 - //! zsmtp serve <port> run a debug server on 127.0.0.1 8 - //! that prints received messages 6 + //! zsmtp send [--tls|--starttls] [--insecure] <host> <port> <from> <to>... 7 + //! send a message read from stdin; --tls speaks TLS from the first 8 + //! byte (port 465 style), --starttls upgrades after EHLO (port 587 9 + //! style), --insecure skips certificate verification 10 + //! zsmtp serve [--tls-cert <pem> --tls-key <pem>] <port> 11 + //! run a debug server on 127.0.0.1 that prints received messages; 12 + //! with a certificate and key it advertises and accepts STARTTLS 9 13 10 14 const std = @import("std"); 11 15 const Io = std.Io; ··· 16 20 const io = init.io; 17 21 const args = try init.minimal.args.toSlice(arena); 18 22 19 - if (args.len >= 6 and std.mem.eql(u8, args[1], "send")) { 20 - return send(io, arena, args[2], args[3], args[4], args[5..]); 23 + if (args.len >= 2 and std.mem.eql(u8, args[1], "send")) { 24 + var config: SendConfig = .{}; 25 + var rest = args[2..]; 26 + while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) { 27 + if (std.mem.eql(u8, rest[0], "--tls")) { 28 + config.mode = .tls; 29 + } else if (std.mem.eql(u8, rest[0], "--starttls")) { 30 + config.mode = .starttls; 31 + } else if (std.mem.eql(u8, rest[0], "--insecure")) { 32 + config.insecure = true; 33 + } else { 34 + return usage(); 35 + } 36 + rest = rest[1..]; 37 + } 38 + if (rest.len < 4) return usage(); 39 + return send(io, arena, config, rest[0], rest[1], rest[2], rest[3..]); 21 40 } 22 - if (args.len == 3 and std.mem.eql(u8, args[1], "serve")) { 23 - return serve(io, arena, args[2]); 41 + if (args.len >= 2 and std.mem.eql(u8, args[1], "serve")) { 42 + var config: ServeConfig = .{}; 43 + var rest = args[2..]; 44 + while (rest.len >= 2 and std.mem.startsWith(u8, rest[0], "--")) { 45 + if (std.mem.eql(u8, rest[0], "--tls-cert")) { 46 + config.cert_path = rest[1]; 47 + } else if (std.mem.eql(u8, rest[0], "--tls-key")) { 48 + config.key_path = rest[1]; 49 + } else { 50 + return usage(); 51 + } 52 + rest = rest[2..]; 53 + } 54 + if (rest.len != 1) return usage(); 55 + if ((config.cert_path == null) != (config.key_path == null)) return usage(); 56 + return serve(io, arena, config, rest[0]); 24 57 } 58 + return usage(); 59 + } 60 + 61 + const ServeConfig = struct { 62 + cert_path: ?[]const u8 = null, 63 + key_path: ?[]const u8 = null, 64 + }; 65 + 66 + const SendConfig = struct { 67 + mode: enum { plain, tls, starttls } = .plain, 68 + insecure: bool = false, 69 + }; 70 + 71 + fn usage() noreturn { 25 72 std.log.err( 26 73 \\usage: 27 - \\ zsmtp send <host> <port> <from> <to>... (message is read from stdin) 28 - \\ zsmtp serve <port> 74 + \\ zsmtp send [--tls|--starttls] [--insecure] <host> <port> <from> <to>... 75 + \\ (message is read from stdin) 76 + \\ zsmtp serve [--tls-cert <pem> --tls-key <pem>] <port> 29 77 , .{}); 30 78 std.process.exit(1); 31 79 } ··· 33 81 fn send( 34 82 io: Io, 35 83 arena: std.mem.Allocator, 84 + config: SendConfig, 36 85 host_arg: []const u8, 37 86 port_arg: []const u8, 38 87 from: []const u8, ··· 47 96 48 97 const stream = try host.connect(io, port, .{ .mode = .stream }); 49 98 defer stream.close(io); 50 - var read_buf: [4096]u8 = undefined; 51 - var write_buf: [4096]u8 = undefined; 52 - var stream_reader = stream.reader(io, &read_buf); 53 - var stream_writer = stream.writer(io, &write_buf); 99 + // The TLS layer requires stream buffers of at least min_buffer_len. 100 + const read_buf = try arena.alloc(u8, zsmtp.Tls.min_buffer_len); 101 + const write_buf = try arena.alloc(u8, zsmtp.Tls.min_buffer_len); 102 + var stream_reader = stream.reader(io, read_buf); 103 + var stream_writer = stream.writer(io, write_buf); 104 + 105 + const tls_options: zsmtp.Tls.Options = .{ 106 + .host = host_arg, 107 + .ca = if (config.insecure) .insecure else .system, 108 + }; 109 + var tls: zsmtp.Tls = undefined; 110 + var tls_active = false; 111 + defer if (tls_active) { 112 + tls.end() catch {}; 113 + tls.deinit(arena); 114 + }; 54 115 55 116 var reply_buf: [1024]u8 = undefined; 56 117 var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 57 118 119 + if (config.mode == .tls) { 120 + tls = try .init(arena, io, &stream_reader.interface, &stream_writer.interface, tls_options); 121 + tls_active = true; 122 + client.setTransport(tls.reader(), tls.writer()); 123 + } 124 + 58 125 _ = try client.greet(); 59 126 _ = try client.hello("localhost"); 127 + 128 + if (config.mode == .starttls) { 129 + try client.starttls(); 130 + tls = try .init(arena, io, &stream_reader.interface, &stream_writer.interface, tls_options); 131 + tls_active = true; 132 + client.setTransport(tls.reader(), tls.writer()); 133 + _ = try client.hello("localhost"); 134 + } 135 + 60 136 client.sendMail(from, recipients, message) catch |err| { 61 137 if (err == error.UnexpectedReply) { 62 138 const reply = client.last_reply.?; ··· 68 144 std.log.info("message sent to {d} recipient(s)", .{recipients.len}); 69 145 } 70 146 71 - fn serve(io: Io, gpa: std.mem.Allocator, port_arg: []const u8) !void { 147 + fn serve(io: Io, gpa: std.mem.Allocator, config: ServeConfig, port_arg: []const u8) !void { 72 148 const port = try std.fmt.parseInt(u16, port_arg, 10); 73 149 const address: Io.net.IpAddress = .{ .ip4 = .loopback(port) }; 74 150 var listener = try address.listen(io, .{}); 75 151 defer listener.deinit(io); 76 - std.log.info("listening on 127.0.0.1:{d}", .{port}); 152 + 153 + var auth: ?zsmtp.tls.config.CertKeyPair = if (config.cert_path) |cert_path| 154 + try .fromFilePath(gpa, io, .cwd(), cert_path, config.key_path.?) 155 + else 156 + null; 157 + const starttls: ?zsmtp.Server.StartTls = if (auth) |*a| .{ .io = io, .auth = a } else null; 158 + std.log.info("listening on 127.0.0.1:{d}{s}", .{ 159 + port, 160 + if (starttls != null) " with STARTTLS" else "", 161 + }); 77 162 78 163 var stdout_buf: [4096]u8 = undefined; 79 164 var stdout: Io.File.Writer = .init(.stdout(), io, &stdout_buf); ··· 82 167 while (true) { 83 168 const stream = try listener.accept(io); 84 169 defer stream.close(io); 85 - var read_buf: [4096]u8 = undefined; 86 - var write_buf: [4096]u8 = undefined; 87 - var stream_reader = stream.reader(io, &read_buf); 88 - var stream_writer = stream.writer(io, &write_buf); 170 + // Sized for the TLS handshake, which runs over the raw stream. 171 + const read_buf = try gpa.alloc(u8, zsmtp.tls.input_buffer_len); 172 + defer gpa.free(read_buf); 173 + const write_buf = try gpa.alloc(u8, zsmtp.tls.output_buffer_len); 174 + defer gpa.free(write_buf); 175 + var stream_reader = stream.reader(io, read_buf); 176 + var stream_writer = stream.writer(io, write_buf); 89 177 var session: zsmtp.Server = .init( 90 178 &stream_reader.interface, 91 179 &stream_writer.interface, 92 180 .{ .context = &printer, .vtable = &.{ .message = MessagePrinter.onMessage } }, 93 - .{ .hostname = "localhost" }, 181 + .{ .hostname = "localhost", .starttls = starttls }, 94 182 ); 95 183 session.run(gpa) catch |err| { 96 184 std.log.warn("session ended with error: {t}", .{err});
+2
src/protocol.zig
··· 112 112 quit, 113 113 vrfy: []const u8, 114 114 help, 115 + starttls, 115 116 /// Unrecognized command verb; the payload is the full line. 116 117 unknown: []const u8, 117 118 ··· 149 150 if (ieql(verb, "QUIT")) return .quit; 150 151 if (ieql(verb, "VRFY")) return .{ .vrfy = rest }; 151 152 if (ieql(verb, "HELP")) return .help; 153 + if (ieql(verb, "STARTTLS")) return .starttls; 152 154 return .{ .unknown = line }; 153 155 } 154 156
+5
src/root.zig
··· 15 15 pub const Command = protocol.Command; 16 16 pub const Client = @import("Client.zig"); 17 17 pub const Server = @import("Server.zig"); 18 + pub const Tls = @import("Tls.zig"); 19 + /// Re-export of the ianic/tls.zig library used for server-side STARTTLS, 20 + /// e.g. `zsmtp.tls.config.CertKeyPair` for loading the server certificate. 21 + pub const tls = @import("tls"); 18 22 19 23 test { 20 24 _ = protocol; 21 25 _ = Client; 22 26 _ = Server; 27 + _ = Tls; 23 28 }