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