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: is this a deliverable address?
346 /// ([RFC 5321 §4.1.1.6](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.6))
347 vrfy: []const u8,
348 /// EXPN: what addresses does this mailing list expand to?
349 /// ([RFC 5321 §4.1.1.7](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.7))
350 ///
351 /// Recognized so that a server can decline it with 502 — "I know this
352 /// command and do not implement it" — rather than 500, which claims not
353 /// to have heard of it. RFC 5321 §4.2.4 allows either, and the first is
354 /// the true one for a server that parsed the verb.
355 expn: []const u8,
356 help,
357 starttls,
358 /// AUTH ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)).
359 auth: AuthArgs,
360 /// BDAT, the CHUNKING extension
361 /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). The
362 /// command line is followed by exactly `size` raw octets.
363 bdat: BdatArgs,
364 /// Unrecognized command verb; the payload is the full line.
365 unknown: []const u8,
366
367 pub const BdatArgs = struct {
368 size: u64,
369 /// True for the final chunk of the message ("BDAT n LAST").
370 last: bool = false,
371 };
372
373 pub const AuthArgs = struct {
374 mechanism: []const u8,
375 /// Raw base64 initial response, if the client sent one ("=" denotes
376 /// an empty initial response).
377 initial: []const u8 = "",
378 };
379
380 pub const PathArgs = struct {
381 /// The mailbox, with angle brackets and any obsolete source route
382 /// stripped.
383 path: []const u8,
384 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024".
385 params: []const u8 = "",
386
387 pub fn paramIterator(args: PathArgs) ParamIterator {
388 return .init(args.params);
389 }
390
391 test paramIterator {
392 const args: PathArgs = .{ .path = "a@example.com", .params = "SIZE=7" };
393 var it = args.paramIterator();
394 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword);
395 }
396 };
397
398 pub const ParseError = error{Syntax};
399
400 /// Parses one command line (without its line ending). Returned slices
401 /// point into `line`.
402 pub fn parse(line: []const u8) ParseError!Command {
403 const trimmed = std.mem.trim(u8, line, " \t");
404 const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len;
405 const verb = trimmed[0..verb_end];
406 const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t");
407
408 if (ieql(verb, "HELO")) {
409 if (rest.len == 0) return error.Syntax;
410 return .{ .helo = rest };
411 }
412 if (ieql(verb, "EHLO")) {
413 if (rest.len == 0) return error.Syntax;
414 return .{ .ehlo = rest };
415 }
416 if (ieql(verb, "LHLO")) {
417 if (rest.len == 0) return error.Syntax;
418 return .{ .lhlo = rest };
419 }
420 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") };
421 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") };
422 if (ieql(verb, "DATA")) return .data;
423 if (ieql(verb, "RSET")) return .rset;
424 if (ieql(verb, "NOOP")) return .noop;
425 if (ieql(verb, "QUIT")) return .quit;
426 // Both take a mandatory argument: `vrfy = "VRFY" SP String CRLF`
427 // and the same shape for EXPN. Without one there is nothing to act
428 // on, so it is a syntax error rather than a command.
429 if (ieql(verb, "VRFY")) {
430 if (rest.len == 0) return error.Syntax;
431 return .{ .vrfy = rest };
432 }
433 if (ieql(verb, "EXPN")) {
434 if (rest.len == 0) return error.Syntax;
435 return .{ .expn = rest };
436 }
437 if (ieql(verb, "HELP")) return .help;
438 if (ieql(verb, "STARTTLS")) return .starttls;
439 if (ieql(verb, "BDAT")) {
440 var it = std.mem.tokenizeAny(u8, rest, " \t");
441 const size_token = it.next() orelse return error.Syntax;
442 const size = std.fmt.parseInt(u64, size_token, 10) catch return error.Syntax;
443 var last = false;
444 if (it.next()) |token| {
445 if (!ieql(token, "LAST")) return error.Syntax;
446 last = true;
447 }
448 if (it.next() != null) return error.Syntax;
449 return .{ .bdat = .{ .size = size, .last = last } };
450 }
451 if (ieql(verb, "AUTH")) {
452 const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len;
453 if (mech_end == 0) return error.Syntax;
454 return .{ .auth = .{
455 .mechanism = rest[0..mech_end],
456 .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"),
457 } };
458 }
459 return .{ .unknown = line };
460 }
461
462 fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs {
463 if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword))
464 return error.Syntax;
465 const after = std.mem.trimStart(u8, rest[keyword.len..], " \t");
466 if (after.len == 0 or after[0] != '<') {
467 // Lenient: accept a bare address ending at whitespace.
468 const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len;
469 if (end == 0) return error.Syntax;
470 return .{
471 .path = after[0..end],
472 .params = std.mem.trimStart(u8, after[end..], " \t"),
473 };
474 }
475 // The closing bracket must be found outside any quoted local-part:
476 // <"a>b"@example.com> is legal (RFC 5321 quoted-string, with
477 // backslash escapes).
478 const close = close: {
479 var in_quotes = false;
480 var i: usize = 1;
481 while (i < after.len) : (i += 1) {
482 const byte = after[i];
483 if (in_quotes) {
484 if (byte == '\\') {
485 i += 1;
486 } else if (byte == '"') {
487 in_quotes = false;
488 }
489 } else if (byte == '"') {
490 in_quotes = true;
491 } else if (byte == '>') {
492 break :close i;
493 }
494 }
495 return error.Syntax;
496 };
497 var path = after[1..close];
498 // Strip an obsolete source route: <@relay1,@relay2:user@host>.
499 if (path.len > 0 and path[0] == '@') {
500 const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax;
501 path = path[colon + 1 ..];
502 }
503 return .{
504 .path = path,
505 .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"),
506 };
507 }
508
509 fn ieql(a: []const u8, b: []const u8) bool {
510 return std.ascii.eqlIgnoreCase(a, b);
511 }
512
513 test parse {
514 const command = try parse("RCPT TO:<bob@example.net>");
515 try std.testing.expectEqualStrings("bob@example.net", command.rcpt.path);
516 try std.testing.expectError(error.Syntax, parse("MAIL <missing-keyword>"));
517 }
518};
519
520/// The `RET` parameter of an extended MAIL command
521/// ([RFC 3461 §4.3](https://datatracker.ietf.org/doc/html/rfc3461#section-4.3)):
522/// how much of the message a failed DSN should carry back. Absent, the
523/// choice is the reporting MTA's.
524/// The `BODY` parameter of an extended MAIL command: what kind of content
525/// the message carries, and so what the receiver has to be able to take.
526pub const Body = enum {
527 /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). Lines of
528 /// at most 998 characters from the ASCII repertoire.
529 seven_bit,
530 /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). The same
531 /// line structure, with the high bit allowed.
532 eight_bit_mime,
533 /// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030). Arbitrary
534 /// octets with no line structure at all, which is why it can only be
535 /// carried by BDAT: DATA has no way to frame content that may hold the
536 /// terminator itself.
537 binary_mime,
538
539 pub const ParseError = error{Syntax};
540
541 pub fn parse(value: []const u8) ParseError!Body {
542 if (std.ascii.eqlIgnoreCase(value, "7BIT")) return .seven_bit;
543 if (std.ascii.eqlIgnoreCase(value, "8BITMIME")) return .eight_bit_mime;
544 if (std.ascii.eqlIgnoreCase(value, "BINARYMIME")) return .binary_mime;
545 return error.Syntax;
546 }
547
548 /// Writes the value as it appears on the wire.
549 pub fn format(b: Body, writer: *Io.Writer) Io.Writer.Error!void {
550 try writer.writeAll(switch (b) {
551 .seven_bit => "7BIT",
552 .eight_bit_mime => "8BITMIME",
553 .binary_mime => "BINARYMIME",
554 });
555 }
556
557 test parse {
558 try std.testing.expectEqual(Body.binary_mime, try parse("binarymime"));
559 try std.testing.expectEqual(Body.seven_bit, try parse("7BIT"));
560 try std.testing.expectError(error.Syntax, parse("BINARY"));
561 }
562};
563
564/// The `AUTH` parameter of an extended MAIL command
565/// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)):
566/// who originally submitted this message, carried forward by a relay that
567/// authenticated them.
568///
569/// It is an assertion, not a proof — the peer is claiming this on its own
570/// authority — which is why RFC 4954 requires a server to disregard it and
571/// behave as though `<>` had been sent whenever the client is unauthenticated
572/// or insufficiently trusted.
573pub const Submitter = union(enum) {
574 /// Sent as `<>`: the two characters that mean "I do not know", which a
575 /// client should send rather than omitting the parameter when it is
576 /// relaying something it cannot vouch for.
577 unknown,
578 /// The mailbox asserted, xtext-decoded. A bare address with no angle
579 /// brackets, which is what RFC 5321's `Mailbox` production is.
580 mailbox: []const u8,
581
582 /// RFC 4954 §5 extends the MAIL command line by 500 characters to make
583 /// room for this, which is the only ceiling it gives.
584 pub const max_len = 500;
585
586 pub const ParseError = error{Syntax};
587
588 /// Parses the parameter value, decoding the mailbox into `buffer`.
589 pub fn parse(buffer: []u8, value: []const u8) ParseError!Submitter {
590 if (value.len == 0 or value.len > max_len) return error.Syntax;
591 const decoded = xtextDecode(buffer, value) catch return error.Syntax;
592 if (std.mem.eql(u8, decoded, "<>")) return .unknown;
593 // Anything else must be a mailbox, and an empty one is not.
594 if (decoded.len == 0) return error.Syntax;
595 return .{ .mailbox = decoded };
596 }
597
598 /// Writes the parameter value as it appears on the wire, xtext-encoding
599 /// the mailbox.
600 pub fn format(s: Submitter, writer: *Io.Writer) Io.Writer.Error!void {
601 switch (s) {
602 .unknown => try writer.writeAll("<>"),
603 .mailbox => |mailbox| try writeXtext(writer, mailbox),
604 }
605 }
606
607 test parse {
608 var buffer: [64]u8 = undefined;
609 try std.testing.expectEqual(Submitter.unknown, try parse(&buffer, "<>"));
610 const who = try parse(&buffer, "e+3Dmc2@example.com");
611 try std.testing.expectEqualStrings("e=mc2@example.com", who.mailbox);
612 try std.testing.expectError(error.Syntax, parse(&buffer, ""));
613 try std.testing.expectError(error.Syntax, parse(&buffer, "not xtext!"));
614 }
615};
616
617/// RFC 3461 §4.4 caps the `ENVID` parameter value at 100 characters, which
618/// is a limit on the xtext-encoded form and not on what went into it.
619pub const max_envid_len = 100;
620
621pub const Ret = enum {
622 /// Return the entire message.
623 full,
624 /// Return the headers only.
625 hdrs,
626
627 pub const ParseError = error{Syntax};
628
629 pub fn parse(value: []const u8) ParseError!Ret {
630 if (std.ascii.eqlIgnoreCase(value, "FULL")) return .full;
631 if (std.ascii.eqlIgnoreCase(value, "HDRS")) return .hdrs;
632 return error.Syntax;
633 }
634
635 /// Writes the value as it appears on the wire.
636 pub fn format(r: Ret, writer: *Io.Writer) Io.Writer.Error!void {
637 try writer.writeAll(switch (r) {
638 .full => "FULL",
639 .hdrs => "HDRS",
640 });
641 }
642
643 test parse {
644 try std.testing.expectEqual(Ret.hdrs, try parse("hdrs"));
645 try std.testing.expectError(error.Syntax, parse("PARTIAL"));
646 }
647};
648
649/// The `NOTIFY` parameter of an extended RCPT command
650/// ([RFC 3461 §4.1](https://datatracker.ietf.org/doc/html/rfc3461#section-4.1)):
651/// the conditions under which the sender wants to hear about this
652/// recipient. Absent, RFC 3461 lets a server read it as either
653/// `FAILURE` or `FAILURE,DELAY` — which is why "not specified" is an
654/// absent `?Notify` here and not a value of it.
655pub const Notify = union(enum) {
656 /// `NOTIFY=NEVER`: no DSN for this recipient under any circumstance.
657 /// RFC 3461 requires the keyword to appear on its own, and parsing
658 /// rejects it in a list.
659 never,
660 /// One or more of `SUCCESS`, `FAILURE` and `DELAY`.
661 on: Conditions,
662
663 pub const Conditions = struct {
664 success: bool = false,
665 failure: bool = false,
666 delay: bool = false,
667 };
668
669 pub const ParseError = error{Syntax};
670
671 pub fn parse(value: []const u8) ParseError!Notify {
672 if (std.ascii.eqlIgnoreCase(value, "NEVER")) return .never;
673 var conditions: Conditions = .{};
674 var it = std.mem.splitScalar(u8, value, ',');
675 var any = false;
676 while (it.next()) |keyword| {
677 if (std.ascii.eqlIgnoreCase(keyword, "SUCCESS")) {
678 conditions.success = true;
679 } else if (std.ascii.eqlIgnoreCase(keyword, "FAILURE")) {
680 conditions.failure = true;
681 } else if (std.ascii.eqlIgnoreCase(keyword, "DELAY")) {
682 conditions.delay = true;
683 } else return error.Syntax; // Including NEVER: it may not be listed.
684 any = true;
685 }
686 if (!any) return error.Syntax;
687 return .{ .on = conditions };
688 }
689
690 /// Writes the value as it appears on the wire.
691 pub fn format(n: Notify, writer: *Io.Writer) Io.Writer.Error!void {
692 switch (n) {
693 .never => try writer.writeAll("NEVER"),
694 .on => |conditions| {
695 var written = false;
696 inline for (.{
697 .{ conditions.success, "SUCCESS" },
698 .{ conditions.failure, "FAILURE" },
699 .{ conditions.delay, "DELAY" },
700 }) |pair| {
701 if (pair[0]) {
702 if (written) try writer.writeByte(',');
703 try writer.writeAll(pair[1]);
704 written = true;
705 }
706 }
707 // An empty condition set has no legal spelling; NEVER is
708 // what "tell me nothing" is written as.
709 if (!written) try writer.writeAll("NEVER");
710 },
711 }
712 }
713
714 test parse {
715 try std.testing.expectEqual(Notify.never, try parse("NEVER"));
716 const both = try parse("SUCCESS,delay");
717 try std.testing.expect(both.on.success and both.on.delay and !both.on.failure);
718 try std.testing.expectError(error.Syntax, parse("NEVER,SUCCESS"));
719 try std.testing.expectError(error.Syntax, parse(""));
720 try std.testing.expectError(error.Syntax, parse("SUCCESS,MAYBE"));
721 }
722};
723
724/// The `ORCPT` parameter of an extended RCPT command
725/// ([RFC 3461 §4.2](https://datatracker.ietf.org/doc/html/rfc3461#section-4.2)):
726/// the address the message was originally addressed to, carried unchanged
727/// through aliasing and forwarding so that a DSN can name what the sender
728/// actually wrote.
729pub const Orcpt = struct {
730 /// The address type, an atom — `rfc822` in all but the unusual cases.
731 addr_type: []const u8,
732 /// The original recipient, xtext-decoded.
733 address: []const u8,
734
735 /// RFC 3461 §4.2 caps the whole parameter value at 500 characters.
736 pub const max_len = 500;
737
738 pub const ParseError = error{Syntax};
739
740 /// Parses `addr-type ";" xtext`, decoding the address into `buffer`.
741 /// The returned `addr_type` points into `value` and `address` points
742 /// into `buffer`, so the two have different lifetimes; a caller keeping
743 /// the result past either one copies both.
744 pub fn parse(buffer: []u8, value: []const u8) ParseError!Orcpt {
745 const semicolon = std.mem.findScalar(u8, value, ';') orelse return error.Syntax;
746 const addr_type = value[0..semicolon];
747 if (addr_type.len == 0) return error.Syntax;
748 for (addr_type) |byte| if (!isAtomByte(byte)) return error.Syntax;
749 return .{
750 .addr_type = addr_type,
751 .address = xtextDecode(buffer, value[semicolon + 1 ..]) catch return error.Syntax,
752 };
753 }
754
755 /// Writes the parameter value as it appears on the wire, xtext-encoding
756 /// the address.
757 pub fn format(o: Orcpt, writer: *Io.Writer) Io.Writer.Error!void {
758 try writer.writeAll(o.addr_type);
759 try writer.writeByte(';');
760 try writeXtext(writer, o.address);
761 }
762
763 /// RFC 5321 `atom` less the specials, which is what an addr-type may be.
764 fn isAtomByte(byte: u8) bool {
765 return switch (byte) {
766 'A'...'Z', 'a'...'z', '0'...'9' => true,
767 '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?' => true,
768 '^', '_', '`', '{', '|', '}', '~' => true,
769 else => false,
770 };
771 }
772
773 test parse {
774 var buffer: [64]u8 = undefined;
775 const orcpt = try parse(&buffer, "rfc822;bob+2Bx@example.net");
776 try std.testing.expectEqualStrings("rfc822", orcpt.addr_type);
777 try std.testing.expectEqualStrings("bob+x@example.net", orcpt.address);
778 try std.testing.expectError(error.Syntax, parse(&buffer, "bob@example.net"));
779 try std.testing.expectError(error.Syntax, parse(&buffer, ";bob@example.net"));
780 }
781};
782
783/// Whether `byte` may appear in an xtext unencoded
784/// ([RFC 3461 §4](https://datatracker.ietf.org/doc/html/rfc3461#section-4)):
785/// printable US-ASCII other than `+`, which introduces an escape, and `=`,
786/// which separates an ESMTP keyword from its value.
787pub fn isXchar(byte: u8) bool {
788 return byte >= '!' and byte <= '~' and byte != '+' and byte != '=';
789}
790
791/// Writes `text` xtext-encoded: anything that is not an `xchar` becomes
792/// `+` and two upper-case hex digits. Every byte therefore survives,
793/// including the ones that would otherwise end the command line, so an
794/// xtext-encoded parameter is safe to write from untrusted input.
795///
796/// RFC 3461 asks that the value before encoding be printable US-ASCII.
797/// That is the caller's to observe; encoding anything else here produces
798/// valid xtext regardless rather than a corrupt command.
799pub fn writeXtext(writer: *Io.Writer, text: []const u8) Io.Writer.Error!void {
800 for (text) |byte| {
801 if (isXchar(byte)) {
802 try writer.writeByte(byte);
803 } else {
804 try writer.print("+{X:0>2}", .{byte});
805 }
806 }
807}
808
809/// The length `writeXtext` will produce for `text`, for checking a value
810/// against the length limits RFC 3461 puts on the encoded form.
811pub fn xtextEncodedLen(text: []const u8) usize {
812 var len: usize = 0;
813 for (text) |byte| len += if (isXchar(byte)) 1 else 3;
814 return len;
815}
816
817pub const XtextError = error{
818 /// Not valid xtext: a `+` not followed by two hex digits, or a raw byte
819 /// that the encoder was required to escape.
820 BadXtext,
821 NoSpaceLeft,
822};
823
824/// Decodes xtext into `buffer`, returning the decoded bytes. Decoding is
825/// strict: a byte an encoder was obliged to escape is rejected rather than
826/// passed through, since accepting it would let two different encodings
827/// mean the same thing.
828pub fn xtextDecode(buffer: []u8, text: []const u8) XtextError![]u8 {
829 var out: usize = 0;
830 var i: usize = 0;
831 while (i < text.len) {
832 const byte = text[i];
833 if (byte == '+') {
834 if (i + 2 >= text.len) return error.BadXtext;
835 const hex = text[i + 1 ..][0..2];
836 // Checked before parsing because `parseInt` also accepts a sign
837 // and underscore separators, which hex digits are not. Lower
838 // case is accepted on the way in even though RFC 3461 requires
839 // upper case on the way out.
840 for (hex) |digit| if (!std.ascii.isHex(digit)) return error.BadXtext;
841 const value = std.fmt.parseInt(u8, hex, 16) catch return error.BadXtext;
842 if (out >= buffer.len) return error.NoSpaceLeft;
843 buffer[out] = value;
844 out += 1;
845 i += 3;
846 } else {
847 if (!isXchar(byte)) return error.BadXtext;
848 if (out >= buffer.len) return error.NoSpaceLeft;
849 buffer[out] = byte;
850 out += 1;
851 i += 1;
852 }
853 }
854 return buffer[0..out];
855}
856
857test xtextDecode {
858 var buffer: [64]u8 = undefined;
859 try std.testing.expectEqualStrings(
860 "a+b=c",
861 try xtextDecode(&buffer, "a+2Bb+3Dc"),
862 );
863 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+2"));
864 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+ZZb"));
865 // A raw '=' or ' ' is what the encoder had to escape.
866 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a=b"));
867 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a b"));
868}
869
870test writeXtext {
871 var out_buf: [64]u8 = undefined;
872 var writer: Io.Writer = .fixed(&out_buf);
873 try writeXtext(&writer, "id+1=2 \r\n");
874 try std.testing.expectEqualStrings("id+2B1+3D2+20+0D+0A", writer.buffered());
875 try std.testing.expectEqual(writer.buffered().len, xtextEncodedLen("id+1=2 \r\n"));
876
877 // Every byte survives the round trip.
878 var raw: [256]u8 = undefined;
879 for (&raw, 0..) |*byte, i| byte.* = @intCast(i);
880 var round_buf: [1024]u8 = undefined;
881 var round: Io.Writer = .fixed(&round_buf);
882 try writeXtext(&round, &raw);
883 var decoded_buf: [256]u8 = undefined;
884 try std.testing.expectEqualSlices(u8, &raw, try xtextDecode(&decoded_buf, round.buffered()));
885}
886
887/// Iterates the ESMTP parameters of a MAIL or RCPT command
888/// ([RFC 5321 §4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2)),
889/// e.g. "SIZE=1024 BODY=8BITMIME".
890pub const ParamIterator = struct {
891 rest: []const u8,
892
893 pub const Param = struct {
894 keyword: []const u8,
895 /// Empty when the parameter carries no value.
896 value: []const u8 = "",
897 };
898
899 pub fn init(params: []const u8) ParamIterator {
900 return .{ .rest = params };
901 }
902
903 pub fn next(it: *ParamIterator) ?Param {
904 it.rest = std.mem.trimStart(u8, it.rest, " \t");
905 if (it.rest.len == 0) return null;
906 const end = std.mem.indexOfAny(u8, it.rest, " \t") orelse it.rest.len;
907 const token = it.rest[0..end];
908 it.rest = it.rest[end..];
909 if (std.mem.indexOfScalar(u8, token, '=')) |eq| {
910 return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] };
911 }
912 return .{ .keyword = token };
913 }
914
915 test init {
916 var it: ParamIterator = .init("SIZE=42");
917 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword);
918 }
919
920 test next {
921 var it: ParamIterator = .init("BODY=8BITMIME CUSTOM");
922 const body = it.next().?;
923 try std.testing.expectEqualStrings("BODY", body.keyword);
924 try std.testing.expectEqualStrings("8BITMIME", body.value);
925 const custom = it.next().?;
926 try std.testing.expectEqualStrings("CUSTOM", custom.keyword);
927 try std.testing.expectEqualStrings("", custom.value);
928 try std.testing.expectEqual(@as(?Param, null), it.next());
929 }
930};
931
932/// Writes `data` as SMTP message content: line endings are normalized to CRLF
933/// and lines beginning with '.' are dot-stuffed
934/// ([RFC 5321 §4.5.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.2)). Does not
935/// write the terminating ".\r\n".
936pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void {
937 var rest = data;
938 while (rest.len > 0) {
939 var line: []const u8 = undefined;
940 if (std.mem.indexOfScalar(u8, rest, '\n')) |i| {
941 line = rest[0..i];
942 rest = rest[i + 1 ..];
943 } else {
944 line = rest;
945 rest = rest[rest.len..];
946 }
947 if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
948 if (line.len > 0 and line[0] == '.') try writer.writeByte('.');
949 try writer.writeAll(line);
950 try writer.writeAll(crlf);
951 }
952}
953
954test readLine {
955 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n");
956 try std.testing.expectEqualStrings("first", try readLine(&reader));
957 try std.testing.expectEqualStrings("second", try readLine(&reader));
958 try std.testing.expectEqualStrings("third", try readLine(&reader));
959 try std.testing.expectError(error.EndOfStream, readLine(&reader));
960}
961
962test Reply {
963 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
964 var buf: [128]u8 = undefined;
965 const reply = try Reply.read(&reader, &buf);
966 try std.testing.expectEqual(@as(u16, 250), reply.code);
967 try std.testing.expectEqualStrings("2.0.0 Ok", reply.text);
968 try std.testing.expect(reply.isPositiveCompletion());
969}
970
971test "Reply.read multiline" {
972 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n");
973 var buf: [128]u8 = undefined;
974 const reply = try Reply.read(&reader, &buf);
975 try std.testing.expectEqual(@as(u16, 250), reply.code);
976 try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text);
977 var it = reply.lines();
978 try std.testing.expectEqualStrings("mx.example.com", it.next().?);
979 try std.testing.expectEqualStrings("PIPELINING", it.next().?);
980 try std.testing.expectEqualStrings("SIZE 1000", it.next().?);
981 try std.testing.expectEqual(@as(?[]const u8, null), it.next());
982}
983
984test "Reply.read rejects malformed replies" {
985 var buf: [128]u8 = undefined;
986 {
987 var reader: Io.Reader = .fixed("2x0 hello\r\n");
988 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
989 }
990 {
991 var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n");
992 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
993 }
994 {
995 var reader: Io.Reader = .fixed("42\r\n");
996 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf));
997 }
998}
999
1000test Command {
1001 {
1002 const cmd = try Command.parse("EHLO client.example.com");
1003 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo);
1004 }
1005 {
1006 const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024");
1007 try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path);
1008 try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params);
1009 }
1010 {
1011 // Null reverse-path and a space after the colon.
1012 const cmd = try Command.parse("MAIL FROM: <>");
1013 try std.testing.expectEqualStrings("", cmd.mail.path);
1014 }
1015 {
1016 // Obsolete source route is stripped.
1017 const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>");
1018 try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path);
1019 }
1020 {
1021 // Quoted local-parts (from postfix's address corpora) may contain
1022 // spaces and even '>' or escaped quotes.
1023 const cmd = try Command.parse("MAIL FROM:<\"foo bar\"@example.com> SIZE=9");
1024 try std.testing.expectEqualStrings("\"foo bar\"@example.com", cmd.mail.path);
1025 try std.testing.expectEqualStrings("SIZE=9", cmd.mail.params);
1026 }
1027 {
1028 const cmd = try Command.parse("RCPT TO:<\"a>b\"@example.com>");
1029 try std.testing.expectEqualStrings("\"a>b\"@example.com", cmd.rcpt.path);
1030 }
1031 {
1032 const cmd = try Command.parse("RCPT TO:<\"a\\\">b\"@example.com>");
1033 try std.testing.expectEqualStrings("\"a\\\">b\"@example.com", cmd.rcpt.path);
1034 }
1035 try std.testing.expectError(error.Syntax, Command.parse("MAIL FROM:<\"unterminated@example.com>"));
1036 {
1037 const cmd = try Command.parse("QUIT");
1038 try std.testing.expectEqual(Command.quit, cmd);
1039 }
1040 {
1041 const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw==");
1042 try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism);
1043 try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial);
1044 }
1045 {
1046 const cmd = try Command.parse("auth login");
1047 try std.testing.expectEqualStrings("login", cmd.auth.mechanism);
1048 try std.testing.expectEqualStrings("", cmd.auth.initial);
1049 }
1050 {
1051 const cmd = try Command.parse("MADE UP");
1052 try std.testing.expectEqualStrings("MADE UP", cmd.unknown);
1053 }
1054 {
1055 const cmd = try Command.parse("BDAT 1024");
1056 try std.testing.expectEqual(@as(u64, 1024), cmd.bdat.size);
1057 try std.testing.expect(!cmd.bdat.last);
1058 }
1059 {
1060 const cmd = try Command.parse("bdat 0 last");
1061 try std.testing.expectEqual(@as(u64, 0), cmd.bdat.size);
1062 try std.testing.expect(cmd.bdat.last);
1063 }
1064 try std.testing.expectError(error.Syntax, Command.parse("BDAT"));
1065 try std.testing.expectError(error.Syntax, Command.parse("BDAT nan"));
1066 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 FIRST"));
1067 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 LAST extra"));
1068 try std.testing.expectError(error.Syntax, Command.parse("AUTH"));
1069 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>"));
1070 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:"));
1071 try std.testing.expectError(error.Syntax, Command.parse("HELO"));
1072}
1073
1074test writeStuffed {
1075 var buf: [256]u8 = undefined;
1076 {
1077 var w: Io.Writer = .fixed(&buf);
1078 try writeStuffed(&w, "line one\r\n.starts with dot\r\n");
1079 try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered());
1080 }
1081 {
1082 // LF-only input is normalized, missing final newline is added.
1083 var w: Io.Writer = .fixed(&buf);
1084 try writeStuffed(&w, "a\nb");
1085 try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered());
1086 }
1087 {
1088 // A lone "." line must not become a terminator.
1089 var w: Io.Writer = .fixed(&buf);
1090 try writeStuffed(&w, ".\n");
1091 try std.testing.expectEqualStrings("..\r\n", w.buffered());
1092 }
1093 {
1094 var w: Io.Writer = .fixed(&buf);
1095 try writeStuffed(&w, "");
1096 try std.testing.expectEqualStrings("", w.buffered());
1097 }
1098}
1099
1100test "fuzz Command.parse" {
1101 try std.testing.fuzz({}, fuzzCommandParse, .{});
1102}
1103
1104fn fuzzCommandParse(context: void, smith: *std.testing.Smith) !void {
1105 _ = context;
1106 var line_buf: [512]u8 = undefined;
1107 const line = line_buf[0..smith.value(u9)];
1108 smith.bytes(line);
1109
1110 const command = Command.parse(line) catch return;
1111 // Payload slices must always lie within the parsed line.
1112 switch (command) {
1113 .helo, .ehlo, .lhlo, .vrfy, .expn, .unknown => |payload| try std.testing.expect(payload.len <= line.len),
1114 .mail, .rcpt => |args| {
1115 try std.testing.expect(args.path.len <= line.len);
1116 try std.testing.expect(args.params.len <= line.len);
1117 },
1118 .auth => |args| {
1119 try std.testing.expect(args.mechanism.len <= line.len);
1120 try std.testing.expect(args.initial.len <= line.len);
1121 },
1122 .data, .rset, .noop, .quit, .help, .starttls, .bdat => {},
1123 }
1124}
1125
1126test "fuzz Reply.read" {
1127 try std.testing.fuzz({}, fuzzReplyRead, .{});
1128}
1129
1130fn fuzzReplyRead(context: void, smith: *std.testing.Smith) !void {
1131 _ = context;
1132 var input_buf: [1024]u8 = undefined;
1133 const input = input_buf[0..smith.value(u10)];
1134 smith.bytes(input);
1135
1136 var reader: Io.Reader = .fixed(input);
1137 var text_buf: [128]u8 = undefined;
1138 // Each successful read consumes at least one line, so this terminates.
1139 while (true) {
1140 const reply = Reply.read(&reader, &text_buf) catch break;
1141 try std.testing.expect(reply.code >= 100 and reply.code <= 599);
1142 }
1143}
1144
1145test ParamIterator {
1146 const command = try Command.parse("MAIL FROM:<a@example.com> SIZE=1024 BODY=8BITMIME FLAG");
1147 var it = command.mail.paramIterator();
1148
1149 const size = it.next().?;
1150 try std.testing.expectEqualStrings("SIZE", size.keyword);
1151 try std.testing.expectEqualStrings("1024", size.value);
1152
1153 const body = it.next().?;
1154 try std.testing.expectEqualStrings("BODY", body.keyword);
1155 try std.testing.expectEqualStrings("8BITMIME", body.value);
1156
1157 const flag = it.next().?;
1158 try std.testing.expectEqualStrings("FLAG", flag.keyword);
1159 try std.testing.expectEqualStrings("", flag.value);
1160
1161 try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next());
1162}
1163
1164test crlf {
1165 try std.testing.expectEqualStrings("\r\n", crlf);
1166}
1167
1168test "RFC 5321 mailbox forms from the is_email corpus round-trip" {
1169 // Parses Dominic Sayers' is_email test suite (tests.xml and
1170 // tests-original.xml, embedded from the lazy `isemail` dependency by
1171 // `zig build test -Disemail-corpus`) and checks that every address
1172 // valid at the RFC 5321 layer passes through the lenient path parser
1173 // byte-for-byte, params intact.
1174 if (comptime @import("build_options").isemail_corpus) {
1175 const corpus = @embedFile("isemail_tests_xml") ++ "\n" ++
1176 @embedFile("isemail_tests_original_xml");
1177 var checked: usize = 0;
1178 var rest: []const u8 = corpus;
1179 while (std.mem.indexOf(u8, rest, "<test ")) |start_index| {
1180 const end_index = std.mem.indexOfPos(u8, rest, start_index, "</test>") orelse break;
1181 const block = rest[start_index..end_index];
1182 rest = rest[end_index + "</test>".len ..];
1183
1184 const category = xmlElementText(block, "category") orelse continue;
1185 if (!std.mem.eql(u8, category, "ISEMAIL_VALID_CATEGORY") and
1186 !std.mem.eql(u8, category, "ISEMAIL_RFC5321")) continue;
1187 const raw = xmlElementText(block, "address") orelse continue;
1188 var address_buf: [256]u8 = undefined;
1189 const address = try xmlUnescape(&address_buf, raw);
1190
1191 var line_buf: [300]u8 = undefined;
1192 const line = try std.fmt.bufPrint(&line_buf, "MAIL FROM:<{s}> SIZE=1", .{address});
1193 const command = try Command.parse(line);
1194 try std.testing.expectEqualStrings(address, command.mail.path);
1195 try std.testing.expectEqualStrings("SIZE=1", command.mail.params);
1196 checked += 1;
1197 }
1198 // The two files carry 125 RFC 5321-valid cases between them; fail
1199 // loudly if the extraction ever silently rots.
1200 try std.testing.expect(checked >= 120);
1201 } else return error.SkipZigTest;
1202}
1203
1204fn xmlElementText(block: []const u8, comptime tag: []const u8) ?[]const u8 {
1205 const open = "<" ++ tag ++ ">";
1206 const close = "</" ++ tag ++ ">";
1207 const start = (std.mem.indexOf(u8, block, open) orelse return null) + open.len;
1208 const end = std.mem.indexOfPos(u8, block, start, close) orelse return null;
1209 return block[start..end];
1210}
1211
1212fn xmlUnescape(buffer: []u8, input: []const u8) ![]const u8 {
1213 var out: usize = 0;
1214 var i: usize = 0;
1215 while (i < input.len) {
1216 if (input[i] != '&') {
1217 buffer[out] = input[i];
1218 out += 1;
1219 i += 1;
1220 continue;
1221 }
1222 const semi = std.mem.indexOfScalarPos(u8, input, i, ';') orelse return error.BadEntity;
1223 const entity = input[i + 1 .. semi];
1224 i = semi + 1;
1225 if (std.mem.eql(u8, entity, "amp")) {
1226 buffer[out] = '&';
1227 out += 1;
1228 } else if (std.mem.eql(u8, entity, "lt")) {
1229 buffer[out] = '<';
1230 out += 1;
1231 } else if (std.mem.eql(u8, entity, "gt")) {
1232 buffer[out] = '>';
1233 out += 1;
1234 } else if (std.mem.eql(u8, entity, "quot")) {
1235 buffer[out] = '"';
1236 out += 1;
1237 } else if (std.mem.eql(u8, entity, "apos")) {
1238 buffer[out] = '\'';
1239 out += 1;
1240 } else if (std.mem.startsWith(u8, entity, "#x") or std.mem.startsWith(u8, entity, "#X")) {
1241 const codepoint = try std.fmt.parseInt(u21, entity[2..], 16);
1242 out += try std.unicode.utf8Encode(codepoint, buffer[out..]);
1243 } else if (std.mem.startsWith(u8, entity, "#")) {
1244 const codepoint = try std.fmt.parseInt(u21, entity[1..], 10);
1245 out += try std.unicode.utf8Encode(codepoint, buffer[out..]);
1246 } else return error.BadEntity;
1247 }
1248 return buffer[0..out];
1249}