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/// An enhanced status code
64/// ([RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463)), the
65/// `class.subject.detail` triple that
66/// [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) puts at the
67/// front of a reply's text.
68///
69/// It says more than the three-digit code can. `550` is "no", where
70/// `5.1.1` is "no, that mailbox does not exist" and `5.7.1` is "no, and not
71/// because of anything about the address" — a difference worth acting on.
72pub const Enhanced = struct {
73 /// 2, 4 or 5: success, persistent transient failure, permanent failure.
74 /// RFC 3463 defines no others.
75 class: u8,
76 subject: u16,
77 detail: u16,
78
79 /// What the subject sub-code is about. Non-exhaustive because the
80 /// production allows three digits and only eight are defined.
81 pub const Subject = enum(u16) {
82 other = 0,
83 addressing = 1,
84 mailbox = 2,
85 mail_system = 3,
86 network = 4,
87 delivery_protocol = 5,
88 content = 6,
89 security = 7,
90 _,
91 };
92
93 pub fn subjectClass(e: Enhanced) Subject {
94 return @enumFromInt(e.subject);
95 }
96
97 /// Whether the class agrees with the reply code it came with.
98 ///
99 /// They should: a 250 carrying `5.1.1` is a server contradicting itself,
100 /// and worth not believing. RFC 3463 does not say what a receiver should
101 /// do about it, so this only reports it.
102 pub fn agrees(e: Enhanced, code: u16) bool {
103 return e.class == code / 100;
104 }
105
106 /// Reads a code from the front of `text`, or null if there is not one.
107 ///
108 /// Strict, because the alternative is misreading an ordinary message
109 /// that happens to begin with digits: the class must be one RFC 3463
110 /// defines, each field is one to three digits with no leading zeros, and
111 /// the code must be followed by a space or be the whole text.
112 pub fn parse(text: []const u8) ?Enhanced {
113 var rest = text;
114 const class = switch (if (rest.len == 0) return null else rest[0]) {
115 '2', '4', '5' => |digit| digit - '0',
116 else => return null,
117 };
118 rest = rest[1..];
119 if (rest.len == 0 or rest[0] != '.') return null;
120 rest = rest[1..];
121
122 const subject = takeNumber(&rest) orelse return null;
123 if (rest.len == 0 or rest[0] != '.') return null;
124 rest = rest[1..];
125 const detail = takeNumber(&rest) orelse return null;
126
127 // RFC 2034: "always followed by one or more spaces". A code with no
128 // text after it at all is not what the RFC describes, but servers
129 // send it and there is nothing ambiguous about it.
130 if (rest.len != 0 and rest[0] != ' ') return null;
131 return .{ .class = class, .subject = subject, .detail = detail };
132 }
133
134 /// `text` with a leading enhanced code and the spaces after it removed,
135 /// or `text` unchanged if there was none.
136 ///
137 /// A multiline reply repeats the code on every line, so this strips it
138 /// from the first one only; walk `Reply.lines` and strip each.
139 pub fn strip(text: []const u8) []const u8 {
140 if (parse(text) == null) return text;
141 const space = std.mem.findScalar(u8, text, ' ') orelse return text[text.len..];
142 return std.mem.trimStart(u8, text[space..], " ");
143 }
144
145 /// Writes the code as `class.subject.detail`.
146 pub fn format(e: Enhanced, writer: *Io.Writer) Io.Writer.Error!void {
147 try writer.print("{d}.{d}.{d}", .{ e.class, e.subject, e.detail });
148 }
149
150 /// One to three digits with no leading zeros, consumed from the front.
151 fn takeNumber(rest: *[]const u8) ?u16 {
152 var digits: usize = 0;
153 while (digits < rest.len and std.ascii.isDigit(rest.*[digits])) digits += 1;
154 if (digits == 0 or digits > 3) return null;
155 // "expressed without leading zero digits", so "0" is a number and
156 // "01" is not one.
157 if (digits > 1 and rest.*[0] == '0') return null;
158 const value = std.fmt.parseInt(u16, rest.*[0..digits], 10) catch return null;
159 rest.* = rest.*[digits..];
160 return value;
161 }
162
163 test parse {
164 const ok = parse("2.1.5 Ok").?;
165 try std.testing.expectEqual(@as(u8, 2), ok.class);
166 try std.testing.expectEqual(Subject.addressing, ok.subjectClass());
167 try std.testing.expectEqual(@as(u16, 5), ok.detail);
168 try std.testing.expect(ok.agrees(250));
169 try std.testing.expect(!ok.agrees(550));
170 try std.testing.expectEqualStrings("Ok", strip("2.1.5 Ok"));
171
172 // A code with no text after it.
173 try std.testing.expectEqual(@as(u16, 0), parse("5.0.0").?.detail);
174 // Three digits each is the most the grammar allows.
175 try std.testing.expectEqual(@as(u16, 999), parse("5.999.999 x").?.subject);
176
177 // And the things that are not codes.
178 try std.testing.expectEqual(@as(?Enhanced, null), parse("")); // nothing
179 try std.testing.expectEqual(@as(?Enhanced, null), parse("3.1.1 x")); // no such class
180 try std.testing.expectEqual(@as(?Enhanced, null), parse("2.1 x")); // two fields
181 try std.testing.expectEqual(@as(?Enhanced, null), parse("2.1.5x")); // no space
182 try std.testing.expectEqual(@as(?Enhanced, null), parse("2.01.5 x")); // leading zero
183 try std.testing.expectEqual(@as(?Enhanced, null), parse("2.1234.5 x")); // too long
184 // An ordinary message that begins with something numeric-looking.
185 try std.testing.expectEqualStrings("2.1 GB is too large", strip("2.1 GB is too large"));
186 }
187};
188
189/// A server reply: a 3-digit code and one or more lines of text.
190pub const Reply = struct {
191 code: u16,
192 /// Text of all reply lines joined with '\n', with codes and separators
193 /// stripped. Points into the buffer passed to `read`.
194 text: []const u8,
195
196 pub const ReadError = ReadLineError || error{
197 InvalidReply,
198 /// The reply text did not fit in the provided buffer.
199 ReplyTooLong,
200 };
201
202 /// Reads one (possibly multiline) reply. The text is copied into `buffer`
203 /// and the returned reply's `text` field points into it.
204 pub fn read(reader: *Io.Reader, buffer: []u8) ReadError!Reply {
205 var text: Io.Writer = .fixed(buffer);
206 var code: ?u16 = null;
207 var first = true;
208 while (true) {
209 const line = try readLine(reader);
210 if (line.len < 3) return error.InvalidReply;
211 const line_code = std.fmt.parseInt(u16, line[0..3], 10) catch
212 return error.InvalidReply;
213 if (line_code < 100 or line_code > 599) return error.InvalidReply;
214 if (code) |prev| {
215 // All lines of a multiline reply must carry the same code.
216 if (prev != line_code) return error.InvalidReply;
217 } else {
218 code = line_code;
219 }
220 var last = true;
221 var line_text: []const u8 = "";
222 if (line.len > 3) {
223 switch (line[3]) {
224 ' ' => {},
225 '-' => last = false,
226 else => return error.InvalidReply,
227 }
228 line_text = line[4..];
229 }
230 if (!first) text.writeByte('\n') catch return error.ReplyTooLong;
231 text.writeAll(line_text) catch return error.ReplyTooLong;
232 first = false;
233 if (last) break;
234 }
235 return .{ .code = code.?, .text = text.buffered() };
236 }
237
238 /// Iterates over the individual text lines of the reply.
239 pub fn lines(r: *const Reply) std.mem.SplitIterator(u8, .scalar) {
240 return std.mem.splitScalar(u8, r.text, '\n');
241 }
242
243 // Reply classes per RFC 5321 §4.2.1
244 // (https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.1).
245 pub fn isPositiveCompletion(r: Reply) bool {
246 return r.code >= 200 and r.code < 300;
247 }
248 pub fn isPositiveIntermediate(r: Reply) bool {
249 return r.code >= 300 and r.code < 400;
250 }
251 pub fn isTransientFailure(r: Reply) bool {
252 return r.code >= 400 and r.code < 500;
253 }
254 /// The enhanced status code at the front of the text, if there is one
255 /// ([RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034)).
256 ///
257 /// A server implementing that extension attaches one to every 2xx, 4xx
258 /// and 5xx reply whether or not the client said EHLO — but not to the
259 /// greeting, not to a HELO or EHLO response, and not to a 3xx, so those
260 /// answer null and correctly so.
261 ///
262 /// Worth checking `agrees` against `code` before acting on it: the two
263 /// disagreeing means the server contradicted itself.
264 pub fn enhanced(r: Reply) ?Enhanced {
265 return Enhanced.parse(r.text);
266 }
267
268 /// The text with the enhanced status code removed, which is the part
269 /// meant for a person. A multiline reply repeats the code on every line,
270 /// so this strips it from the first only; walk `lines` and use
271 /// `Enhanced.strip` on each for the rest.
272 pub fn message(r: Reply) []const u8 {
273 return Enhanced.strip(r.text);
274 }
275
276 test enhanced {
277 const rejected: Reply = .{ .code = 550, .text = "5.1.1 No such user" };
278 const status = rejected.enhanced().?;
279 try std.testing.expect(status.agrees(rejected.code));
280 try std.testing.expectEqual(Enhanced.Subject.addressing, status.subjectClass());
281 try std.testing.expectEqualStrings("No such user", rejected.message());
282
283 // A server that does not implement the extension, and one that is
284 // excluded from it: neither has a code, and neither is an error.
285 const plain: Reply = .{ .code = 550, .text = "No such user" };
286 try std.testing.expectEqual(@as(?Enhanced, null), plain.enhanced());
287 try std.testing.expectEqualStrings("No such user", plain.message());
288 }
289
290 pub fn isPermanentFailure(r: Reply) bool {
291 return r.code >= 500 and r.code < 600;
292 }
293
294 test read {
295 var reader: Io.Reader = .fixed("250-first\r\n250 second\r\n");
296 var buffer: [64]u8 = undefined;
297 const reply = try read(&reader, &buffer);
298 try std.testing.expectEqual(@as(u16, 250), reply.code);
299 try std.testing.expectEqualStrings("first\nsecond", reply.text);
300 }
301
302 test lines {
303 const reply: Reply = .{ .code = 250, .text = "one\ntwo" };
304 var it = reply.lines();
305 try std.testing.expectEqualStrings("one", it.next().?);
306 try std.testing.expectEqualStrings("two", it.next().?);
307 try std.testing.expectEqual(@as(?[]const u8, null), it.next());
308 }
309
310 test isPositiveCompletion {
311 try std.testing.expect((Reply{ .code = 250, .text = "" }).isPositiveCompletion());
312 try std.testing.expect(!(Reply{ .code = 354, .text = "" }).isPositiveCompletion());
313 }
314
315 test isPositiveIntermediate {
316 try std.testing.expect((Reply{ .code = 354, .text = "" }).isPositiveIntermediate());
317 }
318
319 test isTransientFailure {
320 try std.testing.expect((Reply{ .code = 451, .text = "" }).isTransientFailure());
321 }
322
323 test isPermanentFailure {
324 try std.testing.expect((Reply{ .code = 550, .text = "" }).isPermanentFailure());
325 }
326};
327
328/// A parsed client command, as seen by a server.
329pub const Command = union(enum) {
330 helo: []const u8,
331 ehlo: []const u8,
332 /// LHLO, the LMTP greeting
333 /// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)), which
334 /// has the same semantics as EHLO. An LMTP server takes this one and
335 /// refuses HELO and EHLO; an SMTP server does the reverse.
336 lhlo: []const u8,
337 /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`).
338 mail: PathArgs,
339 /// RCPT TO.
340 rcpt: PathArgs,
341 data,
342 rset,
343 noop,
344 quit,
345 vrfy: []const u8,
346 help,
347 starttls,
348 /// AUTH ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)).
349 auth: AuthArgs,
350 /// BDAT, the CHUNKING extension
351 /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). The
352 /// command line is followed by exactly `size` raw octets.
353 bdat: BdatArgs,
354 /// Unrecognized command verb; the payload is the full line.
355 unknown: []const u8,
356
357 pub const BdatArgs = struct {
358 size: u64,
359 /// True for the final chunk of the message ("BDAT n LAST").
360 last: bool = false,
361 };
362
363 pub const AuthArgs = struct {
364 mechanism: []const u8,
365 /// Raw base64 initial response, if the client sent one ("=" denotes
366 /// an empty initial response).
367 initial: []const u8 = "",
368 };
369
370 pub const PathArgs = struct {
371 /// The mailbox, with angle brackets and any obsolete source route
372 /// stripped.
373 path: []const u8,
374 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024".
375 params: []const u8 = "",
376
377 pub fn paramIterator(args: PathArgs) ParamIterator {
378 return .init(args.params);
379 }
380
381 test paramIterator {
382 const args: PathArgs = .{ .path = "a@example.com", .params = "SIZE=7" };
383 var it = args.paramIterator();
384 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword);
385 }
386 };
387
388 pub const ParseError = error{Syntax};
389
390 /// Parses one command line (without its line ending). Returned slices
391 /// point into `line`.
392 pub fn parse(line: []const u8) ParseError!Command {
393 const trimmed = std.mem.trim(u8, line, " \t");
394 const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len;
395 const verb = trimmed[0..verb_end];
396 const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t");
397
398 if (ieql(verb, "HELO")) {
399 if (rest.len == 0) return error.Syntax;
400 return .{ .helo = rest };
401 }
402 if (ieql(verb, "EHLO")) {
403 if (rest.len == 0) return error.Syntax;
404 return .{ .ehlo = rest };
405 }
406 if (ieql(verb, "LHLO")) {
407 if (rest.len == 0) return error.Syntax;
408 return .{ .lhlo = rest };
409 }
410 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") };
411 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") };
412 if (ieql(verb, "DATA")) return .data;
413 if (ieql(verb, "RSET")) return .rset;
414 if (ieql(verb, "NOOP")) return .noop;
415 if (ieql(verb, "QUIT")) return .quit;
416 if (ieql(verb, "VRFY")) return .{ .vrfy = rest };
417 if (ieql(verb, "HELP")) return .help;
418 if (ieql(verb, "STARTTLS")) return .starttls;
419 if (ieql(verb, "BDAT")) {
420 var it = std.mem.tokenizeAny(u8, rest, " \t");
421 const size_token = it.next() orelse return error.Syntax;
422 const size = std.fmt.parseInt(u64, size_token, 10) catch return error.Syntax;
423 var last = false;
424 if (it.next()) |token| {
425 if (!ieql(token, "LAST")) return error.Syntax;
426 last = true;
427 }
428 if (it.next() != null) return error.Syntax;
429 return .{ .bdat = .{ .size = size, .last = last } };
430 }
431 if (ieql(verb, "AUTH")) {
432 const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len;
433 if (mech_end == 0) return error.Syntax;
434 return .{ .auth = .{
435 .mechanism = rest[0..mech_end],
436 .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"),
437 } };
438 }
439 return .{ .unknown = line };
440 }
441
442 fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs {
443 if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword))
444 return error.Syntax;
445 const after = std.mem.trimStart(u8, rest[keyword.len..], " \t");
446 if (after.len == 0 or after[0] != '<') {
447 // Lenient: accept a bare address ending at whitespace.
448 const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len;
449 if (end == 0) return error.Syntax;
450 return .{
451 .path = after[0..end],
452 .params = std.mem.trimStart(u8, after[end..], " \t"),
453 };
454 }
455 // The closing bracket must be found outside any quoted local-part:
456 // <"a>b"@example.com> is legal (RFC 5321 quoted-string, with
457 // backslash escapes).
458 const close = close: {
459 var in_quotes = false;
460 var i: usize = 1;
461 while (i < after.len) : (i += 1) {
462 const byte = after[i];
463 if (in_quotes) {
464 if (byte == '\\') {
465 i += 1;
466 } else if (byte == '"') {
467 in_quotes = false;
468 }
469 } else if (byte == '"') {
470 in_quotes = true;
471 } else if (byte == '>') {
472 break :close i;
473 }
474 }
475 return error.Syntax;
476 };
477 var path = after[1..close];
478 // Strip an obsolete source route: <@relay1,@relay2:user@host>.
479 if (path.len > 0 and path[0] == '@') {
480 const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax;
481 path = path[colon + 1 ..];
482 }
483 return .{
484 .path = path,
485 .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"),
486 };
487 }
488
489 fn ieql(a: []const u8, b: []const u8) bool {
490 return std.ascii.eqlIgnoreCase(a, b);
491 }
492
493 test parse {
494 const command = try parse("RCPT TO:<bob@example.net>");
495 try std.testing.expectEqualStrings("bob@example.net", command.rcpt.path);
496 try std.testing.expectError(error.Syntax, parse("MAIL <missing-keyword>"));
497 }
498};
499
500/// The `RET` parameter of an extended MAIL command
501/// ([RFC 3461 §4.3](https://datatracker.ietf.org/doc/html/rfc3461#section-4.3)):
502/// how much of the message a failed DSN should carry back. Absent, the
503/// choice is the reporting MTA's.
504/// The `BODY` parameter of an extended MAIL command: what kind of content
505/// the message carries, and so what the receiver has to be able to take.
506pub const Body = enum {
507 /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). Lines of
508 /// at most 998 characters from the ASCII repertoire.
509 seven_bit,
510 /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). The same
511 /// line structure, with the high bit allowed.
512 eight_bit_mime,
513 /// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030). Arbitrary
514 /// octets with no line structure at all, which is why it can only be
515 /// carried by BDAT: DATA has no way to frame content that may hold the
516 /// terminator itself.
517 binary_mime,
518
519 pub const ParseError = error{Syntax};
520
521 pub fn parse(value: []const u8) ParseError!Body {
522 if (std.ascii.eqlIgnoreCase(value, "7BIT")) return .seven_bit;
523 if (std.ascii.eqlIgnoreCase(value, "8BITMIME")) return .eight_bit_mime;
524 if (std.ascii.eqlIgnoreCase(value, "BINARYMIME")) return .binary_mime;
525 return error.Syntax;
526 }
527
528 /// Writes the value as it appears on the wire.
529 pub fn format(b: Body, writer: *Io.Writer) Io.Writer.Error!void {
530 try writer.writeAll(switch (b) {
531 .seven_bit => "7BIT",
532 .eight_bit_mime => "8BITMIME",
533 .binary_mime => "BINARYMIME",
534 });
535 }
536
537 test parse {
538 try std.testing.expectEqual(Body.binary_mime, try parse("binarymime"));
539 try std.testing.expectEqual(Body.seven_bit, try parse("7BIT"));
540 try std.testing.expectError(error.Syntax, parse("BINARY"));
541 }
542};
543
544/// The `AUTH` parameter of an extended MAIL command
545/// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)):
546/// who originally submitted this message, carried forward by a relay that
547/// authenticated them.
548///
549/// It is an assertion, not a proof — the peer is claiming this on its own
550/// authority — which is why RFC 4954 requires a server to disregard it and
551/// behave as though `<>` had been sent whenever the client is unauthenticated
552/// or insufficiently trusted.
553pub const Submitter = union(enum) {
554 /// Sent as `<>`: the two characters that mean "I do not know", which a
555 /// client should send rather than omitting the parameter when it is
556 /// relaying something it cannot vouch for.
557 unknown,
558 /// The mailbox asserted, xtext-decoded. A bare address with no angle
559 /// brackets, which is what RFC 5321's `Mailbox` production is.
560 mailbox: []const u8,
561
562 /// RFC 4954 §5 extends the MAIL command line by 500 characters to make
563 /// room for this, which is the only ceiling it gives.
564 pub const max_len = 500;
565
566 pub const ParseError = error{Syntax};
567
568 /// Parses the parameter value, decoding the mailbox into `buffer`.
569 pub fn parse(buffer: []u8, value: []const u8) ParseError!Submitter {
570 if (value.len == 0 or value.len > max_len) return error.Syntax;
571 const decoded = xtextDecode(buffer, value) catch return error.Syntax;
572 if (std.mem.eql(u8, decoded, "<>")) return .unknown;
573 // Anything else must be a mailbox, and an empty one is not.
574 if (decoded.len == 0) return error.Syntax;
575 return .{ .mailbox = decoded };
576 }
577
578 /// Writes the parameter value as it appears on the wire, xtext-encoding
579 /// the mailbox.
580 pub fn format(s: Submitter, writer: *Io.Writer) Io.Writer.Error!void {
581 switch (s) {
582 .unknown => try writer.writeAll("<>"),
583 .mailbox => |mailbox| try writeXtext(writer, mailbox),
584 }
585 }
586
587 test parse {
588 var buffer: [64]u8 = undefined;
589 try std.testing.expectEqual(Submitter.unknown, try parse(&buffer, "<>"));
590 const who = try parse(&buffer, "e+3Dmc2@example.com");
591 try std.testing.expectEqualStrings("e=mc2@example.com", who.mailbox);
592 try std.testing.expectError(error.Syntax, parse(&buffer, ""));
593 try std.testing.expectError(error.Syntax, parse(&buffer, "not xtext!"));
594 }
595};
596
597/// RFC 3461 §4.4 caps the `ENVID` parameter value at 100 characters, which
598/// is a limit on the xtext-encoded form and not on what went into it.
599pub const max_envid_len = 100;
600
601pub const Ret = enum {
602 /// Return the entire message.
603 full,
604 /// Return the headers only.
605 hdrs,
606
607 pub const ParseError = error{Syntax};
608
609 pub fn parse(value: []const u8) ParseError!Ret {
610 if (std.ascii.eqlIgnoreCase(value, "FULL")) return .full;
611 if (std.ascii.eqlIgnoreCase(value, "HDRS")) return .hdrs;
612 return error.Syntax;
613 }
614
615 /// Writes the value as it appears on the wire.
616 pub fn format(r: Ret, writer: *Io.Writer) Io.Writer.Error!void {
617 try writer.writeAll(switch (r) {
618 .full => "FULL",
619 .hdrs => "HDRS",
620 });
621 }
622
623 test parse {
624 try std.testing.expectEqual(Ret.hdrs, try parse("hdrs"));
625 try std.testing.expectError(error.Syntax, parse("PARTIAL"));
626 }
627};
628
629/// The `NOTIFY` parameter of an extended RCPT command
630/// ([RFC 3461 §4.1](https://datatracker.ietf.org/doc/html/rfc3461#section-4.1)):
631/// the conditions under which the sender wants to hear about this
632/// recipient. Absent, RFC 3461 lets a server read it as either
633/// `FAILURE` or `FAILURE,DELAY` — which is why "not specified" is an
634/// absent `?Notify` here and not a value of it.
635pub const Notify = union(enum) {
636 /// `NOTIFY=NEVER`: no DSN for this recipient under any circumstance.
637 /// RFC 3461 requires the keyword to appear on its own, and parsing
638 /// rejects it in a list.
639 never,
640 /// One or more of `SUCCESS`, `FAILURE` and `DELAY`.
641 on: Conditions,
642
643 pub const Conditions = struct {
644 success: bool = false,
645 failure: bool = false,
646 delay: bool = false,
647 };
648
649 pub const ParseError = error{Syntax};
650
651 pub fn parse(value: []const u8) ParseError!Notify {
652 if (std.ascii.eqlIgnoreCase(value, "NEVER")) return .never;
653 var conditions: Conditions = .{};
654 var it = std.mem.splitScalar(u8, value, ',');
655 var any = false;
656 while (it.next()) |keyword| {
657 if (std.ascii.eqlIgnoreCase(keyword, "SUCCESS")) {
658 conditions.success = true;
659 } else if (std.ascii.eqlIgnoreCase(keyword, "FAILURE")) {
660 conditions.failure = true;
661 } else if (std.ascii.eqlIgnoreCase(keyword, "DELAY")) {
662 conditions.delay = true;
663 } else return error.Syntax; // Including NEVER: it may not be listed.
664 any = true;
665 }
666 if (!any) return error.Syntax;
667 return .{ .on = conditions };
668 }
669
670 /// Writes the value as it appears on the wire.
671 pub fn format(n: Notify, writer: *Io.Writer) Io.Writer.Error!void {
672 switch (n) {
673 .never => try writer.writeAll("NEVER"),
674 .on => |conditions| {
675 var written = false;
676 inline for (.{
677 .{ conditions.success, "SUCCESS" },
678 .{ conditions.failure, "FAILURE" },
679 .{ conditions.delay, "DELAY" },
680 }) |pair| {
681 if (pair[0]) {
682 if (written) try writer.writeByte(',');
683 try writer.writeAll(pair[1]);
684 written = true;
685 }
686 }
687 // An empty condition set has no legal spelling; NEVER is
688 // what "tell me nothing" is written as.
689 if (!written) try writer.writeAll("NEVER");
690 },
691 }
692 }
693
694 test parse {
695 try std.testing.expectEqual(Notify.never, try parse("NEVER"));
696 const both = try parse("SUCCESS,delay");
697 try std.testing.expect(both.on.success and both.on.delay and !both.on.failure);
698 try std.testing.expectError(error.Syntax, parse("NEVER,SUCCESS"));
699 try std.testing.expectError(error.Syntax, parse(""));
700 try std.testing.expectError(error.Syntax, parse("SUCCESS,MAYBE"));
701 }
702};
703
704/// The `ORCPT` parameter of an extended RCPT command
705/// ([RFC 3461 §4.2](https://datatracker.ietf.org/doc/html/rfc3461#section-4.2)):
706/// the address the message was originally addressed to, carried unchanged
707/// through aliasing and forwarding so that a DSN can name what the sender
708/// actually wrote.
709pub const Orcpt = struct {
710 /// The address type, an atom — `rfc822` in all but the unusual cases.
711 addr_type: []const u8,
712 /// The original recipient, xtext-decoded.
713 address: []const u8,
714
715 /// RFC 3461 §4.2 caps the whole parameter value at 500 characters.
716 pub const max_len = 500;
717
718 pub const ParseError = error{Syntax};
719
720 /// Parses `addr-type ";" xtext`, decoding the address into `buffer`.
721 /// The returned `addr_type` points into `value` and `address` points
722 /// into `buffer`, so the two have different lifetimes; a caller keeping
723 /// the result past either one copies both.
724 pub fn parse(buffer: []u8, value: []const u8) ParseError!Orcpt {
725 const semicolon = std.mem.findScalar(u8, value, ';') orelse return error.Syntax;
726 const addr_type = value[0..semicolon];
727 if (addr_type.len == 0) return error.Syntax;
728 for (addr_type) |byte| if (!isAtomByte(byte)) return error.Syntax;
729 return .{
730 .addr_type = addr_type,
731 .address = xtextDecode(buffer, value[semicolon + 1 ..]) catch return error.Syntax,
732 };
733 }
734
735 /// Writes the parameter value as it appears on the wire, xtext-encoding
736 /// the address.
737 pub fn format(o: Orcpt, writer: *Io.Writer) Io.Writer.Error!void {
738 try writer.writeAll(o.addr_type);
739 try writer.writeByte(';');
740 try writeXtext(writer, o.address);
741 }
742
743 /// RFC 5321 `atom` less the specials, which is what an addr-type may be.
744 fn isAtomByte(byte: u8) bool {
745 return switch (byte) {
746 'A'...'Z', 'a'...'z', '0'...'9' => true,
747 '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?' => true,
748 '^', '_', '`', '{', '|', '}', '~' => true,
749 else => false,
750 };
751 }
752
753 test parse {
754 var buffer: [64]u8 = undefined;
755 const orcpt = try parse(&buffer, "rfc822;bob+2Bx@example.net");
756 try std.testing.expectEqualStrings("rfc822", orcpt.addr_type);
757 try std.testing.expectEqualStrings("bob+x@example.net", orcpt.address);
758 try std.testing.expectError(error.Syntax, parse(&buffer, "bob@example.net"));
759 try std.testing.expectError(error.Syntax, parse(&buffer, ";bob@example.net"));
760 }
761};
762
763/// Whether `byte` may appear in an xtext unencoded
764/// ([RFC 3461 §4](https://datatracker.ietf.org/doc/html/rfc3461#section-4)):
765/// printable US-ASCII other than `+`, which introduces an escape, and `=`,
766/// which separates an ESMTP keyword from its value.
767pub fn isXchar(byte: u8) bool {
768 return byte >= '!' and byte <= '~' and byte != '+' and byte != '=';
769}
770
771/// Writes `text` xtext-encoded: anything that is not an `xchar` becomes
772/// `+` and two upper-case hex digits. Every byte therefore survives,
773/// including the ones that would otherwise end the command line, so an
774/// xtext-encoded parameter is safe to write from untrusted input.
775///
776/// RFC 3461 asks that the value before encoding be printable US-ASCII.
777/// That is the caller's to observe; encoding anything else here produces
778/// valid xtext regardless rather than a corrupt command.
779pub fn writeXtext(writer: *Io.Writer, text: []const u8) Io.Writer.Error!void {
780 for (text) |byte| {
781 if (isXchar(byte)) {
782 try writer.writeByte(byte);
783 } else {
784 try writer.print("+{X:0>2}", .{byte});
785 }
786 }
787}
788
789/// The length `writeXtext` will produce for `text`, for checking a value
790/// against the length limits RFC 3461 puts on the encoded form.
791pub fn xtextEncodedLen(text: []const u8) usize {
792 var len: usize = 0;
793 for (text) |byte| len += if (isXchar(byte)) 1 else 3;
794 return len;
795}
796
797pub const XtextError = error{
798 /// Not valid xtext: a `+` not followed by two hex digits, or a raw byte
799 /// that the encoder was required to escape.
800 BadXtext,
801 NoSpaceLeft,
802};
803
804/// Decodes xtext into `buffer`, returning the decoded bytes. Decoding is
805/// strict: a byte an encoder was obliged to escape is rejected rather than
806/// passed through, since accepting it would let two different encodings
807/// mean the same thing.
808pub fn xtextDecode(buffer: []u8, text: []const u8) XtextError![]u8 {
809 var out: usize = 0;
810 var i: usize = 0;
811 while (i < text.len) {
812 const byte = text[i];
813 if (byte == '+') {
814 if (i + 2 >= text.len) return error.BadXtext;
815 const hex = text[i + 1 ..][0..2];
816 // Checked before parsing because `parseInt` also accepts a sign
817 // and underscore separators, which hex digits are not. Lower
818 // case is accepted on the way in even though RFC 3461 requires
819 // upper case on the way out.
820 for (hex) |digit| if (!std.ascii.isHex(digit)) return error.BadXtext;
821 const value = std.fmt.parseInt(u8, hex, 16) catch return error.BadXtext;
822 if (out >= buffer.len) return error.NoSpaceLeft;
823 buffer[out] = value;
824 out += 1;
825 i += 3;
826 } else {
827 if (!isXchar(byte)) return error.BadXtext;
828 if (out >= buffer.len) return error.NoSpaceLeft;
829 buffer[out] = byte;
830 out += 1;
831 i += 1;
832 }
833 }
834 return buffer[0..out];
835}
836
837test xtextDecode {
838 var buffer: [64]u8 = undefined;
839 try std.testing.expectEqualStrings(
840 "a+b=c",
841 try xtextDecode(&buffer, "a+2Bb+3Dc"),
842 );
843 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+2"));
844 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+ZZb"));
845 // A raw '=' or ' ' is what the encoder had to escape.
846 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a=b"));
847 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a b"));
848}
849
850test writeXtext {
851 var out_buf: [64]u8 = undefined;
852 var writer: Io.Writer = .fixed(&out_buf);
853 try writeXtext(&writer, "id+1=2 \r\n");
854 try std.testing.expectEqualStrings("id+2B1+3D2+20+0D+0A", writer.buffered());
855 try std.testing.expectEqual(writer.buffered().len, xtextEncodedLen("id+1=2 \r\n"));
856
857 // Every byte survives the round trip.
858 var raw: [256]u8 = undefined;
859 for (&raw, 0..) |*byte, i| byte.* = @intCast(i);
860 var round_buf: [1024]u8 = undefined;
861 var round: Io.Writer = .fixed(&round_buf);
862 try writeXtext(&round, &raw);
863 var decoded_buf: [256]u8 = undefined;
864 try std.testing.expectEqualSlices(u8, &raw, try xtextDecode(&decoded_buf, round.buffered()));
865}
866
867/// Iterates the ESMTP parameters of a MAIL or RCPT command
868/// ([RFC 5321 §4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2)),
869/// e.g. "SIZE=1024 BODY=8BITMIME".
870pub const ParamIterator = struct {
871 rest: []const u8,
872
873 pub const Param = struct {
874 keyword: []const u8,
875 /// Empty when the parameter carries no value.
876 value: []const u8 = "",
877 };
878
879 pub fn init(params: []const u8) ParamIterator {
880 return .{ .rest = params };
881 }
882
883 pub fn next(it: *ParamIterator) ?Param {
884 it.rest = std.mem.trimStart(u8, it.rest, " \t");
885 if (it.rest.len == 0) return null;
886 const end = std.mem.indexOfAny(u8, it.rest, " \t") orelse it.rest.len;
887 const token = it.rest[0..end];
888 it.rest = it.rest[end..];
889 if (std.mem.indexOfScalar(u8, token, '=')) |eq| {
890 return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] };
891 }
892 return .{ .keyword = token };
893 }
894
895 test init {
896 var it: ParamIterator = .init("SIZE=42");
897 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword);
898 }
899
900 test next {
901 var it: ParamIterator = .init("BODY=8BITMIME CUSTOM");
902 const body = it.next().?;
903 try std.testing.expectEqualStrings("BODY", body.keyword);
904 try std.testing.expectEqualStrings("8BITMIME", body.value);
905 const custom = it.next().?;
906 try std.testing.expectEqualStrings("CUSTOM", custom.keyword);
907 try std.testing.expectEqualStrings("", custom.value);
908 try std.testing.expectEqual(@as(?Param, null), it.next());
909 }
910};
911
912/// Writes `data` as SMTP message content: line endings are normalized to CRLF
913/// and lines beginning with '.' are dot-stuffed
914/// ([RFC 5321 §4.5.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.2)). Does not
915/// write the terminating ".\r\n".
916pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void {
917 var rest = data;
918 while (rest.len > 0) {
919 var line: []const u8 = undefined;
920 if (std.mem.indexOfScalar(u8, rest, '\n')) |i| {
921 line = rest[0..i];
922 rest = rest[i + 1 ..];
923 } else {
924 line = rest;
925 rest = rest[rest.len..];
926 }
927 if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
928 if (line.len > 0 and line[0] == '.') try writer.writeByte('.');
929 try writer.writeAll(line);
930 try writer.writeAll(crlf);
931 }
932}
933
934test readLine {
935 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n");
936 try std.testing.expectEqualStrings("first", try readLine(&reader));
937 try std.testing.expectEqualStrings("second", try readLine(&reader));
938 try std.testing.expectEqualStrings("third", try readLine(&reader));
939 try std.testing.expectError(error.EndOfStream, readLine(&reader));
940}
941
942test Reply {
943 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
944 var buf: [128]u8 = undefined;
945 const reply = try Reply.read(&reader, &buf);
946 try std.testing.expectEqual(@as(u16, 250), reply.code);
947 try std.testing.expectEqualStrings("2.0.0 Ok", reply.text);
948 try std.testing.expect(reply.isPositiveCompletion());
949}
950
951test "Reply.read multiline" {
952 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n");
953 var buf: [128]u8 = undefined;
954 const reply = try Reply.read(&reader, &buf);
955 try std.testing.expectEqual(@as(u16, 250), reply.code);
956 try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text);
957 var it = reply.lines();
958 try std.testing.expectEqualStrings("mx.example.com", it.next().?);
959 try std.testing.expectEqualStrings("PIPELINING", it.next().?);
960 try std.testing.expectEqualStrings("SIZE 1000", it.next().?);
961 try std.testing.expectEqual(@as(?[]const u8, null), it.next());
962}
963
964test "Reply.read rejects malformed replies" {
965 var buf: [128]u8 = undefined;
966 {
967 var reader: Io.Reader = .fixed("2x0 hello\r\n");
968 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
969 }
970 {
971 var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n");
972 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
973 }
974 {
975 var reader: Io.Reader = .fixed("42\r\n");
976 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
977 }
978}
979
980test Command {
981 {
982 const cmd = try Command.parse("EHLO client.example.com");
983 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo);
984 }
985 {
986 const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024");
987 try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path);
988 try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params);
989 }
990 {
991 // Null reverse-path and a space after the colon.
992 const cmd = try Command.parse("MAIL FROM: <>");
993 try std.testing.expectEqualStrings("", cmd.mail.path);
994 }
995 {
996 // Obsolete source route is stripped.
997 const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>");
998 try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path);
999 }
1000 {
1001 // Quoted local-parts (from postfix's address corpora) may contain
1002 // spaces and even '>' or escaped quotes.
1003 const cmd = try Command.parse("MAIL FROM:<\"foo bar\"@example.com> SIZE=9");
1004 try std.testing.expectEqualStrings("\"foo bar\"@example.com", cmd.mail.path);
1005 try std.testing.expectEqualStrings("SIZE=9", cmd.mail.params);
1006 }
1007 {
1008 const cmd = try Command.parse("RCPT TO:<\"a>b\"@example.com>");
1009 try std.testing.expectEqualStrings("\"a>b\"@example.com", cmd.rcpt.path);
1010 }
1011 {
1012 const cmd = try Command.parse("RCPT TO:<\"a\\\">b\"@example.com>");
1013 try std.testing.expectEqualStrings("\"a\\\">b\"@example.com", cmd.rcpt.path);
1014 }
1015 try std.testing.expectError(error.Syntax, Command.parse("MAIL FROM:<\"unterminated@example.com>"));
1016 {
1017 const cmd = try Command.parse("QUIT");
1018 try std.testing.expectEqual(Command.quit, cmd);
1019 }
1020 {
1021 const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw==");
1022 try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism);
1023 try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial);
1024 }
1025 {
1026 const cmd = try Command.parse("auth login");
1027 try std.testing.expectEqualStrings("login", cmd.auth.mechanism);
1028 try std.testing.expectEqualStrings("", cmd.auth.initial);
1029 }
1030 {
1031 const cmd = try Command.parse("MADE UP");
1032 try std.testing.expectEqualStrings("MADE UP", cmd.unknown);
1033 }
1034 {
1035 const cmd = try Command.parse("BDAT 1024");
1036 try std.testing.expectEqual(@as(u64, 1024), cmd.bdat.size);
1037 try std.testing.expect(!cmd.bdat.last);
1038 }
1039 {
1040 const cmd = try Command.parse("bdat 0 last");
1041 try std.testing.expectEqual(@as(u64, 0), cmd.bdat.size);
1042 try std.testing.expect(cmd.bdat.last);
1043 }
1044 try std.testing.expectError(error.Syntax, Command.parse("BDAT"));
1045 try std.testing.expectError(error.Syntax, Command.parse("BDAT nan"));
1046 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 FIRST"));
1047 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 LAST extra"));
1048 try std.testing.expectError(error.Syntax, Command.parse("AUTH"));
1049 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>"));
1050 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:"));
1051 try std.testing.expectError(error.Syntax, Command.parse("HELO"));
1052}
1053
1054test writeStuffed {
1055 var buf: [256]u8 = undefined;
1056 {
1057 var w: Io.Writer = .fixed(&buf);
1058 try writeStuffed(&w, "line one\r\n.starts with dot\r\n");
1059 try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered());
1060 }
1061 {
1062 // LF-only input is normalized, missing final newline is added.
1063 var w: Io.Writer = .fixed(&buf);
1064 try writeStuffed(&w, "a\nb");
1065 try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered());
1066 }
1067 {
1068 // A lone "." line must not become a terminator.
1069 var w: Io.Writer = .fixed(&buf);
1070 try writeStuffed(&w, ".\n");
1071 try std.testing.expectEqualStrings("..\r\n", w.buffered());
1072 }
1073 {
1074 var w: Io.Writer = .fixed(&buf);
1075 try writeStuffed(&w, "");
1076 try std.testing.expectEqualStrings("", w.buffered());
1077 }
1078}
1079
1080test "fuzz Command.parse" {
1081 try std.testing.fuzz({}, fuzzCommandParse, .{});
1082}
1083
1084fn fuzzCommandParse(context: void, smith: *std.testing.Smith) !void {
1085 _ = context;
1086 var line_buf: [512]u8 = undefined;
1087 const line = line_buf[0..smith.value(u9)];
1088 smith.bytes(line);
1089
1090 const command = Command.parse(line) catch return;
1091 // Payload slices must always lie within the parsed line.
1092 switch (command) {
1093 .helo, .ehlo, .lhlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len),
1094 .mail, .rcpt => |args| {
1095 try std.testing.expect(args.path.len <= line.len);
1096 try std.testing.expect(args.params.len <= line.len);
1097 },
1098 .auth => |args| {
1099 try std.testing.expect(args.mechanism.len <= line.len);
1100 try std.testing.expect(args.initial.len <= line.len);
1101 },
1102 .data, .rset, .noop, .quit, .help, .starttls, .bdat => {},
1103 }
1104}
1105
1106test "fuzz Reply.read" {
1107 try std.testing.fuzz({}, fuzzReplyRead, .{});
1108}
1109
1110fn fuzzReplyRead(context: void, smith: *std.testing.Smith) !void {
1111 _ = context;
1112 var input_buf: [1024]u8 = undefined;
1113 const input = input_buf[0..smith.value(u10)];
1114 smith.bytes(input);
1115
1116 var reader: Io.Reader = .fixed(input);
1117 var text_buf: [128]u8 = undefined;
1118 // Each successful read consumes at least one line, so this terminates.
1119 while (true) {
1120 const reply = Reply.read(&reader, &text_buf) catch break;
1121 try std.testing.expect(reply.code >= 100 and reply.code <= 599);
1122 }
1123}
1124
1125test ParamIterator {
1126 const command = try Command.parse("MAIL FROM:<a@example.com> SIZE=1024 BODY=8BITMIME FLAG");
1127 var it = command.mail.paramIterator();
1128
1129 const size = it.next().?;
1130 try std.testing.expectEqualStrings("SIZE", size.keyword);
1131 try std.testing.expectEqualStrings("1024", size.value);
1132
1133 const body = it.next().?;
1134 try std.testing.expectEqualStrings("BODY", body.keyword);
1135 try std.testing.expectEqualStrings("8BITMIME", body.value);
1136
1137 const flag = it.next().?;
1138 try std.testing.expectEqualStrings("FLAG", flag.keyword);
1139 try std.testing.expectEqualStrings("", flag.value);
1140
1141 try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next());
1142}
1143
1144test crlf {
1145 try std.testing.expectEqualStrings("\r\n", crlf);
1146}
1147
1148test "RFC 5321 mailbox forms from the is_email corpus round-trip" {
1149 // Parses Dominic Sayers' is_email test suite (tests.xml and
1150 // tests-original.xml, embedded from the lazy `isemail` dependency by
1151 // `zig build test -Disemail-corpus`) and checks that every address
1152 // valid at the RFC 5321 layer passes through the lenient path parser
1153 // byte-for-byte, params intact.
1154 if (comptime @import("build_options").isemail_corpus) {
1155 const corpus = @embedFile("isemail_tests_xml") ++ "\n" ++
1156 @embedFile("isemail_tests_original_xml");
1157 var checked: usize = 0;
1158 var rest: []const u8 = corpus;
1159 while (std.mem.indexOf(u8, rest, "<test ")) |start_index| {
1160 const end_index = std.mem.indexOfPos(u8, rest, start_index, "</test>") orelse break;
1161 const block = rest[start_index..end_index];
1162 rest = rest[end_index + "</test>".len ..];
1163
1164 const category = xmlElementText(block, "category") orelse continue;
1165 if (!std.mem.eql(u8, category, "ISEMAIL_VALID_CATEGORY") and
1166 !std.mem.eql(u8, category, "ISEMAIL_RFC5321")) continue;
1167 const raw = xmlElementText(block, "address") orelse continue;
1168 var address_buf: [256]u8 = undefined;
1169 const address = try xmlUnescape(&address_buf, raw);
1170
1171 var line_buf: [300]u8 = undefined;
1172 const line = try std.fmt.bufPrint(&line_buf, "MAIL FROM:<{s}> SIZE=1", .{address});
1173 const command = try Command.parse(line);
1174 try std.testing.expectEqualStrings(address, command.mail.path);
1175 try std.testing.expectEqualStrings("SIZE=1", command.mail.params);
1176 checked += 1;
1177 }
1178 // The two files carry 125 RFC 5321-valid cases between them; fail
1179 // loudly if the extraction ever silently rots.
1180 try std.testing.expect(checked >= 120);
1181 } else return error.SkipZigTest;
1182}
1183
1184fn xmlElementText(block: []const u8, comptime tag: []const u8) ?[]const u8 {
1185 const open = "<" ++ tag ++ ">";
1186 const close = "</" ++ tag ++ ">";
1187 const start = (std.mem.indexOf(u8, block, open) orelse return null) + open.len;
1188 const end = std.mem.indexOfPos(u8, block, start, close) orelse return null;
1189 return block[start..end];
1190}
1191
1192fn xmlUnescape(buffer: []u8, input: []const u8) ![]const u8 {
1193 var out: usize = 0;
1194 var i: usize = 0;
1195 while (i < input.len) {
1196 if (input[i] != '&') {
1197 buffer[out] = input[i];
1198 out += 1;
1199 i += 1;
1200 continue;
1201 }
1202 const semi = std.mem.indexOfScalarPos(u8, input, i, ';') orelse return error.BadEntity;
1203 const entity = input[i + 1 .. semi];
1204 i = semi + 1;
1205 if (std.mem.eql(u8, entity, "amp")) {
1206 buffer[out] = '&';
1207 out += 1;
1208 } else if (std.mem.eql(u8, entity, "lt")) {
1209 buffer[out] = '<';
1210 out += 1;
1211 } else if (std.mem.eql(u8, entity, "gt")) {
1212 buffer[out] = '>';
1213 out += 1;
1214 } else if (std.mem.eql(u8, entity, "quot")) {
1215 buffer[out] = '"';
1216 out += 1;
1217 } else if (std.mem.eql(u8, entity, "apos")) {
1218 buffer[out] = '\'';
1219 out += 1;
1220 } else if (std.mem.startsWith(u8, entity, "#x") or std.mem.startsWith(u8, entity, "#X")) {
1221 const codepoint = try std.fmt.parseInt(u21, entity[2..], 16);
1222 out += try std.unicode.utf8Encode(codepoint, buffer[out..]);
1223 } else if (std.mem.startsWith(u8, entity, "#")) {
1224 const codepoint = try std.fmt.parseInt(u21, entity[1..], 10);
1225 out += try std.unicode.utf8Encode(codepoint, buffer[out..]);
1226 } else return error.BadEntity;
1227 }
1228 return buffer[0..out];
1229}