An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-smtp / src / protocol.zig
50 kB 1229 lines
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}