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