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 implicit TLS on the server side

Server.Options.starttls becomes tls: ?TlsOptions with a mode field:
.starttls keeps the RFC 3207 behavior (advertise, 220, upgrade, state
reset) and .implicit performs the tls.zig server handshake before the
greeting (SMTPS, port 465 style). Both paths share one upgradeToTls
helper; in implicit mode STARTTLS is never advertised and the command
gets 502. Breaking rename for Server.Options at version 0.0.0.

The serve CLI grows --implicit-tls (requires --tls-cert/--tls-key) and
its flag parser now supports valueless flags.

Verified locally with openssl s_client (greeting arrives inside the
TLS channel) and our own --tls client, plus a STARTTLS regression
check. The VM interop test adds an implicit-TLS zsmtp server and a
swaks --tlsc subtest against it; all 16 subtests pass.

The Status list is complete: TLS in both modes on both sides, AUTH,
streaming bodies, and MAIL parameter validation.

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

+112 -39
+15 -7
README.md
··· 137 137 138 138 var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 139 139 .hostname = "mx.example.com", 140 - .starttls = .{ .io = io, .auth = &auth }, 140 + .tls = .{ .io = io, .auth = &auth }, 141 141 }); 142 142 try session.run(gpa); 143 143 ``` 144 144 145 145 On STARTTLS the session answers 220, performs the server handshake, swaps 146 146 its transport to the encrypted connection, and resets state per RFC 3207 (the 147 - client must EHLO again). 147 + client must EHLO again). With `.mode = .implicit` the handshake instead runs 148 + before the greeting (SMTPS, port 465 style): 149 + 150 + ```zig 151 + var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{ 152 + .hostname = "mx.example.com", 153 + .tls = .{ .io = io, .auth = &auth, .mode = .implicit }, 154 + }); 155 + ``` 148 156 149 157 ## Demo CLI 150 158 ··· 155 163 # (with a cert/key pair it advertises and accepts STARTTLS): 156 164 ./zig-out/bin/zsmtp serve 2525 157 165 ./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525 166 + ./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem --implicit-tls 2465 158 167 159 168 # Send a message read from stdin: 160 169 printf 'Subject: hi\r\n\r\nhello\r\n' | \ ··· 169 178 170 179 TLS is supported on both sides via 171 180 [ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit 172 - TLS and STARTTLS via `zsmtp.Tls`, and the server accepts STARTTLS (TLS 1.3 173 - only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the client and PLAIN and 174 - LOGIN on the server. Message bodies can be streamed on both sides, and the 175 - server validates MAIL parameters (SIZE=, BODY=). Not yet implemented: 176 - implicit TLS on the server side. 181 + TLS and STARTTLS via `zsmtp.Tls`, and the server accepts both STARTTLS and 182 + implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the 183 + client and PLAIN and LOGIN on the server. Message bodies can be streamed on 184 + both sides, and the server validates MAIL parameters (SIZE=, BODY=). 177 185 178 186 ## Tests 179 187
+22
nix/interop-test.nix
··· 140 140 }; 141 141 }; 142 142 143 + systemd.services.zsmtp-server-tlsc = { 144 + description = "zsmtp debug server (implicit TLS)"; 145 + wantedBy = [ "multi-user.target" ]; 146 + serviceConfig = { 147 + ExecStart = "${zsmtp}/bin/zsmtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem --implicit-tls 2528"; 148 + DynamicUser = true; 149 + }; 150 + }; 151 + 143 152 systemd.services.zsmtp-server-auth = { 144 153 description = "zsmtp debug server (authentication required)"; 145 154 wantedBy = [ "multi-user.target" ]; ··· 159 168 machine.wait_for_open_port(2626) 160 169 machine.wait_for_unit("zsmtp-server.service") 161 170 machine.wait_for_unit("zsmtp-server-tls.service") 171 + machine.wait_for_unit("zsmtp-server-tlsc.service") 162 172 machine.wait_for_unit("zsmtp-server-auth.service") 163 173 machine.wait_for_open_port(2525) 164 174 machine.wait_for_open_port(2526) 165 175 machine.wait_for_open_port(2527) 176 + machine.wait_for_open_port(2528) 166 177 167 178 168 179 def deliver(flags, port, needle, mailbox): ··· 257 268 ) 258 269 machine.wait_until_succeeds( 259 270 "journalctl -u zsmtp-server | grep 'swaks to zsmtp plain'", timeout=60 271 + ) 272 + 273 + with subtest("swaks to zsmtp server, implicit TLS"): 274 + machine.succeed( 275 + "swaks --tlsc --server 127.0.0.1:2528 --from bob@example.com" 276 + " --to alice@example.net --header 'Subject: swaks tlsc'" 277 + " --body 'swaks to zsmtp implicit tls'" 278 + ) 279 + machine.wait_until_succeeds( 280 + "journalctl -u zsmtp-server-tlsc | grep 'swaks to zsmtp implicit tls'", 281 + timeout=60, 260 282 ) 261 283 262 284 with subtest("swaks to zsmtp server, STARTTLS"):
+46 -20
src/Server.zig
··· 40 40 /// Advertised via the SIZE extension and enforced during DATA. 41 41 max_message_size: usize = 16 * 1024 * 1024, 42 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, 43 + /// When set, the session speaks TLS (see `TlsOptions.mode`). The 44 + /// underlying stream reader/writer handed to `init` must then have 45 + /// buffers of at least `tls.input_buffer_len` and 46 + /// `tls.output_buffer_len` bytes, since the handshake and TLS records 47 + /// run over them. 48 + tls: ?TlsOptions = null, 48 49 /// Reject MAIL with 530 until the client has authenticated. Requires a 49 50 /// handler with an `authenticate` callback. 50 51 require_auth: bool = false, 51 52 }; 52 53 53 - pub const StartTls = struct { 54 + pub const TlsOptions = struct { 54 55 io: Io, 55 56 /// Server certificate chain and private key presented to clients. 56 57 auth: *tls.config.CertKeyPair, 58 + mode: Mode = .starttls, 59 + 60 + pub const Mode = enum { 61 + /// Advertise and accept the STARTTLS command (RFC 3207). 62 + starttls, 63 + /// Perform the TLS handshake before the greeting (implicit TLS / 64 + /// SMTPS, port 465 style). 65 + implicit, 66 + }; 57 67 }; 58 68 59 69 /// A handler's verdict on an envelope step or a complete message. ··· 128 138 std.debug.assert(!s.options.require_auth or s.handler.vtable.authenticate != null); 129 139 std.debug.assert((s.handler.vtable.message == null) != (s.handler.vtable.messageReader == null)); 130 140 141 + if (s.options.tls) |config| { 142 + if (config.mode == .implicit and !s.secured) try s.upgradeToTls(config); 143 + } 144 + 131 145 var greeted = false; 132 146 var authenticated = false; 133 147 var from: ?[]const u8 = null; ··· 170 184 body = .unspecified; 171 185 _ = arena_state.reset(.retain_capacity); 172 186 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n", .{s.options.hostname}); 173 - if (s.options.starttls != null and !s.secured) 174 - try s.writer.writeAll("250-STARTTLS\r\n"); 187 + if (s.options.tls) |config| { 188 + if (config.mode == .starttls and !s.secured) 189 + try s.writer.writeAll("250-STARTTLS\r\n"); 190 + } 175 191 if (s.handler.vtable.authenticate != null and !authenticated) 176 192 try s.writer.writeAll("250-AUTH PLAIN LOGIN\r\n"); 177 193 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); ··· 292 308 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"), 293 309 .help => try s.reply(214, "2.0.0 See RFC 5321"), 294 310 .starttls => { 295 - const config = s.options.starttls orelse { 311 + const config = s.options.tls orelse { 296 312 try s.reply(502, "5.5.1 STARTTLS not supported"); 297 313 continue; 298 314 }; 315 + if (config.mode != .starttls) { 316 + try s.reply(502, "5.5.1 STARTTLS not supported"); 317 + continue; 318 + } 299 319 if (s.secured) { 300 320 try s.reply(503, "5.5.1 TLS already active"); 301 321 continue; 302 322 } 303 323 try s.reply(220, "2.0.0 Ready to start TLS"); 304 - var rng_source: std.Random.IoSource = .{ .io = config.io }; 305 - s.tls_connection = tls.server(s.reader, s.writer, .{ 306 - .auth = config.auth, 307 - .rng = rng_source.interface(), 308 - .now = Io.Clock.real.now(config.io), 309 - }) catch return error.TlsHandshakeFailed; 310 - s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer); 311 - s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer); 312 - s.reader = &s.tls_reader.interface; 313 - s.writer = &s.tls_writer.interface; 314 - s.secured = true; 324 + try s.upgradeToTls(config); 315 325 // RFC 3207 §4.2: both sides return to their initial state; 316 326 // the client must EHLO again. 317 327 greeted = false; ··· 353 363 .unknown => try s.reply(500, "5.5.2 Command not recognized"), 354 364 } 355 365 } 366 + } 367 + 368 + /// Performs the server-side TLS handshake over the current transport and 369 + /// swaps the session onto the encrypted connection. 370 + fn upgradeToTls(s: *Server, config: TlsOptions) error{TlsHandshakeFailed}!void { 371 + var rng_source: std.Random.IoSource = .{ .io = config.io }; 372 + s.tls_connection = tls.server(s.reader, s.writer, .{ 373 + .auth = config.auth, 374 + .rng = rng_source.interface(), 375 + .now = Io.Clock.real.now(config.io), 376 + }) catch return error.TlsHandshakeFailed; 377 + s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer); 378 + s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer); 379 + s.reader = &s.tls_reader.interface; 380 + s.writer = &s.tls_writer.interface; 381 + s.secured = true; 356 382 } 357 383 358 384 const AuthOutcome = enum { authenticated, rejected, disconnected };
+29 -12
src/main.zig
··· 10 10 //! style), --insecure skips certificate verification, --user/--password 11 11 //! authenticate with the best advertised mechanism (or the one forced 12 12 //! by --auth-method) 13 - //! zsmtp serve [--tls-cert <pem> --tls-key <pem>] [--auth <user>:<pass>] <port> 13 + //! zsmtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] 14 + //! [--auth <user>:<pass>] <port> 14 15 //! run a debug server on 127.0.0.1 that prints received messages; 15 - //! --auth requires authentication with the given credentials; 16 - //! with a certificate and key it advertises and accepts STARTTLS 16 + //! --auth requires authentication with the given credentials; with a 17 + //! certificate and key it advertises and accepts STARTTLS, or speaks 18 + //! TLS from the first byte with --implicit-tls 17 19 18 20 const std = @import("std"); 19 21 const Io = std.Io; ··· 58 60 if (args.len >= 2 and std.mem.eql(u8, args[1], "serve")) { 59 61 var config: ServeConfig = .{}; 60 62 var rest = args[2..]; 61 - while (rest.len >= 2 and std.mem.startsWith(u8, rest[0], "--")) { 62 - if (std.mem.eql(u8, rest[0], "--tls-cert")) { 63 + while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) { 64 + if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--tls-cert")) { 63 65 config.cert_path = rest[1]; 64 - } else if (std.mem.eql(u8, rest[0], "--tls-key")) { 66 + rest = rest[1..]; 67 + } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--tls-key")) { 65 68 config.key_path = rest[1]; 66 - } else if (std.mem.eql(u8, rest[0], "--auth")) { 69 + rest = rest[1..]; 70 + } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--auth")) { 67 71 const sep = std.mem.indexOfScalar(u8, rest[1], ':') orelse return usage(); 68 72 config.username = rest[1][0..sep]; 69 73 config.password = rest[1][sep + 1 ..]; 74 + rest = rest[1..]; 75 + } else if (std.mem.eql(u8, rest[0], "--implicit-tls")) { 76 + config.implicit_tls = true; 70 77 } else { 71 78 return usage(); 72 79 } 73 - rest = rest[2..]; 80 + rest = rest[1..]; 74 81 } 75 82 if (rest.len != 1) return usage(); 76 83 if ((config.cert_path == null) != (config.key_path == null)) return usage(); 84 + if (config.implicit_tls and config.cert_path == null) return usage(); 77 85 return serve(io, arena, config, rest[0]); 78 86 } 79 87 return usage(); ··· 82 90 const ServeConfig = struct { 83 91 cert_path: ?[]const u8 = null, 84 92 key_path: ?[]const u8 = null, 93 + implicit_tls: bool = false, 85 94 username: ?[]const u8 = null, 86 95 password: ?[]const u8 = null, 87 96 }; ··· 100 109 \\ zsmtp send [--tls|--starttls] [--insecure] [--user <u> --password <p>] 101 110 \\ [--auth-method plain|login|cram-md5] <host> <port> <from> <to>... 102 111 \\ (message is read from stdin) 103 - \\ zsmtp serve [--tls-cert <pem> --tls-key <pem>] [--auth <user>:<pass>] <port> 112 + \\ zsmtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] 113 + \\ [--auth <user>:<pass>] <port> 104 114 , .{}); 105 115 std.process.exit(1); 106 116 } ··· 210 220 try .fromFilePath(gpa, io, .cwd(), cert_path, config.key_path.?) 211 221 else 212 222 null; 213 - const starttls: ?zsmtp.Server.StartTls = if (auth) |*a| .{ .io = io, .auth = a } else null; 223 + const tls_options: ?zsmtp.Server.TlsOptions = if (auth) |*a| .{ 224 + .io = io, 225 + .auth = a, 226 + .mode = if (config.implicit_tls) .implicit else .starttls, 227 + } else null; 214 228 std.log.info("listening on 127.0.0.1:{d}{s}", .{ 215 229 port, 216 - if (starttls != null) " with STARTTLS" else "", 230 + if (tls_options) |t| switch (t.mode) { 231 + .starttls => " with STARTTLS", 232 + .implicit => " with implicit TLS", 233 + } else "", 217 234 }); 218 235 219 236 var stdout_buf: [4096]u8 = undefined; ··· 245 262 } }, 246 263 .{ 247 264 .hostname = "localhost", 248 - .starttls = starttls, 265 + .tls = tls_options, 249 266 .require_auth = config.username != null, 250 267 }, 251 268 );