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](https://datatracker.ietf.org/doc/html/rfc5321)):
5//! line reading, reply parsing,
6//! command parsing, and message data dot-stuffing. Used by both the client
7//! and server layers, and usable directly for custom protocol handling.
8
9const std = @import("std");
10const Io = std.Io;
11
12pub const crlf = "\r\n";
13
14/// Bytes that may never appear in a command argument.
15///
16/// CR and LF end the command line, so a value carrying either one lets
17/// whatever follows it be read by the server as further SMTP commands — an
18/// address of `a@b>\r\nRCPT TO:<victim@c` turns one recipient into two.
19/// NUL is here because it separates the three fields of an SASL PLAIN
20/// response, where a value carrying one silently shifts the boundary
21/// between authorization identity, username and password.
22pub const forbidden_in_argument = "\r\n\x00";
23
24/// Whether `text` is safe to write into a command line as an argument.
25///
26/// This is a framing check, not address validation: it says that `text`
27/// cannot end the line early, not that it is a well-formed mailbox. The
28/// full RFC 5321 path grammar is deliberately not enforced, because plenty
29/// of addresses in real use do not satisfy it and a client that refused to
30/// carry them would be the wrong tool. Callers building commands from
31/// untrusted input should check here and reject what fails.
32pub fn isSafeArgument(text: []const u8) bool {
33 return std.mem.findAny(u8, text, forbidden_in_argument) == null;
34}
35
36test isSafeArgument {
37 try std.testing.expect(isSafeArgument("alice@example.com"));
38 try std.testing.expect(isSafeArgument("\"odd name\"@example.com"));
39 try std.testing.expect(!isSafeArgument("a@b>\r\nRCPT TO:<victim@c"));
40 try std.testing.expect(!isSafeArgument("a@b\nMAIL FROM:<c@d>"));
41 try std.testing.expect(!isSafeArgument("alice\x00root"));
42}
43
44pub const ReadLineError = error{
45 ReadFailed,
46 EndOfStream,
47 /// The line did not fit in the reader's buffer.
48 LineTooLong,
49};
50
51/// Reads one CRLF- (or bare LF-) terminated line, returning it without the
52/// line ending. The returned slice points into the reader's buffer and is
53/// invalidated by the next read.
54pub fn readLine(reader: *Io.Reader) ReadLineError![]u8 {
55 const line = reader.takeSentinel('\n') catch |err| switch (err) {
56 error.StreamTooLong => return error.LineTooLong,
57 error.ReadFailed, error.EndOfStream => |e| return e,
58 };
59 if (line.len > 0 and line[line.len - 1] == '\r') return line[0 .. line.len - 1];
60 return line;
61}
62
63/// A server reply: a 3-digit code and one or more lines of text.
64pub const Reply = struct {
65 code: u16,
66 /// Text of all reply lines joined with '\n', with codes and separators
67 /// stripped. Points into the buffer passed to `read`.
68 text: []const u8,
69
70 pub const ReadError = ReadLineError || error{
71 InvalidReply,
72 /// The reply text did not fit in the provided buffer.
73 ReplyTooLong,
74 };
75
76 /// Reads one (possibly multiline) reply. The text is copied into `buffer`
77 /// and the returned reply's `text` field points into it.
78 pub fn read(reader: *Io.Reader, buffer: []u8) ReadError!Reply {
79 var text: Io.Writer = .fixed(buffer);
80 var code: ?u16 = null;
81 var first = true;
82 while (true) {
83 const line = try readLine(reader);
84 if (line.len < 3) return error.InvalidReply;
85 const line_code = std.fmt.parseInt(u16, line[0..3], 10) catch
86 return error.InvalidReply;
87 if (line_code < 100 or line_code > 599) return error.InvalidReply;
88 if (code) |prev| {
89 // All lines of a multiline reply must carry the same code.
90 if (prev != line_code) return error.InvalidReply;
91 } else {
92 code = line_code;
93 }
94 var last = true;
95 var line_text: []const u8 = "";
96 if (line.len > 3) {
97 switch (line[3]) {
98 ' ' => {},
99 '-' => last = false,
100 else => return error.InvalidReply,
101 }
102 line_text = line[4..];
103 }
104 if (!first) text.writeByte('\n') catch return error.ReplyTooLong;
105 text.writeAll(line_text) catch return error.ReplyTooLong;
106 first = false;
107 if (last) break;
108 }
109 return .{ .code = code.?, .text = text.buffered() };
110 }
111
112 /// Iterates over the individual text lines of the reply.
113 pub fn lines(r: *const Reply) std.mem.SplitIterator(u8, .scalar) {
114 return std.mem.splitScalar(u8, r.text, '\n');
115 }
116
117 // Reply classes per RFC 5321 §4.2.1
118 // (https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.1).
119 pub fn isPositiveCompletion(r: Reply) bool {
120 return r.code >= 200 and r.code < 300;
121 }
122 pub fn isPositiveIntermediate(r: Reply) bool {
123 return r.code >= 300 and r.code < 400;
124 }
125 pub fn isTransientFailure(r: Reply) bool {
126 return r.code >= 400 and r.code < 500;
127 }
128 pub fn isPermanentFailure(r: Reply) bool {
129 return r.code >= 500 and r.code < 600;
130 }
131
132 test read {
133 var reader: Io.Reader = .fixed("250-first\r\n250 second\r\n");
134 var buffer: [64]u8 = undefined;
135 const reply = try read(&reader, &buffer);
136 try std.testing.expectEqual(@as(u16, 250), reply.code);
137 try std.testing.expectEqualStrings("first\nsecond", reply.text);
138 }
139
140 test lines {
141 const reply: Reply = .{ .code = 250, .text = "one\ntwo" };
142 var it = reply.lines();
143 try std.testing.expectEqualStrings("one", it.next().?);
144 try std.testing.expectEqualStrings("two", it.next().?);
145 try std.testing.expectEqual(@as(?[]const u8, null), it.next());
146 }
147
148 test isPositiveCompletion {
149 try std.testing.expect((Reply{ .code = 250, .text = "" }).isPositiveCompletion());
150 try std.testing.expect(!(Reply{ .code = 354, .text = "" }).isPositiveCompletion());
151 }
152
153 test isPositiveIntermediate {
154 try std.testing.expect((Reply{ .code = 354, .text = "" }).isPositiveIntermediate());
155 }
156
157 test isTransientFailure {
158 try std.testing.expect((Reply{ .code = 451, .text = "" }).isTransientFailure());
159 }
160
161 test isPermanentFailure {
162 try std.testing.expect((Reply{ .code = 550, .text = "" }).isPermanentFailure());
163 }
164};
165
166/// A parsed client command, as seen by a server.
167pub const Command = union(enum) {
168 helo: []const u8,
169 ehlo: []const u8,
170 /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`).
171 mail: PathArgs,
172 /// RCPT TO.
173 rcpt: PathArgs,
174 data,
175 rset,
176 noop,
177 quit,
178 vrfy: []const u8,
179 help,
180 starttls,
181 /// AUTH ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)).
182 auth: AuthArgs,
183 /// BDAT, the CHUNKING extension
184 /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). The
185 /// command line is followed by exactly `size` raw octets.
186 bdat: BdatArgs,
187 /// Unrecognized command verb; the payload is the full line.
188 unknown: []const u8,
189
190 pub const BdatArgs = struct {
191 size: u64,
192 /// True for the final chunk of the message ("BDAT n LAST").
193 last: bool = false,
194 };
195
196 pub const AuthArgs = struct {
197 mechanism: []const u8,
198 /// Raw base64 initial response, if the client sent one ("=" denotes
199 /// an empty initial response).
200 initial: []const u8 = "",
201 };
202
203 pub const PathArgs = struct {
204 /// The mailbox, with angle brackets and any obsolete source route
205 /// stripped.
206 path: []const u8,
207 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024".
208 params: []const u8 = "",
209
210 pub fn paramIterator(args: PathArgs) ParamIterator {
211 return .init(args.params);
212 }
213
214 test paramIterator {
215 const args: PathArgs = .{ .path = "a@example.com", .params = "SIZE=7" };
216 var it = args.paramIterator();
217 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword);
218 }
219 };
220
221 pub const ParseError = error{Syntax};
222
223 /// Parses one command line (without its line ending). Returned slices
224 /// point into `line`.
225 pub fn parse(line: []const u8) ParseError!Command {
226 const trimmed = std.mem.trim(u8, line, " \t");
227 const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len;
228 const verb = trimmed[0..verb_end];
229 const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t");
230
231 if (ieql(verb, "HELO")) {
232 if (rest.len == 0) return error.Syntax;
233 return .{ .helo = rest };
234 }
235 if (ieql(verb, "EHLO")) {
236 if (rest.len == 0) return error.Syntax;
237 return .{ .ehlo = rest };
238 }
239 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") };
240 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") };
241 if (ieql(verb, "DATA")) return .data;
242 if (ieql(verb, "RSET")) return .rset;
243 if (ieql(verb, "NOOP")) return .noop;
244 if (ieql(verb, "QUIT")) return .quit;
245 if (ieql(verb, "VRFY")) return .{ .vrfy = rest };
246 if (ieql(verb, "HELP")) return .help;
247 if (ieql(verb, "STARTTLS")) return .starttls;
248 if (ieql(verb, "BDAT")) {
249 var it = std.mem.tokenizeAny(u8, rest, " \t");
250 const size_token = it.next() orelse return error.Syntax;
251 const size = std.fmt.parseInt(u64, size_token, 10) catch return error.Syntax;
252 var last = false;
253 if (it.next()) |token| {
254 if (!ieql(token, "LAST")) return error.Syntax;
255 last = true;
256 }
257 if (it.next() != null) return error.Syntax;
258 return .{ .bdat = .{ .size = size, .last = last } };
259 }
260 if (ieql(verb, "AUTH")) {
261 const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len;
262 if (mech_end == 0) return error.Syntax;
263 return .{ .auth = .{
264 .mechanism = rest[0..mech_end],
265 .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"),
266 } };
267 }
268 return .{ .unknown = line };
269 }
270
271 fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs {
272 if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword))
273 return error.Syntax;
274 const after = std.mem.trimStart(u8, rest[keyword.len..], " \t");
275 if (after.len == 0 or after[0] != '<') {
276 // Lenient: accept a bare address ending at whitespace.
277 const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len;
278 if (end == 0) return error.Syntax;
279 return .{
280 .path = after[0..end],
281 .params = std.mem.trimStart(u8, after[end..], " \t"),
282 };
283 }
284 // The closing bracket must be found outside any quoted local-part:
285 // <"a>b"@example.com> is legal (RFC 5321 quoted-string, with
286 // backslash escapes).
287 const close = close: {
288 var in_quotes = false;
289 var i: usize = 1;
290 while (i < after.len) : (i += 1) {
291 const byte = after[i];
292 if (in_quotes) {
293 if (byte == '\\') {
294 i += 1;
295 } else if (byte == '"') {
296 in_quotes = false;
297 }
298 } else if (byte == '"') {
299 in_quotes = true;
300 } else if (byte == '>') {
301 break :close i;
302 }
303 }
304 return error.Syntax;
305 };
306 var path = after[1..close];
307 // Strip an obsolete source route: <@relay1,@relay2:user@host>.
308 if (path.len > 0 and path[0] == '@') {
309 const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax;
310 path = path[colon + 1 ..];
311 }
312 return .{
313 .path = path,
314 .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"),
315 };
316 }
317
318 fn ieql(a: []const u8, b: []const u8) bool {
319 return std.ascii.eqlIgnoreCase(a, b);
320 }
321
322 test parse {
323 const command = try parse("RCPT TO:<bob@example.net>");
324 try std.testing.expectEqualStrings("bob@example.net", command.rcpt.path);
325 try std.testing.expectError(error.Syntax, parse("MAIL <missing-keyword>"));
326 }
327};
328
329/// Iterates the ESMTP parameters of a MAIL or RCPT command
330/// ([RFC 5321 §4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2)),
331/// e.g. "SIZE=1024 BODY=8BITMIME".
332pub const ParamIterator = struct {
333 rest: []const u8,
334
335 pub const Param = struct {
336 keyword: []const u8,
337 /// Empty when the parameter carries no value.
338 value: []const u8 = "",
339 };
340
341 pub fn init(params: []const u8) ParamIterator {
342 return .{ .rest = params };
343 }
344
345 pub fn next(it: *ParamIterator) ?Param {
346 it.rest = std.mem.trimStart(u8, it.rest, " \t");
347 if (it.rest.len == 0) return null;
348 const end = std.mem.indexOfAny(u8, it.rest, " \t") orelse it.rest.len;
349 const token = it.rest[0..end];
350 it.rest = it.rest[end..];
351 if (std.mem.indexOfScalar(u8, token, '=')) |eq| {
352 return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] };
353 }
354 return .{ .keyword = token };
355 }
356
357 test init {
358 var it: ParamIterator = .init("SIZE=42");
359 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword);
360 }
361
362 test next {
363 var it: ParamIterator = .init("BODY=8BITMIME CUSTOM");
364 const body = it.next().?;
365 try std.testing.expectEqualStrings("BODY", body.keyword);
366 try std.testing.expectEqualStrings("8BITMIME", body.value);
367 const custom = it.next().?;
368 try std.testing.expectEqualStrings("CUSTOM", custom.keyword);
369 try std.testing.expectEqualStrings("", custom.value);
370 try std.testing.expectEqual(@as(?Param, null), it.next());
371 }
372};
373
374/// Writes `data` as SMTP message content: line endings are normalized to CRLF
375/// and lines beginning with '.' are dot-stuffed
376/// ([RFC 5321 §4.5.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.2)). Does not
377/// write the terminating ".\r\n".
378pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void {
379 var rest = data;
380 while (rest.len > 0) {
381 var line: []const u8 = undefined;
382 if (std.mem.indexOfScalar(u8, rest, '\n')) |i| {
383 line = rest[0..i];
384 rest = rest[i + 1 ..];
385 } else {
386 line = rest;
387 rest = rest[rest.len..];
388 }
389 if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
390 if (line.len > 0 and line[0] == '.') try writer.writeByte('.');
391 try writer.writeAll(line);
392 try writer.writeAll(crlf);
393 }
394}
395
396test readLine {
397 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n");
398 try std.testing.expectEqualStrings("first", try readLine(&reader));
399 try std.testing.expectEqualStrings("second", try readLine(&reader));
400 try std.testing.expectEqualStrings("third", try readLine(&reader));
401 try std.testing.expectError(error.EndOfStream, readLine(&reader));
402}
403
404test Reply {
405 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
406 var buf: [128]u8 = undefined;
407 const reply = try Reply.read(&reader, &buf);
408 try std.testing.expectEqual(@as(u16, 250), reply.code);
409 try std.testing.expectEqualStrings("2.0.0 Ok", reply.text);
410 try std.testing.expect(reply.isPositiveCompletion());
411}
412
413test "Reply.read multiline" {
414 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n");
415 var buf: [128]u8 = undefined;
416 const reply = try Reply.read(&reader, &buf);
417 try std.testing.expectEqual(@as(u16, 250), reply.code);
418 try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text);
419 var it = reply.lines();
420 try std.testing.expectEqualStrings("mx.example.com", it.next().?);
421 try std.testing.expectEqualStrings("PIPELINING", it.next().?);
422 try std.testing.expectEqualStrings("SIZE 1000", it.next().?);
423 try std.testing.expectEqual(@as(?[]const u8, null), it.next());
424}
425
426test "Reply.read rejects malformed replies" {
427 var buf: [128]u8 = undefined;
428 {
429 var reader: Io.Reader = .fixed("2x0 hello\r\n");
430 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
431 }
432 {
433 var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n");
434 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
435 }
436 {
437 var reader: Io.Reader = .fixed("42\r\n");
438 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
439 }
440}
441
442test Command {
443 {
444 const cmd = try Command.parse("EHLO client.example.com");
445 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo);
446 }
447 {
448 const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024");
449 try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path);
450 try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params);
451 }
452 {
453 // Null reverse-path and a space after the colon.
454 const cmd = try Command.parse("MAIL FROM: <>");
455 try std.testing.expectEqualStrings("", cmd.mail.path);
456 }
457 {
458 // Obsolete source route is stripped.
459 const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>");
460 try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path);
461 }
462 {
463 // Quoted local-parts (from postfix's address corpora) may contain
464 // spaces and even '>' or escaped quotes.
465 const cmd = try Command.parse("MAIL FROM:<\"foo bar\"@example.com> SIZE=9");
466 try std.testing.expectEqualStrings("\"foo bar\"@example.com", cmd.mail.path);
467 try std.testing.expectEqualStrings("SIZE=9", cmd.mail.params);
468 }
469 {
470 const cmd = try Command.parse("RCPT TO:<\"a>b\"@example.com>");
471 try std.testing.expectEqualStrings("\"a>b\"@example.com", cmd.rcpt.path);
472 }
473 {
474 const cmd = try Command.parse("RCPT TO:<\"a\\\">b\"@example.com>");
475 try std.testing.expectEqualStrings("\"a\\\">b\"@example.com", cmd.rcpt.path);
476 }
477 try std.testing.expectError(error.Syntax, Command.parse("MAIL FROM:<\"unterminated@example.com>"));
478 {
479 const cmd = try Command.parse("QUIT");
480 try std.testing.expectEqual(Command.quit, cmd);
481 }
482 {
483 const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw==");
484 try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism);
485 try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial);
486 }
487 {
488 const cmd = try Command.parse("auth login");
489 try std.testing.expectEqualStrings("login", cmd.auth.mechanism);
490 try std.testing.expectEqualStrings("", cmd.auth.initial);
491 }
492 {
493 const cmd = try Command.parse("MADE UP");
494 try std.testing.expectEqualStrings("MADE UP", cmd.unknown);
495 }
496 {
497 const cmd = try Command.parse("BDAT 1024");
498 try std.testing.expectEqual(@as(u64, 1024), cmd.bdat.size);
499 try std.testing.expect(!cmd.bdat.last);
500 }
501 {
502 const cmd = try Command.parse("bdat 0 last");
503 try std.testing.expectEqual(@as(u64, 0), cmd.bdat.size);
504 try std.testing.expect(cmd.bdat.last);
505 }
506 try std.testing.expectError(error.Syntax, Command.parse("BDAT"));
507 try std.testing.expectError(error.Syntax, Command.parse("BDAT nan"));
508 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 FIRST"));
509 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 LAST extra"));
510 try std.testing.expectError(error.Syntax, Command.parse("AUTH"));
511 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>"));
512 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:"));
513 try std.testing.expectError(error.Syntax, Command.parse("HELO"));
514}
515
516test writeStuffed {
517 var buf: [256]u8 = undefined;
518 {
519 var w: Io.Writer = .fixed(&buf);
520 try writeStuffed(&w, "line one\r\n.starts with dot\r\n");
521 try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered());
522 }
523 {
524 // LF-only input is normalized, missing final newline is added.
525 var w: Io.Writer = .fixed(&buf);
526 try writeStuffed(&w, "a\nb");
527 try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered());
528 }
529 {
530 // A lone "." line must not become a terminator.
531 var w: Io.Writer = .fixed(&buf);
532 try writeStuffed(&w, ".\n");
533 try std.testing.expectEqualStrings("..\r\n", w.buffered());
534 }
535 {
536 var w: Io.Writer = .fixed(&buf);
537 try writeStuffed(&w, "");
538 try std.testing.expectEqualStrings("", w.buffered());
539 }
540}
541
542test "fuzz Command.parse" {
543 try std.testing.fuzz({}, fuzzCommandParse, .{});
544}
545
546fn fuzzCommandParse(context: void, smith: *std.testing.Smith) !void {
547 _ = context;
548 var line_buf: [512]u8 = undefined;
549 const line = line_buf[0..smith.value(u9)];
550 smith.bytes(line);
551
552 const command = Command.parse(line) catch return;
553 // Payload slices must always lie within the parsed line.
554 switch (command) {
555 .helo, .ehlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len),
556 .mail, .rcpt => |args| {
557 try std.testing.expect(args.path.len <= line.len);
558 try std.testing.expect(args.params.len <= line.len);
559 },
560 .auth => |args| {
561 try std.testing.expect(args.mechanism.len <= line.len);
562 try std.testing.expect(args.initial.len <= line.len);
563 },
564 .data, .rset, .noop, .quit, .help, .starttls, .bdat => {},
565 }
566}
567
568test "fuzz Reply.read" {
569 try std.testing.fuzz({}, fuzzReplyRead, .{});
570}
571
572fn fuzzReplyRead(context: void, smith: *std.testing.Smith) !void {
573 _ = context;
574 var input_buf: [1024]u8 = undefined;
575 const input = input_buf[0..smith.value(u10)];
576 smith.bytes(input);
577
578 var reader: Io.Reader = .fixed(input);
579 var text_buf: [128]u8 = undefined;
580 // Each successful read consumes at least one line, so this terminates.
581 while (true) {
582 const reply = Reply.read(&reader, &text_buf) catch break;
583 try std.testing.expect(reply.code >= 100 and reply.code <= 599);
584 }
585}
586
587test ParamIterator {
588 const command = try Command.parse("MAIL FROM:<a@example.com> SIZE=1024 BODY=8BITMIME FLAG");
589 var it = command.mail.paramIterator();
590
591 const size = it.next().?;
592 try std.testing.expectEqualStrings("SIZE", size.keyword);
593 try std.testing.expectEqualStrings("1024", size.value);
594
595 const body = it.next().?;
596 try std.testing.expectEqualStrings("BODY", body.keyword);
597 try std.testing.expectEqualStrings("8BITMIME", body.value);
598
599 const flag = it.next().?;
600 try std.testing.expectEqualStrings("FLAG", flag.keyword);
601 try std.testing.expectEqualStrings("", flag.value);
602
603 try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next());
604}
605
606test crlf {
607 try std.testing.expectEqualStrings("\r\n", crlf);
608}
609
610test "RFC 5321 mailbox forms from the is_email corpus round-trip" {
611 // Parses Dominic Sayers' is_email test suite (tests.xml and
612 // tests-original.xml, embedded from the lazy `isemail` dependency by
613 // `zig build test -Disemail-corpus`) and checks that every address
614 // valid at the RFC 5321 layer passes through the lenient path parser
615 // byte-for-byte, params intact.
616 if (comptime @import("build_options").isemail_corpus) {
617 const corpus = @embedFile("isemail_tests_xml") ++ "\n" ++
618 @embedFile("isemail_tests_original_xml");
619 var checked: usize = 0;
620 var rest: []const u8 = corpus;
621 while (std.mem.indexOf(u8, rest, "<test ")) |start_index| {
622 const end_index = std.mem.indexOfPos(u8, rest, start_index, "</test>") orelse break;
623 const block = rest[start_index..end_index];
624 rest = rest[end_index + "</test>".len ..];
625
626 const category = xmlElementText(block, "category") orelse continue;
627 if (!std.mem.eql(u8, category, "ISEMAIL_VALID_CATEGORY") and
628 !std.mem.eql(u8, category, "ISEMAIL_RFC5321")) continue;
629 const raw = xmlElementText(block, "address") orelse continue;
630 var address_buf: [256]u8 = undefined;
631 const address = try xmlUnescape(&address_buf, raw);
632
633 var line_buf: [300]u8 = undefined;
634 const line = try std.fmt.bufPrint(&line_buf, "MAIL FROM:<{s}> SIZE=1", .{address});
635 const command = try Command.parse(line);
636 try std.testing.expectEqualStrings(address, command.mail.path);
637 try std.testing.expectEqualStrings("SIZE=1", command.mail.params);
638 checked += 1;
639 }
640 // The two files carry 125 RFC 5321-valid cases between them; fail
641 // loudly if the extraction ever silently rots.
642 try std.testing.expect(checked >= 120);
643 } else return error.SkipZigTest;
644}
645
646fn xmlElementText(block: []const u8, comptime tag: []const u8) ?[]const u8 {
647 const open = "<" ++ tag ++ ">";
648 const close = "</" ++ tag ++ ">";
649 const start = (std.mem.indexOf(u8, block, open) orelse return null) + open.len;
650 const end = std.mem.indexOfPos(u8, block, start, close) orelse return null;
651 return block[start..end];
652}
653
654fn xmlUnescape(buffer: []u8, input: []const u8) ![]const u8 {
655 var out: usize = 0;
656 var i: usize = 0;
657 while (i < input.len) {
658 if (input[i] != '&') {
659 buffer[out] = input[i];
660 out += 1;
661 i += 1;
662 continue;
663 }
664 const semi = std.mem.indexOfScalarPos(u8, input, i, ';') orelse return error.BadEntity;
665 const entity = input[i + 1 .. semi];
666 i = semi + 1;
667 if (std.mem.eql(u8, entity, "amp")) {
668 buffer[out] = '&';
669 out += 1;
670 } else if (std.mem.eql(u8, entity, "lt")) {
671 buffer[out] = '<';
672 out += 1;
673 } else if (std.mem.eql(u8, entity, "gt")) {
674 buffer[out] = '>';
675 out += 1;
676 } else if (std.mem.eql(u8, entity, "quot")) {
677 buffer[out] = '"';
678 out += 1;
679 } else if (std.mem.eql(u8, entity, "apos")) {
680 buffer[out] = '\'';
681 out += 1;
682 } else if (std.mem.startsWith(u8, entity, "#x") or std.mem.startsWith(u8, entity, "#X")) {
683 const codepoint = try std.fmt.parseInt(u21, entity[2..], 16);
684 out += try std.unicode.utf8Encode(codepoint, buffer[out..]);
685 } else if (std.mem.startsWith(u8, entity, "#")) {
686 const codepoint = try std.fmt.parseInt(u21, entity[1..], 10);
687 out += try std.unicode.utf8Encode(codepoint, buffer[out..]);
688 } else return error.BadEntity;
689 }
690 return buffer[0..out];
691}