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.

RFC review: standards list, doc links, exim-derived protocol gauntlet

README gains a Standards section listing every implemented RFC with
its per-side coverage (5321, 1870, 6152, 2920, 3207, 8314, 4954, 4616,
2195, draft-murchison-sasl-login, 3463/2034, 6531, and 8446 via
tls.zig). Doc comments now link each RFC mention to the datatracker,
with section fragments where a section is cited; authLogin's doc notes
it has no RFC.

The review surfaced two fixes: the server always emitted RFC 3463
enhanced status codes but never advertised ENHANCEDSTATUSCODES
(RFC 2034) - now it does; and root.zig's module doc still called TLS
an eventual feature.

Also adds a protocol gauntlet unit test distilled from exim's test
suite (test/scripts/0000-Basic, notably 0019's syntax-error dialogue
and the 0008/0100 dotted message lines), asserting the exact 28-reply
transcript and resulting envelope. The dialogue was first validated by
running exim's own scriptable test client (test/src/client.c, built
with zig cc) against zsmtp serve.

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

+160 -20
+37
README.md
··· 183 183 client and PLAIN and LOGIN on the server. Message bodies can be streamed on 184 184 both sides, and the server validates MAIL parameters (SIZE=, BODY=). 185 185 186 + ## Standards 187 + 188 + - [RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321) — Simple Mail 189 + Transfer Protocol: the command/reply protocol, multiline replies, 190 + dot-stuffing, reply classes, and ESMTP parameter syntax (client and 191 + server). 192 + - [RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870) — SIZE: 193 + advertised and enforced by the server (oversize declarations are rejected 194 + with 552 before DATA); parsed from EHLO by the client. 195 + - [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152) — 8BITMIME: 196 + advertised by the server and `BODY=` validated; parsed by the client. 197 + - [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING: 198 + advertised by the server, whose strictly sequential command loop handles 199 + pipelined clients naturally; parsed by the client. 200 + - [RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207) — STARTTLS: 201 + client and server, including the mandatory post-handshake state reset. 202 + - [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314) — implicit TLS 203 + (SMTPS): client (`Tls` before any SMTP traffic) and server 204 + (`.mode = .implicit`). 205 + - [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client 206 + and server, including initial responses and `*` cancellation. 207 + - [RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616) — the PLAIN 208 + SASL mechanism (client and server). 209 + - [RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195) — CRAM-MD5 210 + (client only; the server would need plaintext-equivalent credentials). 211 + - [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 212 + — the de-facto AUTH LOGIN mechanism (client and server). 213 + - [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) / 214 + [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced 215 + status codes: carried in every server reply and advertised via 216 + ENHANCEDSTATUSCODES; detected by the client. 217 + - [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) — SMTPUTF8: 218 + detected by the client in EHLO; not implemented by the server. 219 + 220 + TLS itself (TLS 1.3, [RFC 8446](https://datatracker.ietf.org/doc/html/rfc8446)) 221 + is provided by [ianic/tls.zig](https://github.com/ianic/tls.zig). 222 + 186 223 ## Tests 187 224 188 225 ```sh
+11 -5
src/Client.zig
··· 132 132 return c.expect(220); 133 133 } 134 134 135 - /// Sends EHLO and returns the extensions the server advertised, falling back 135 + /// Sends EHLO ([RFC 5321 §4.1.1.1](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.1)) 136 + /// and returns the extensions the server advertised, falling back 136 137 /// to plain HELO for servers that do not speak ESMTP. 137 138 pub fn hello(c: *Client, client_name: []const u8) Error!Extensions { 138 139 try c.send("EHLO {s}", .{client_name}); ··· 146 147 return error.UnexpectedReply; 147 148 } 148 149 149 - /// Sends STARTTLS (RFC 3207) and reads the server's 220 go-ahead. On 150 + /// Sends STARTTLS ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)) and 151 + /// reads the server's 220 go-ahead. On 150 152 /// success, perform a TLS handshake over the underlying stream (see `Tls`), 151 153 /// switch to the encrypted transport with `setTransport`, and then call 152 154 /// `hello` again — the server discards everything it learned before the ··· 183 185 return error.NoSupportedMechanism; 184 186 } 185 187 186 - /// Authenticates with AUTH PLAIN (RFC 4616). Pass an empty `authzid` unless 188 + /// Authenticates with AUTH PLAIN ([RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616)). 189 + /// Pass an empty `authzid` unless 187 190 /// you need to act on behalf of another identity. Note that sending 188 191 /// credentials over an unencrypted connection exposes them to the network. 189 192 pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) AuthError!void { ··· 198 201 } 199 202 200 203 /// Authenticates with AUTH LOGIN, the legacy two-step username/password 201 - /// exchange still required by some servers. 204 + /// exchange still required by some servers (no RFC; the de-facto 205 + /// [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 206 + /// mechanism). 202 207 pub fn authLogin(c: *Client, username: []const u8, password: []const u8) AuthError!void { 203 208 try c.send("AUTH LOGIN", .{}); 204 209 _ = try c.expect(334); // Username: prompt ··· 208 213 try c.expectAuthSuccess(); 209 214 } 210 215 211 - /// Authenticates with AUTH CRAM-MD5 (RFC 2195): the password never crosses 216 + /// Authenticates with AUTH CRAM-MD5 ([RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195)): 217 + /// the password never crosses 212 218 /// the wire, only an HMAC-MD5 of the server's challenge. 213 219 pub fn authCramMd5(c: *Client, username: []const u8, password: []const u8) AuthError!void { 214 220 try c.send("AUTH CRAM-MD5", .{});
+99 -7
src/Server.zig
··· 58 58 mode: Mode = .starttls, 59 59 60 60 pub const Mode = enum { 61 - /// Advertise and accept the STARTTLS command (RFC 3207). 61 + /// Advertise and accept the STARTTLS command 62 + /// ([RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207)). 62 63 starttls, 63 64 /// Perform the TLS handshake before the greeting (implicit TLS / 64 - /// SMTPS, port 465 style). 65 + /// SMTPS, port 465 style; [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314)). 65 66 implicit, 66 67 }; 67 68 }; ··· 74 75 pub const Rejection = struct { 75 76 /// Use 4xx for "try again later", 5xx for permanent rejection. 76 77 code: u16 = 550, 78 + /// By convention prefixed with an enhanced status code 79 + /// ([RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463)). 77 80 text: []const u8 = "5.7.1 Rejected", 78 81 }; 79 82 }; ··· 82 85 /// Empty for the null reverse-path (`MAIL FROM:<>`). 83 86 from: []const u8, 84 87 recipients: []const []const u8, 85 - /// Value of the MAIL SIZE= parameter (RFC 1870), if the client 88 + /// Value of the MAIL SIZE= parameter 89 + /// ([RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870)), if the client 86 90 /// declared one. Already validated against `Options.max_message_size`. 87 91 declared_size: ?u64 = null, 88 - /// Value of the MAIL BODY= parameter (RFC 6152). 92 + /// Value of the MAIL BODY= parameter 93 + /// ([RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152)). 89 94 body: Body = .unspecified, 90 95 91 96 pub const Body = enum { unspecified, seven_bit, eight_bit_mime }; ··· 100 105 pub const VTable = struct { 101 106 /// Called for AUTH with the decoded credentials; return true to 102 107 /// accept. When set, AUTH PLAIN and AUTH LOGIN are advertised and 103 - /// accepted (RFC 4954). 108 + /// accepted ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 104 109 authenticate: ?*const fn (context: ?*anyopaque, username: []const u8, password: []const u8) bool = null, 105 110 /// Called for MAIL FROM. Null accepts every sender. 106 111 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null, ··· 183 188 declared_size = null; 184 189 body = .unspecified; 185 190 _ = arena_state.reset(.retain_capacity); 186 - try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n", .{s.options.hostname}); 191 + // Every reply carries an enhanced status code (RFC 3463), so 192 + // the ENHANCEDSTATUSCODES extension (RFC 2034) is advertised. 193 + try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-ENHANCEDSTATUSCODES\r\n", .{s.options.hostname}); 187 194 if (s.options.tls) |config| { 188 195 if (config.mode == .starttls and !s.secured) 189 196 try s.writer.writeAll("250-STARTTLS\r\n"); ··· 725 732 726 733 try std.testing.expectEqualStrings( 727 734 "220 mx.test ESMTP ready\r\n" ++ 728 - "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 16777216\r\n" ++ 735 + "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++ 729 736 "250 2.1.0 Ok\r\n" ++ 730 737 "250 2.1.5 Ok\r\n" ++ 731 738 "250 2.1.5 Ok\r\n" ++ ··· 1127 1134 const handler: Handler = .{ .vtable = &.{ .message = Callbacks.onMessage } }; 1128 1135 const envelope: Envelope = .{ .from = "", .recipients = &.{} }; 1129 1136 try std.testing.expectEqual(Decision.accept, handler.vtable.message.?(null, envelope, "")); 1137 + } 1138 + 1139 + test "protocol gauntlet adapted from exim's test suite" { 1140 + // Command sequences and dot-stuffing cases distilled from exim's 1141 + // test/scripts/0000-Basic (notably 0019's SMTP syntax-error dialogue 1142 + // and 0008/0100's dotted message lines), verified against this server 1143 + // with exim's own scriptable test client. 1144 + var h: TestHandler = .{}; 1145 + defer h.deinit(); 1146 + 1147 + var out_buf: [4096]u8 = undefined; 1148 + const output = try runScript( 1149 + "NOOP\r\n" ++ 1150 + "rhubarb\r\n" ++ 1151 + "mail from:<x@y>\r\n" ++ 1152 + "rcpt to:<a@b>\r\n" ++ 1153 + "ehlo test.client\r\n" ++ 1154 + "mail\r\n" ++ 1155 + "mail from:\r\n" ++ 1156 + "mail from:<>\r\n" ++ 1157 + "mail from:<x@y>\r\n" ++ 1158 + "rcpt to:\r\n" ++ 1159 + "data\r\n" ++ 1160 + "rset\r\n" ++ 1161 + "etrn abc\r\n" ++ 1162 + "vrfy userx\r\n" ++ 1163 + "help\r\n" ++ 1164 + "mail from:<ok@test1> SIZE=100 BODY=8BITMIME\r\n" ++ 1165 + "rcpt to:<userx@test.ex>\r\n" ++ 1166 + "rcpt to:<@relay.example:route@test.ex>\r\n" ++ 1167 + "data\r\n" ++ 1168 + "..that line started with a dot\r\n" ++ 1169 + ".. and one starting with two dots\r\n" ++ 1170 + "Message body\r\n" ++ 1171 + ".\r\n" ++ 1172 + "mail from:<a@b> SIZE=99999999\r\n" ++ 1173 + "mail from:<a@b> BODY=BINARYMIME\r\n" ++ 1174 + "mail from:<a@b> FOO=bar\r\n" ++ 1175 + "mail from:<a@b> SIZE=nan\r\n" ++ 1176 + "starttls\r\n" ++ 1177 + "quit\r\n", 1178 + &out_buf, 1179 + h.handler(), 1180 + .{}, 1181 + ); 1182 + 1183 + try std.testing.expectEqualStrings( 1184 + "220 localhost ESMTP ready\r\n" ++ 1185 + "250 2.0.0 Ok\r\n" ++ 1186 + "500 5.5.2 Command not recognized\r\n" ++ 1187 + "503 5.5.1 Send EHLO first\r\n" ++ 1188 + "503 5.5.1 Need MAIL command first\r\n" ++ 1189 + "250-localhost\r\n250-PIPELINING\r\n250-8BITMIME\r\n" ++ 1190 + "250-ENHANCEDSTATUSCODES\r\n250 SIZE 16777216\r\n" ++ 1191 + "501 5.5.4 Syntax error in parameters\r\n" ++ 1192 + "501 5.5.4 Syntax error in parameters\r\n" ++ 1193 + "250 2.1.0 Ok\r\n" ++ 1194 + "503 5.5.1 Nested MAIL command\r\n" ++ 1195 + "501 5.5.4 Syntax error in parameters\r\n" ++ 1196 + "503 5.5.1 Need RCPT command first\r\n" ++ 1197 + "250 2.0.0 Ok\r\n" ++ 1198 + "500 5.5.2 Command not recognized\r\n" ++ 1199 + "252 2.5.2 Cannot VRFY user\r\n" ++ 1200 + "214 2.0.0 See RFC 5321\r\n" ++ 1201 + "250 2.1.0 Ok\r\n" ++ 1202 + "250 2.1.5 Ok\r\n" ++ 1203 + "250 2.1.5 Ok\r\n" ++ 1204 + "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 1205 + "250 2.0.0 Ok, message accepted\r\n" ++ 1206 + "552 5.3.4 Message size exceeds fixed maximum\r\n" ++ 1207 + "555 5.5.4 Unsupported BODY value\r\n" ++ 1208 + "555 5.5.4 Unrecognized parameter\r\n" ++ 1209 + "501 5.5.2 Invalid SIZE parameter\r\n" ++ 1210 + "502 5.5.1 STARTTLS not supported\r\n" ++ 1211 + "221 2.0.0 Bye\r\n", 1212 + output, 1213 + ); 1214 + try std.testing.expectEqualStrings("ok@test1", h.from.items); 1215 + try std.testing.expectEqualStrings("userx@test.ex;route@test.ex;", h.recipients.items); 1216 + try std.testing.expectEqualStrings( 1217 + ".that line started with a dot\r\n. and one starting with two dots\r\nMessage body\r\n", 1218 + h.data.items, 1219 + ); 1220 + try std.testing.expectEqual(@as(?u64, 100), h.declared_size); 1221 + try std.testing.expectEqual(Envelope.Body.eight_bit_mime, h.body); 1130 1222 }
+9 -5
src/protocol.zig
··· 1 1 // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 2 // SPDX-License-Identifier: MIT 3 3 4 - //! Shared SMTP protocol primitives (RFC 5321): line reading, reply parsing, 4 + //! Shared SMTP protocol primitives ([RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321)): 5 + //! line reading, reply parsing, 5 6 //! command parsing, and message data dot-stuffing. Used by both the client 6 7 //! and server layers, and usable directly for custom protocol handling. 7 8 ··· 83 84 return std.mem.splitScalar(u8, r.text, '\n'); 84 85 } 85 86 86 - // Reply classes per RFC 5321 §4.2.1. 87 + // Reply classes per RFC 5321 §4.2.1 88 + // (https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.1). 87 89 pub fn isPositiveCompletion(r: Reply) bool { 88 90 return r.code >= 200 and r.code < 300; 89 91 } ··· 146 148 vrfy: []const u8, 147 149 help, 148 150 starttls, 149 - /// AUTH (RFC 4954). 151 + /// AUTH ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 150 152 auth: AuthArgs, 151 153 /// Unrecognized command verb; the payload is the full line. 152 154 unknown: []const u8, ··· 252 254 }; 253 255 254 256 /// Iterates the ESMTP parameters of a MAIL or RCPT command 255 - /// (RFC 5321 §4.1.2), e.g. "SIZE=1024 BODY=8BITMIME". 257 + /// ([RFC 5321 §4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2)), 258 + /// e.g. "SIZE=1024 BODY=8BITMIME". 256 259 pub const ParamIterator = struct { 257 260 rest: []const u8, 258 261 ··· 296 299 }; 297 300 298 301 /// Writes `data` as SMTP message content: line endings are normalized to CRLF 299 - /// and lines beginning with '.' are dot-stuffed (RFC 5321 §4.5.2). Does not 302 + /// and lines beginning with '.' are dot-stuffed 303 + /// ([RFC 5321 §4.5.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.2)). Does not 300 304 /// write the terminating ".\r\n". 301 305 pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void { 302 306 var rest = data;
+4 -3
src/root.zig
··· 1 1 // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 2 // SPDX-License-Identifier: MIT 3 3 4 - //! zsmtp — an SMTP client and server library for Zig (RFC 5321). 4 + //! zsmtp — an SMTP client and server library for Zig 5 + //! ([RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321)). 5 6 //! 6 7 //! Both `Client` and `Server` run over plain `std.Io.Reader`/`std.Io.Writer` 7 8 //! pairs, so they work with any transport: TCP streams, in-memory buffers in 8 - //! tests, or (eventually) a TLS layer. The lower-level protocol pieces — 9 - //! reply parsing, command parsing, dot-stuffing — are exposed via `protocol`. 9 + //! tests, or the `Tls` layer. The lower-level protocol pieces — reply 10 + //! parsing, command parsing, dot-stuffing — are exposed via `protocol`. 10 11 11 12 const std = @import("std"); 12 13