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//! Shared SMTP protocol primitives (RFC 5321): line reading, reply parsing,
5//! command parsing, and message data dot-stuffing. Used by both the client
6//! and server layers, and usable directly for custom protocol handling.
7
8const std = @import("std");
9const Io = std.Io;
10
11pub const crlf = "\r\n";
12
13pub const ReadLineError = error{
14 ReadFailed,
15 EndOfStream,
16 /// The line did not fit in the reader's buffer.
17 LineTooLong,
18};
19
20/// Reads one CRLF- (or bare LF-) terminated line, returning it without the
21/// line ending. The returned slice points into the reader's buffer and is
22/// invalidated by the next read.
23pub fn readLine(reader: *Io.Reader) ReadLineError![]u8 {
24 const line = reader.takeSentinel('\n') catch |err| switch (err) {
25 error.StreamTooLong => return error.LineTooLong,
26 error.ReadFailed, error.EndOfStream => |e| return e,
27 };
28 if (line.len > 0 and line[line.len - 1] == '\r') return line[0 .. line.len - 1];
29 return line;
30}
31
32/// A server reply: a 3-digit code and one or more lines of text.
33pub const Reply = struct {
34 code: u16,
35 /// Text of all reply lines joined with '\n', with codes and separators
36 /// stripped. Points into the buffer passed to `read`.
37 text: []const u8,
38
39 pub const ReadError = ReadLineError || error{
40 InvalidReply,
41 /// The reply text did not fit in the provided buffer.
42 ReplyTooLong,
43 };
44
45 /// Reads one (possibly multiline) reply. The text is copied into `buffer`
46 /// and the returned reply's `text` field points into it.
47 pub fn read(reader: *Io.Reader, buffer: []u8) ReadError!Reply {
48 var text: Io.Writer = .fixed(buffer);
49 var code: ?u16 = null;
50 var first = true;
51 while (true) {
52 const line = try readLine(reader);
53 if (line.len < 3) return error.InvalidReply;
54 const line_code = std.fmt.parseInt(u16, line[0..3], 10) catch
55 return error.InvalidReply;
56 if (line_code < 100 or line_code > 599) return error.InvalidReply;
57 if (code) |prev| {
58 // All lines of a multiline reply must carry the same code.
59 if (prev != line_code) return error.InvalidReply;
60 } else {
61 code = line_code;
62 }
63 var last = true;
64 var line_text: []const u8 = "";
65 if (line.len > 3) {
66 switch (line[3]) {
67 ' ' => {},
68 '-' => last = false,
69 else => return error.InvalidReply,
70 }
71 line_text = line[4..];
72 }
73 if (!first) text.writeByte('\n') catch return error.ReplyTooLong;
74 text.writeAll(line_text) catch return error.ReplyTooLong;
75 first = false;
76 if (last) break;
77 }
78 return .{ .code = code.?, .text = text.buffered() };
79 }
80
81 /// Iterates over the individual text lines of the reply.
82 pub fn lines(r: *const Reply) std.mem.SplitIterator(u8, .scalar) {
83 return std.mem.splitScalar(u8, r.text, '\n');
84 }
85
86 // Reply classes per RFC 5321 §4.2.1.
87 pub fn isPositiveCompletion(r: Reply) bool {
88 return r.code >= 200 and r.code < 300;
89 }
90 pub fn isPositiveIntermediate(r: Reply) bool {
91 return r.code >= 300 and r.code < 400;
92 }
93 pub fn isTransientFailure(r: Reply) bool {
94 return r.code >= 400 and r.code < 500;
95 }
96 pub fn isPermanentFailure(r: Reply) bool {
97 return r.code >= 500 and r.code < 600;
98 }
99};
100
101/// A parsed client command, as seen by a server.
102pub const Command = union(enum) {
103 helo: []const u8,
104 ehlo: []const u8,
105 /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`).
106 mail: PathArgs,
107 /// RCPT TO.
108 rcpt: PathArgs,
109 data,
110 rset,
111 noop,
112 quit,
113 vrfy: []const u8,
114 help,
115 starttls,
116 /// Unrecognized command verb; the payload is the full line.
117 unknown: []const u8,
118
119 pub const PathArgs = struct {
120 /// The mailbox, with angle brackets and any obsolete source route
121 /// stripped.
122 path: []const u8,
123 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024".
124 params: []const u8 = "",
125 };
126
127 pub const ParseError = error{Syntax};
128
129 /// Parses one command line (without its line ending). Returned slices
130 /// point into `line`.
131 pub fn parse(line: []const u8) ParseError!Command {
132 const trimmed = std.mem.trim(u8, line, " \t");
133 const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len;
134 const verb = trimmed[0..verb_end];
135 const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t");
136
137 if (ieql(verb, "HELO")) {
138 if (rest.len == 0) return error.Syntax;
139 return .{ .helo = rest };
140 }
141 if (ieql(verb, "EHLO")) {
142 if (rest.len == 0) return error.Syntax;
143 return .{ .ehlo = rest };
144 }
145 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") };
146 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") };
147 if (ieql(verb, "DATA")) return .data;
148 if (ieql(verb, "RSET")) return .rset;
149 if (ieql(verb, "NOOP")) return .noop;
150 if (ieql(verb, "QUIT")) return .quit;
151 if (ieql(verb, "VRFY")) return .{ .vrfy = rest };
152 if (ieql(verb, "HELP")) return .help;
153 if (ieql(verb, "STARTTLS")) return .starttls;
154 return .{ .unknown = line };
155 }
156
157 fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs {
158 if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword))
159 return error.Syntax;
160 const after = std.mem.trimStart(u8, rest[keyword.len..], " \t");
161 if (after.len == 0 or after[0] != '<') {
162 // Lenient: accept a bare address ending at whitespace.
163 const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len;
164 if (end == 0) return error.Syntax;
165 return .{
166 .path = after[0..end],
167 .params = std.mem.trimStart(u8, after[end..], " \t"),
168 };
169 }
170 const close = std.mem.indexOfScalar(u8, after, '>') orelse return error.Syntax;
171 var path = after[1..close];
172 // Strip an obsolete source route: <@relay1,@relay2:user@host>.
173 if (path.len > 0 and path[0] == '@') {
174 const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax;
175 path = path[colon + 1 ..];
176 }
177 return .{
178 .path = path,
179 .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"),
180 };
181 }
182
183 fn ieql(a: []const u8, b: []const u8) bool {
184 return std.ascii.eqlIgnoreCase(a, b);
185 }
186};
187
188/// Writes `data` as SMTP message content: line endings are normalized to CRLF
189/// and lines beginning with '.' are dot-stuffed (RFC 5321 §4.5.2). Does not
190/// write the terminating ".\r\n".
191pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void {
192 var rest = data;
193 while (rest.len > 0) {
194 var line: []const u8 = undefined;
195 if (std.mem.indexOfScalar(u8, rest, '\n')) |i| {
196 line = rest[0..i];
197 rest = rest[i + 1 ..];
198 } else {
199 line = rest;
200 rest = rest[rest.len..];
201 }
202 if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
203 if (line.len > 0 and line[0] == '.') try writer.writeByte('.');
204 try writer.writeAll(line);
205 try writer.writeAll(crlf);
206 }
207}
208
209test "readLine strips CRLF and LF" {
210 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n");
211 try std.testing.expectEqualStrings("first", try readLine(&reader));
212 try std.testing.expectEqualStrings("second", try readLine(&reader));
213 try std.testing.expectEqualStrings("third", try readLine(&reader));
214 try std.testing.expectError(error.EndOfStream, readLine(&reader));
215}
216
217test "Reply.read single line" {
218 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
219 var buf: [128]u8 = undefined;
220 const reply = try Reply.read(&reader, &buf);
221 try std.testing.expectEqual(@as(u16, 250), reply.code);
222 try std.testing.expectEqualStrings("2.0.0 Ok", reply.text);
223 try std.testing.expect(reply.isPositiveCompletion());
224}
225
226test "Reply.read multiline" {
227 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n");
228 var buf: [128]u8 = undefined;
229 const reply = try Reply.read(&reader, &buf);
230 try std.testing.expectEqual(@as(u16, 250), reply.code);
231 try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text);
232 var it = reply.lines();
233 try std.testing.expectEqualStrings("mx.example.com", it.next().?);
234 try std.testing.expectEqualStrings("PIPELINING", it.next().?);
235 try std.testing.expectEqualStrings("SIZE 1000", it.next().?);
236 try std.testing.expectEqual(@as(?[]const u8, null), it.next());
237}
238
239test "Reply.read rejects malformed replies" {
240 var buf: [128]u8 = undefined;
241 {
242 var reader: Io.Reader = .fixed("2x0 hello\r\n");
243 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
244 }
245 {
246 var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n");
247 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
248 }
249 {
250 var reader: Io.Reader = .fixed("42\r\n");
251 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
252 }
253}
254
255test "Command.parse" {
256 {
257 const cmd = try Command.parse("EHLO client.example.com");
258 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo);
259 }
260 {
261 const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024");
262 try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path);
263 try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params);
264 }
265 {
266 // Null reverse-path and a space after the colon.
267 const cmd = try Command.parse("MAIL FROM: <>");
268 try std.testing.expectEqualStrings("", cmd.mail.path);
269 }
270 {
271 // Obsolete source route is stripped.
272 const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>");
273 try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path);
274 }
275 {
276 const cmd = try Command.parse("QUIT");
277 try std.testing.expectEqual(Command.quit, cmd);
278 }
279 {
280 const cmd = try Command.parse("MADE UP");
281 try std.testing.expectEqualStrings("MADE UP", cmd.unknown);
282 }
283 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>"));
284 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:"));
285 try std.testing.expectError(error.Syntax, Command.parse("HELO"));
286}
287
288test "writeStuffed" {
289 var buf: [256]u8 = undefined;
290 {
291 var w: Io.Writer = .fixed(&buf);
292 try writeStuffed(&w, "line one\r\n.starts with dot\r\n");
293 try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered());
294 }
295 {
296 // LF-only input is normalized, missing final newline is added.
297 var w: Io.Writer = .fixed(&buf);
298 try writeStuffed(&w, "a\nb");
299 try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered());
300 }
301 {
302 // A lone "." line must not become a terminator.
303 var w: Io.Writer = .fixed(&buf);
304 try writeStuffed(&w, ".\n");
305 try std.testing.expectEqualStrings("..\r\n", w.buffered());
306 }
307 {
308 var w: Io.Writer = .fixed(&buf);
309 try writeStuffed(&w, "");
310 try std.testing.expectEqualStrings("", w.buffered());
311 }
312}