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 test read {
101 var reader: Io.Reader = .fixed("250-first\r\n250 second\r\n");
102 var buffer: [64]u8 = undefined;
103 const reply = try read(&reader, &buffer);
104 try std.testing.expectEqual(@as(u16, 250), reply.code);
105 try std.testing.expectEqualStrings("first\nsecond", reply.text);
106 }
107
108 test lines {
109 const reply: Reply = .{ .code = 250, .text = "one\ntwo" };
110 var it = reply.lines();
111 try std.testing.expectEqualStrings("one", it.next().?);
112 try std.testing.expectEqualStrings("two", it.next().?);
113 try std.testing.expectEqual(@as(?[]const u8, null), it.next());
114 }
115
116 test isPositiveCompletion {
117 try std.testing.expect((Reply{ .code = 250, .text = "" }).isPositiveCompletion());
118 try std.testing.expect(!(Reply{ .code = 354, .text = "" }).isPositiveCompletion());
119 }
120
121 test isPositiveIntermediate {
122 try std.testing.expect((Reply{ .code = 354, .text = "" }).isPositiveIntermediate());
123 }
124
125 test isTransientFailure {
126 try std.testing.expect((Reply{ .code = 451, .text = "" }).isTransientFailure());
127 }
128
129 test isPermanentFailure {
130 try std.testing.expect((Reply{ .code = 550, .text = "" }).isPermanentFailure());
131 }
132};
133
134/// A parsed client command, as seen by a server.
135pub const Command = union(enum) {
136 helo: []const u8,
137 ehlo: []const u8,
138 /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`).
139 mail: PathArgs,
140 /// RCPT TO.
141 rcpt: PathArgs,
142 data,
143 rset,
144 noop,
145 quit,
146 vrfy: []const u8,
147 help,
148 starttls,
149 /// AUTH (RFC 4954).
150 auth: AuthArgs,
151 /// Unrecognized command verb; the payload is the full line.
152 unknown: []const u8,
153
154 pub const AuthArgs = struct {
155 mechanism: []const u8,
156 /// Raw base64 initial response, if the client sent one ("=" denotes
157 /// an empty initial response).
158 initial: []const u8 = "",
159 };
160
161 pub const PathArgs = struct {
162 /// The mailbox, with angle brackets and any obsolete source route
163 /// stripped.
164 path: []const u8,
165 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024".
166 params: []const u8 = "",
167
168 pub fn paramIterator(args: PathArgs) ParamIterator {
169 return .init(args.params);
170 }
171
172 test paramIterator {
173 const args: PathArgs = .{ .path = "a@example.com", .params = "SIZE=7" };
174 var it = args.paramIterator();
175 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword);
176 }
177 };
178
179 pub const ParseError = error{Syntax};
180
181 /// Parses one command line (without its line ending). Returned slices
182 /// point into `line`.
183 pub fn parse(line: []const u8) ParseError!Command {
184 const trimmed = std.mem.trim(u8, line, " \t");
185 const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len;
186 const verb = trimmed[0..verb_end];
187 const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t");
188
189 if (ieql(verb, "HELO")) {
190 if (rest.len == 0) return error.Syntax;
191 return .{ .helo = rest };
192 }
193 if (ieql(verb, "EHLO")) {
194 if (rest.len == 0) return error.Syntax;
195 return .{ .ehlo = rest };
196 }
197 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") };
198 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") };
199 if (ieql(verb, "DATA")) return .data;
200 if (ieql(verb, "RSET")) return .rset;
201 if (ieql(verb, "NOOP")) return .noop;
202 if (ieql(verb, "QUIT")) return .quit;
203 if (ieql(verb, "VRFY")) return .{ .vrfy = rest };
204 if (ieql(verb, "HELP")) return .help;
205 if (ieql(verb, "STARTTLS")) return .starttls;
206 if (ieql(verb, "AUTH")) {
207 const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len;
208 if (mech_end == 0) return error.Syntax;
209 return .{ .auth = .{
210 .mechanism = rest[0..mech_end],
211 .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"),
212 } };
213 }
214 return .{ .unknown = line };
215 }
216
217 fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs {
218 if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword))
219 return error.Syntax;
220 const after = std.mem.trimStart(u8, rest[keyword.len..], " \t");
221 if (after.len == 0 or after[0] != '<') {
222 // Lenient: accept a bare address ending at whitespace.
223 const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len;
224 if (end == 0) return error.Syntax;
225 return .{
226 .path = after[0..end],
227 .params = std.mem.trimStart(u8, after[end..], " \t"),
228 };
229 }
230 const close = std.mem.indexOfScalar(u8, after, '>') orelse return error.Syntax;
231 var path = after[1..close];
232 // Strip an obsolete source route: <@relay1,@relay2:user@host>.
233 if (path.len > 0 and path[0] == '@') {
234 const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax;
235 path = path[colon + 1 ..];
236 }
237 return .{
238 .path = path,
239 .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"),
240 };
241 }
242
243 fn ieql(a: []const u8, b: []const u8) bool {
244 return std.ascii.eqlIgnoreCase(a, b);
245 }
246
247 test parse {
248 const command = try parse("RCPT TO:<bob@example.net>");
249 try std.testing.expectEqualStrings("bob@example.net", command.rcpt.path);
250 try std.testing.expectError(error.Syntax, parse("MAIL <missing-keyword>"));
251 }
252};
253
254/// Iterates the ESMTP parameters of a MAIL or RCPT command
255/// (RFC 5321 §4.1.2), e.g. "SIZE=1024 BODY=8BITMIME".
256pub const ParamIterator = struct {
257 rest: []const u8,
258
259 pub const Param = struct {
260 keyword: []const u8,
261 /// Empty when the parameter carries no value.
262 value: []const u8 = "",
263 };
264
265 pub fn init(params: []const u8) ParamIterator {
266 return .{ .rest = params };
267 }
268
269 pub fn next(it: *ParamIterator) ?Param {
270 it.rest = std.mem.trimStart(u8, it.rest, " \t");
271 if (it.rest.len == 0) return null;
272 const end = std.mem.indexOfAny(u8, it.rest, " \t") orelse it.rest.len;
273 const token = it.rest[0..end];
274 it.rest = it.rest[end..];
275 if (std.mem.indexOfScalar(u8, token, '=')) |eq| {
276 return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] };
277 }
278 return .{ .keyword = token };
279 }
280
281 test init {
282 var it: ParamIterator = .init("SIZE=42");
283 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword);
284 }
285
286 test next {
287 var it: ParamIterator = .init("BODY=8BITMIME CUSTOM");
288 const body = it.next().?;
289 try std.testing.expectEqualStrings("BODY", body.keyword);
290 try std.testing.expectEqualStrings("8BITMIME", body.value);
291 const custom = it.next().?;
292 try std.testing.expectEqualStrings("CUSTOM", custom.keyword);
293 try std.testing.expectEqualStrings("", custom.value);
294 try std.testing.expectEqual(@as(?Param, null), it.next());
295 }
296};
297
298/// 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
300/// write the terminating ".\r\n".
301pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void {
302 var rest = data;
303 while (rest.len > 0) {
304 var line: []const u8 = undefined;
305 if (std.mem.indexOfScalar(u8, rest, '\n')) |i| {
306 line = rest[0..i];
307 rest = rest[i + 1 ..];
308 } else {
309 line = rest;
310 rest = rest[rest.len..];
311 }
312 if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
313 if (line.len > 0 and line[0] == '.') try writer.writeByte('.');
314 try writer.writeAll(line);
315 try writer.writeAll(crlf);
316 }
317}
318
319test readLine {
320 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n");
321 try std.testing.expectEqualStrings("first", try readLine(&reader));
322 try std.testing.expectEqualStrings("second", try readLine(&reader));
323 try std.testing.expectEqualStrings("third", try readLine(&reader));
324 try std.testing.expectError(error.EndOfStream, readLine(&reader));
325}
326
327test Reply {
328 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
329 var buf: [128]u8 = undefined;
330 const reply = try Reply.read(&reader, &buf);
331 try std.testing.expectEqual(@as(u16, 250), reply.code);
332 try std.testing.expectEqualStrings("2.0.0 Ok", reply.text);
333 try std.testing.expect(reply.isPositiveCompletion());
334}
335
336test "Reply.read multiline" {
337 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n");
338 var buf: [128]u8 = undefined;
339 const reply = try Reply.read(&reader, &buf);
340 try std.testing.expectEqual(@as(u16, 250), reply.code);
341 try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text);
342 var it = reply.lines();
343 try std.testing.expectEqualStrings("mx.example.com", it.next().?);
344 try std.testing.expectEqualStrings("PIPELINING", it.next().?);
345 try std.testing.expectEqualStrings("SIZE 1000", it.next().?);
346 try std.testing.expectEqual(@as(?[]const u8, null), it.next());
347}
348
349test "Reply.read rejects malformed replies" {
350 var buf: [128]u8 = undefined;
351 {
352 var reader: Io.Reader = .fixed("2x0 hello\r\n");
353 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
354 }
355 {
356 var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n");
357 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
358 }
359 {
360 var reader: Io.Reader = .fixed("42\r\n");
361 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
362 }
363}
364
365test Command {
366 {
367 const cmd = try Command.parse("EHLO client.example.com");
368 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo);
369 }
370 {
371 const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024");
372 try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path);
373 try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params);
374 }
375 {
376 // Null reverse-path and a space after the colon.
377 const cmd = try Command.parse("MAIL FROM: <>");
378 try std.testing.expectEqualStrings("", cmd.mail.path);
379 }
380 {
381 // Obsolete source route is stripped.
382 const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>");
383 try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path);
384 }
385 {
386 const cmd = try Command.parse("QUIT");
387 try std.testing.expectEqual(Command.quit, cmd);
388 }
389 {
390 const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw==");
391 try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism);
392 try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial);
393 }
394 {
395 const cmd = try Command.parse("auth login");
396 try std.testing.expectEqualStrings("login", cmd.auth.mechanism);
397 try std.testing.expectEqualStrings("", cmd.auth.initial);
398 }
399 {
400 const cmd = try Command.parse("MADE UP");
401 try std.testing.expectEqualStrings("MADE UP", cmd.unknown);
402 }
403 try std.testing.expectError(error.Syntax, Command.parse("AUTH"));
404 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>"));
405 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:"));
406 try std.testing.expectError(error.Syntax, Command.parse("HELO"));
407}
408
409test writeStuffed {
410 var buf: [256]u8 = undefined;
411 {
412 var w: Io.Writer = .fixed(&buf);
413 try writeStuffed(&w, "line one\r\n.starts with dot\r\n");
414 try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered());
415 }
416 {
417 // LF-only input is normalized, missing final newline is added.
418 var w: Io.Writer = .fixed(&buf);
419 try writeStuffed(&w, "a\nb");
420 try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered());
421 }
422 {
423 // A lone "." line must not become a terminator.
424 var w: Io.Writer = .fixed(&buf);
425 try writeStuffed(&w, ".\n");
426 try std.testing.expectEqualStrings("..\r\n", w.buffered());
427 }
428 {
429 var w: Io.Writer = .fixed(&buf);
430 try writeStuffed(&w, "");
431 try std.testing.expectEqualStrings("", w.buffered());
432 }
433}
434
435test "fuzz Command.parse" {
436 try std.testing.fuzz({}, fuzzCommandParse, .{});
437}
438
439fn fuzzCommandParse(context: void, smith: *std.testing.Smith) !void {
440 _ = context;
441 var line_buf: [512]u8 = undefined;
442 const line = line_buf[0..smith.value(u9)];
443 smith.bytes(line);
444
445 const command = Command.parse(line) catch return;
446 // Payload slices must always lie within the parsed line.
447 switch (command) {
448 .helo, .ehlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len),
449 .mail, .rcpt => |args| {
450 try std.testing.expect(args.path.len <= line.len);
451 try std.testing.expect(args.params.len <= line.len);
452 },
453 .auth => |args| {
454 try std.testing.expect(args.mechanism.len <= line.len);
455 try std.testing.expect(args.initial.len <= line.len);
456 },
457 .data, .rset, .noop, .quit, .help, .starttls => {},
458 }
459}
460
461test "fuzz Reply.read" {
462 try std.testing.fuzz({}, fuzzReplyRead, .{});
463}
464
465fn fuzzReplyRead(context: void, smith: *std.testing.Smith) !void {
466 _ = context;
467 var input_buf: [1024]u8 = undefined;
468 const input = input_buf[0..smith.value(u10)];
469 smith.bytes(input);
470
471 var reader: Io.Reader = .fixed(input);
472 var text_buf: [128]u8 = undefined;
473 // Each successful read consumes at least one line, so this terminates.
474 while (true) {
475 const reply = Reply.read(&reader, &text_buf) catch break;
476 try std.testing.expect(reply.code >= 100 and reply.code <= 599);
477 }
478}
479
480test ParamIterator {
481 const command = try Command.parse("MAIL FROM:<a@example.com> SIZE=1024 BODY=8BITMIME FLAG");
482 var it = command.mail.paramIterator();
483
484 const size = it.next().?;
485 try std.testing.expectEqualStrings("SIZE", size.keyword);
486 try std.testing.expectEqualStrings("1024", size.value);
487
488 const body = it.next().?;
489 try std.testing.expectEqualStrings("BODY", body.keyword);
490 try std.testing.expectEqualStrings("8BITMIME", body.value);
491
492 const flag = it.next().?;
493 try std.testing.expectEqualStrings("FLAG", flag.keyword);
494 try std.testing.expectEqualStrings("", flag.value);
495
496 try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next());
497}
498
499test crlf {
500 try std.testing.expectEqualStrings("\r\n", crlf);
501}