// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Shared SMTP protocol primitives ([RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321)): //! line reading, reply parsing, //! command parsing, and message data dot-stuffing. Used by both the client //! and server layers, and usable directly for custom protocol handling. const std = @import("std"); const Io = std.Io; pub const crlf = "\r\n"; /// Bytes that may never appear in a command argument. /// /// CR and LF end the command line, so a value carrying either one lets /// whatever follows it be read by the server as further SMTP commands — an /// address of `a@b>\r\nRCPT TO:\r\nRCPT TO:")); try std.testing.expect(!isSafeArgument("alice\x00root")); } pub const ReadLineError = error{ ReadFailed, EndOfStream, /// The line did not fit in the reader's buffer. LineTooLong, }; /// Reads one CRLF- (or bare LF-) terminated line, returning it without the /// line ending. The returned slice points into the reader's buffer and is /// invalidated by the next read. pub fn readLine(reader: *Io.Reader) ReadLineError![]u8 { const line = reader.takeSentinel('\n') catch |err| switch (err) { error.StreamTooLong => return error.LineTooLong, error.ReadFailed, error.EndOfStream => |e| return e, }; if (line.len > 0 and line[line.len - 1] == '\r') return line[0 .. line.len - 1]; return line; } /// A server reply: a 3-digit code and one or more lines of text. pub const Reply = struct { code: u16, /// Text of all reply lines joined with '\n', with codes and separators /// stripped. Points into the buffer passed to `read`. text: []const u8, pub const ReadError = ReadLineError || error{ InvalidReply, /// The reply text did not fit in the provided buffer. ReplyTooLong, }; /// Reads one (possibly multiline) reply. The text is copied into `buffer` /// and the returned reply's `text` field points into it. pub fn read(reader: *Io.Reader, buffer: []u8) ReadError!Reply { var text: Io.Writer = .fixed(buffer); var code: ?u16 = null; var first = true; while (true) { const line = try readLine(reader); if (line.len < 3) return error.InvalidReply; const line_code = std.fmt.parseInt(u16, line[0..3], 10) catch return error.InvalidReply; if (line_code < 100 or line_code > 599) return error.InvalidReply; if (code) |prev| { // All lines of a multiline reply must carry the same code. if (prev != line_code) return error.InvalidReply; } else { code = line_code; } var last = true; var line_text: []const u8 = ""; if (line.len > 3) { switch (line[3]) { ' ' => {}, '-' => last = false, else => return error.InvalidReply, } line_text = line[4..]; } if (!first) text.writeByte('\n') catch return error.ReplyTooLong; text.writeAll(line_text) catch return error.ReplyTooLong; first = false; if (last) break; } return .{ .code = code.?, .text = text.buffered() }; } /// Iterates over the individual text lines of the reply. pub fn lines(r: *const Reply) std.mem.SplitIterator(u8, .scalar) { return std.mem.splitScalar(u8, r.text, '\n'); } // Reply classes per RFC 5321 §4.2.1 // (https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.1). pub fn isPositiveCompletion(r: Reply) bool { return r.code >= 200 and r.code < 300; } pub fn isPositiveIntermediate(r: Reply) bool { return r.code >= 300 and r.code < 400; } pub fn isTransientFailure(r: Reply) bool { return r.code >= 400 and r.code < 500; } pub fn isPermanentFailure(r: Reply) bool { return r.code >= 500 and r.code < 600; } test read { var reader: Io.Reader = .fixed("250-first\r\n250 second\r\n"); var buffer: [64]u8 = undefined; const reply = try read(&reader, &buffer); try std.testing.expectEqual(@as(u16, 250), reply.code); try std.testing.expectEqualStrings("first\nsecond", reply.text); } test lines { const reply: Reply = .{ .code = 250, .text = "one\ntwo" }; var it = reply.lines(); try std.testing.expectEqualStrings("one", it.next().?); try std.testing.expectEqualStrings("two", it.next().?); try std.testing.expectEqual(@as(?[]const u8, null), it.next()); } test isPositiveCompletion { try std.testing.expect((Reply{ .code = 250, .text = "" }).isPositiveCompletion()); try std.testing.expect(!(Reply{ .code = 354, .text = "" }).isPositiveCompletion()); } test isPositiveIntermediate { try std.testing.expect((Reply{ .code = 354, .text = "" }).isPositiveIntermediate()); } test isTransientFailure { try std.testing.expect((Reply{ .code = 451, .text = "" }).isTransientFailure()); } test isPermanentFailure { try std.testing.expect((Reply{ .code = 550, .text = "" }).isPermanentFailure()); } }; /// A parsed client command, as seen by a server. pub const Command = union(enum) { helo: []const u8, ehlo: []const u8, /// LHLO, the LMTP greeting /// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)), which /// has the same semantics as EHLO. An LMTP server takes this one and /// refuses HELO and EHLO; an SMTP server does the reverse. lhlo: []const u8, /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`). mail: PathArgs, /// RCPT TO. rcpt: PathArgs, data, rset, noop, quit, vrfy: []const u8, help, starttls, /// AUTH ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). auth: AuthArgs, /// BDAT, the CHUNKING extension /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). The /// command line is followed by exactly `size` raw octets. bdat: BdatArgs, /// Unrecognized command verb; the payload is the full line. unknown: []const u8, pub const BdatArgs = struct { size: u64, /// True for the final chunk of the message ("BDAT n LAST"). last: bool = false, }; pub const AuthArgs = struct { mechanism: []const u8, /// Raw base64 initial response, if the client sent one ("=" denotes /// an empty initial response). initial: []const u8 = "", }; pub const PathArgs = struct { /// The mailbox, with angle brackets and any obsolete source route /// stripped. path: []const u8, /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024". params: []const u8 = "", pub fn paramIterator(args: PathArgs) ParamIterator { return .init(args.params); } test paramIterator { const args: PathArgs = .{ .path = "a@example.com", .params = "SIZE=7" }; var it = args.paramIterator(); try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); } }; pub const ParseError = error{Syntax}; /// Parses one command line (without its line ending). Returned slices /// point into `line`. pub fn parse(line: []const u8) ParseError!Command { const trimmed = std.mem.trim(u8, line, " \t"); const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len; const verb = trimmed[0..verb_end]; const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t"); if (ieql(verb, "HELO")) { if (rest.len == 0) return error.Syntax; return .{ .helo = rest }; } if (ieql(verb, "EHLO")) { if (rest.len == 0) return error.Syntax; return .{ .ehlo = rest }; } if (ieql(verb, "LHLO")) { if (rest.len == 0) return error.Syntax; return .{ .lhlo = rest }; } if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") }; if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") }; if (ieql(verb, "DATA")) return .data; if (ieql(verb, "RSET")) return .rset; if (ieql(verb, "NOOP")) return .noop; if (ieql(verb, "QUIT")) return .quit; if (ieql(verb, "VRFY")) return .{ .vrfy = rest }; if (ieql(verb, "HELP")) return .help; if (ieql(verb, "STARTTLS")) return .starttls; if (ieql(verb, "BDAT")) { var it = std.mem.tokenizeAny(u8, rest, " \t"); const size_token = it.next() orelse return error.Syntax; const size = std.fmt.parseInt(u64, size_token, 10) catch return error.Syntax; var last = false; if (it.next()) |token| { if (!ieql(token, "LAST")) return error.Syntax; last = true; } if (it.next() != null) return error.Syntax; return .{ .bdat = .{ .size = size, .last = last } }; } if (ieql(verb, "AUTH")) { const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len; if (mech_end == 0) return error.Syntax; return .{ .auth = .{ .mechanism = rest[0..mech_end], .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"), } }; } return .{ .unknown = line }; } fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs { if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword)) return error.Syntax; const after = std.mem.trimStart(u8, rest[keyword.len..], " \t"); if (after.len == 0 or after[0] != '<') { // Lenient: accept a bare address ending at whitespace. const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len; if (end == 0) return error.Syntax; return .{ .path = after[0..end], .params = std.mem.trimStart(u8, after[end..], " \t"), }; } // The closing bracket must be found outside any quoted local-part: // <"a>b"@example.com> is legal (RFC 5321 quoted-string, with // backslash escapes). const close = close: { var in_quotes = false; var i: usize = 1; while (i < after.len) : (i += 1) { const byte = after[i]; if (in_quotes) { if (byte == '\\') { i += 1; } else if (byte == '"') { in_quotes = false; } } else if (byte == '"') { in_quotes = true; } else if (byte == '>') { break :close i; } } return error.Syntax; }; var path = after[1..close]; // Strip an obsolete source route: <@relay1,@relay2:user@host>. if (path.len > 0 and path[0] == '@') { const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax; path = path[colon + 1 ..]; } return .{ .path = path, .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"), }; } fn ieql(a: []const u8, b: []const u8) bool { return std.ascii.eqlIgnoreCase(a, b); } test parse { const command = try parse("RCPT TO:"); try std.testing.expectEqualStrings("bob@example.net", command.rcpt.path); try std.testing.expectError(error.Syntax, parse("MAIL ")); } }; /// The `RET` parameter of an extended MAIL command /// ([RFC 3461 §4.3](https://datatracker.ietf.org/doc/html/rfc3461#section-4.3)): /// how much of the message a failed DSN should carry back. Absent, the /// choice is the reporting MTA's. /// The `BODY` parameter of an extended MAIL command: what kind of content /// the message carries, and so what the receiver has to be able to take. pub const Body = enum { /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). Lines of /// at most 998 characters from the ASCII repertoire. seven_bit, /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). The same /// line structure, with the high bit allowed. eight_bit_mime, /// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030). Arbitrary /// octets with no line structure at all, which is why it can only be /// carried by BDAT: DATA has no way to frame content that may hold the /// terminator itself. binary_mime, pub const ParseError = error{Syntax}; pub fn parse(value: []const u8) ParseError!Body { if (std.ascii.eqlIgnoreCase(value, "7BIT")) return .seven_bit; if (std.ascii.eqlIgnoreCase(value, "8BITMIME")) return .eight_bit_mime; if (std.ascii.eqlIgnoreCase(value, "BINARYMIME")) return .binary_mime; return error.Syntax; } /// Writes the value as it appears on the wire. pub fn format(b: Body, writer: *Io.Writer) Io.Writer.Error!void { try writer.writeAll(switch (b) { .seven_bit => "7BIT", .eight_bit_mime => "8BITMIME", .binary_mime => "BINARYMIME", }); } test parse { try std.testing.expectEqual(Body.binary_mime, try parse("binarymime")); try std.testing.expectEqual(Body.seven_bit, try parse("7BIT")); try std.testing.expectError(error.Syntax, parse("BINARY")); } }; /// RFC 3461 §4.4 caps the `ENVID` parameter value at 100 characters, which /// is a limit on the xtext-encoded form and not on what went into it. pub const max_envid_len = 100; pub const Ret = enum { /// Return the entire message. full, /// Return the headers only. hdrs, pub const ParseError = error{Syntax}; pub fn parse(value: []const u8) ParseError!Ret { if (std.ascii.eqlIgnoreCase(value, "FULL")) return .full; if (std.ascii.eqlIgnoreCase(value, "HDRS")) return .hdrs; return error.Syntax; } /// Writes the value as it appears on the wire. pub fn format(r: Ret, writer: *Io.Writer) Io.Writer.Error!void { try writer.writeAll(switch (r) { .full => "FULL", .hdrs => "HDRS", }); } test parse { try std.testing.expectEqual(Ret.hdrs, try parse("hdrs")); try std.testing.expectError(error.Syntax, parse("PARTIAL")); } }; /// The `NOTIFY` parameter of an extended RCPT command /// ([RFC 3461 §4.1](https://datatracker.ietf.org/doc/html/rfc3461#section-4.1)): /// the conditions under which the sender wants to hear about this /// recipient. Absent, RFC 3461 lets a server read it as either /// `FAILURE` or `FAILURE,DELAY` — which is why "not specified" is an /// absent `?Notify` here and not a value of it. pub const Notify = union(enum) { /// `NOTIFY=NEVER`: no DSN for this recipient under any circumstance. /// RFC 3461 requires the keyword to appear on its own, and parsing /// rejects it in a list. never, /// One or more of `SUCCESS`, `FAILURE` and `DELAY`. on: Conditions, pub const Conditions = struct { success: bool = false, failure: bool = false, delay: bool = false, }; pub const ParseError = error{Syntax}; pub fn parse(value: []const u8) ParseError!Notify { if (std.ascii.eqlIgnoreCase(value, "NEVER")) return .never; var conditions: Conditions = .{}; var it = std.mem.splitScalar(u8, value, ','); var any = false; while (it.next()) |keyword| { if (std.ascii.eqlIgnoreCase(keyword, "SUCCESS")) { conditions.success = true; } else if (std.ascii.eqlIgnoreCase(keyword, "FAILURE")) { conditions.failure = true; } else if (std.ascii.eqlIgnoreCase(keyword, "DELAY")) { conditions.delay = true; } else return error.Syntax; // Including NEVER: it may not be listed. any = true; } if (!any) return error.Syntax; return .{ .on = conditions }; } /// Writes the value as it appears on the wire. pub fn format(n: Notify, writer: *Io.Writer) Io.Writer.Error!void { switch (n) { .never => try writer.writeAll("NEVER"), .on => |conditions| { var written = false; inline for (.{ .{ conditions.success, "SUCCESS" }, .{ conditions.failure, "FAILURE" }, .{ conditions.delay, "DELAY" }, }) |pair| { if (pair[0]) { if (written) try writer.writeByte(','); try writer.writeAll(pair[1]); written = true; } } // An empty condition set has no legal spelling; NEVER is // what "tell me nothing" is written as. if (!written) try writer.writeAll("NEVER"); }, } } test parse { try std.testing.expectEqual(Notify.never, try parse("NEVER")); const both = try parse("SUCCESS,delay"); try std.testing.expect(both.on.success and both.on.delay and !both.on.failure); try std.testing.expectError(error.Syntax, parse("NEVER,SUCCESS")); try std.testing.expectError(error.Syntax, parse("")); try std.testing.expectError(error.Syntax, parse("SUCCESS,MAYBE")); } }; /// The `ORCPT` parameter of an extended RCPT command /// ([RFC 3461 §4.2](https://datatracker.ietf.org/doc/html/rfc3461#section-4.2)): /// the address the message was originally addressed to, carried unchanged /// through aliasing and forwarding so that a DSN can name what the sender /// actually wrote. pub const Orcpt = struct { /// The address type, an atom — `rfc822` in all but the unusual cases. addr_type: []const u8, /// The original recipient, xtext-decoded. address: []const u8, /// RFC 3461 §4.2 caps the whole parameter value at 500 characters. pub const max_len = 500; pub const ParseError = error{Syntax}; /// Parses `addr-type ";" xtext`, decoding the address into `buffer`. /// The returned `addr_type` points into `value` and `address` points /// into `buffer`, so the two have different lifetimes; a caller keeping /// the result past either one copies both. pub fn parse(buffer: []u8, value: []const u8) ParseError!Orcpt { const semicolon = std.mem.findScalar(u8, value, ';') orelse return error.Syntax; const addr_type = value[0..semicolon]; if (addr_type.len == 0) return error.Syntax; for (addr_type) |byte| if (!isAtomByte(byte)) return error.Syntax; return .{ .addr_type = addr_type, .address = xtextDecode(buffer, value[semicolon + 1 ..]) catch return error.Syntax, }; } /// Writes the parameter value as it appears on the wire, xtext-encoding /// the address. pub fn format(o: Orcpt, writer: *Io.Writer) Io.Writer.Error!void { try writer.writeAll(o.addr_type); try writer.writeByte(';'); try writeXtext(writer, o.address); } /// RFC 5321 `atom` less the specials, which is what an addr-type may be. fn isAtomByte(byte: u8) bool { return switch (byte) { 'A'...'Z', 'a'...'z', '0'...'9' => true, '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?' => true, '^', '_', '`', '{', '|', '}', '~' => true, else => false, }; } test parse { var buffer: [64]u8 = undefined; const orcpt = try parse(&buffer, "rfc822;bob+2Bx@example.net"); try std.testing.expectEqualStrings("rfc822", orcpt.addr_type); try std.testing.expectEqualStrings("bob+x@example.net", orcpt.address); try std.testing.expectError(error.Syntax, parse(&buffer, "bob@example.net")); try std.testing.expectError(error.Syntax, parse(&buffer, ";bob@example.net")); } }; /// Whether `byte` may appear in an xtext unencoded /// ([RFC 3461 §4](https://datatracker.ietf.org/doc/html/rfc3461#section-4)): /// printable US-ASCII other than `+`, which introduces an escape, and `=`, /// which separates an ESMTP keyword from its value. pub fn isXchar(byte: u8) bool { return byte >= '!' and byte <= '~' and byte != '+' and byte != '='; } /// Writes `text` xtext-encoded: anything that is not an `xchar` becomes /// `+` and two upper-case hex digits. Every byte therefore survives, /// including the ones that would otherwise end the command line, so an /// xtext-encoded parameter is safe to write from untrusted input. /// /// RFC 3461 asks that the value before encoding be printable US-ASCII. /// That is the caller's to observe; encoding anything else here produces /// valid xtext regardless rather than a corrupt command. pub fn writeXtext(writer: *Io.Writer, text: []const u8) Io.Writer.Error!void { for (text) |byte| { if (isXchar(byte)) { try writer.writeByte(byte); } else { try writer.print("+{X:0>2}", .{byte}); } } } /// The length `writeXtext` will produce for `text`, for checking a value /// against the length limits RFC 3461 puts on the encoded form. pub fn xtextEncodedLen(text: []const u8) usize { var len: usize = 0; for (text) |byte| len += if (isXchar(byte)) 1 else 3; return len; } pub const XtextError = error{ /// Not valid xtext: a `+` not followed by two hex digits, or a raw byte /// that the encoder was required to escape. BadXtext, NoSpaceLeft, }; /// Decodes xtext into `buffer`, returning the decoded bytes. Decoding is /// strict: a byte an encoder was obliged to escape is rejected rather than /// passed through, since accepting it would let two different encodings /// mean the same thing. pub fn xtextDecode(buffer: []u8, text: []const u8) XtextError![]u8 { var out: usize = 0; var i: usize = 0; while (i < text.len) { const byte = text[i]; if (byte == '+') { if (i + 2 >= text.len) return error.BadXtext; const hex = text[i + 1 ..][0..2]; // Checked before parsing because `parseInt` also accepts a sign // and underscore separators, which hex digits are not. Lower // case is accepted on the way in even though RFC 3461 requires // upper case on the way out. for (hex) |digit| if (!std.ascii.isHex(digit)) return error.BadXtext; const value = std.fmt.parseInt(u8, hex, 16) catch return error.BadXtext; if (out >= buffer.len) return error.NoSpaceLeft; buffer[out] = value; out += 1; i += 3; } else { if (!isXchar(byte)) return error.BadXtext; if (out >= buffer.len) return error.NoSpaceLeft; buffer[out] = byte; out += 1; i += 1; } } return buffer[0..out]; } test xtextDecode { var buffer: [64]u8 = undefined; try std.testing.expectEqualStrings( "a+b=c", try xtextDecode(&buffer, "a+2Bb+3Dc"), ); try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+2")); try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+ZZb")); // A raw '=' or ' ' is what the encoder had to escape. try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a=b")); try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a b")); } test writeXtext { var out_buf: [64]u8 = undefined; var writer: Io.Writer = .fixed(&out_buf); try writeXtext(&writer, "id+1=2 \r\n"); try std.testing.expectEqualStrings("id+2B1+3D2+20+0D+0A", writer.buffered()); try std.testing.expectEqual(writer.buffered().len, xtextEncodedLen("id+1=2 \r\n")); // Every byte survives the round trip. var raw: [256]u8 = undefined; for (&raw, 0..) |*byte, i| byte.* = @intCast(i); var round_buf: [1024]u8 = undefined; var round: Io.Writer = .fixed(&round_buf); try writeXtext(&round, &raw); var decoded_buf: [256]u8 = undefined; try std.testing.expectEqualSlices(u8, &raw, try xtextDecode(&decoded_buf, round.buffered())); } /// Iterates the ESMTP parameters of a MAIL or RCPT command /// ([RFC 5321 §4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2)), /// e.g. "SIZE=1024 BODY=8BITMIME". pub const ParamIterator = struct { rest: []const u8, pub const Param = struct { keyword: []const u8, /// Empty when the parameter carries no value. value: []const u8 = "", }; pub fn init(params: []const u8) ParamIterator { return .{ .rest = params }; } pub fn next(it: *ParamIterator) ?Param { it.rest = std.mem.trimStart(u8, it.rest, " \t"); if (it.rest.len == 0) return null; const end = std.mem.indexOfAny(u8, it.rest, " \t") orelse it.rest.len; const token = it.rest[0..end]; it.rest = it.rest[end..]; if (std.mem.indexOfScalar(u8, token, '=')) |eq| { return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] }; } return .{ .keyword = token }; } test init { var it: ParamIterator = .init("SIZE=42"); try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); } test next { var it: ParamIterator = .init("BODY=8BITMIME CUSTOM"); const body = it.next().?; try std.testing.expectEqualStrings("BODY", body.keyword); try std.testing.expectEqualStrings("8BITMIME", body.value); const custom = it.next().?; try std.testing.expectEqualStrings("CUSTOM", custom.keyword); try std.testing.expectEqualStrings("", custom.value); try std.testing.expectEqual(@as(?Param, null), it.next()); } }; /// Writes `data` as SMTP message content: line endings are normalized to CRLF /// and lines beginning with '.' are dot-stuffed /// ([RFC 5321 §4.5.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.2)). Does not /// write the terminating ".\r\n". pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void { var rest = data; while (rest.len > 0) { var line: []const u8 = undefined; if (std.mem.indexOfScalar(u8, rest, '\n')) |i| { line = rest[0..i]; rest = rest[i + 1 ..]; } else { line = rest; rest = rest[rest.len..]; } if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; if (line.len > 0 and line[0] == '.') try writer.writeByte('.'); try writer.writeAll(line); try writer.writeAll(crlf); } } test readLine { var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n"); try std.testing.expectEqualStrings("first", try readLine(&reader)); try std.testing.expectEqualStrings("second", try readLine(&reader)); try std.testing.expectEqualStrings("third", try readLine(&reader)); try std.testing.expectError(error.EndOfStream, readLine(&reader)); } test Reply { var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); var buf: [128]u8 = undefined; const reply = try Reply.read(&reader, &buf); try std.testing.expectEqual(@as(u16, 250), reply.code); try std.testing.expectEqualStrings("2.0.0 Ok", reply.text); try std.testing.expect(reply.isPositiveCompletion()); } test "Reply.read multiline" { var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n"); var buf: [128]u8 = undefined; const reply = try Reply.read(&reader, &buf); try std.testing.expectEqual(@as(u16, 250), reply.code); try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text); var it = reply.lines(); try std.testing.expectEqualStrings("mx.example.com", it.next().?); try std.testing.expectEqualStrings("PIPELINING", it.next().?); try std.testing.expectEqualStrings("SIZE 1000", it.next().?); try std.testing.expectEqual(@as(?[]const u8, null), it.next()); } test "Reply.read rejects malformed replies" { var buf: [128]u8 = undefined; { var reader: Io.Reader = .fixed("2x0 hello\r\n"); try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); } { var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n"); try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); } { var reader: Io.Reader = .fixed("42\r\n"); try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); } } test Command { { const cmd = try Command.parse("EHLO client.example.com"); try std.testing.expectEqualStrings("client.example.com", cmd.ehlo); } { const cmd = try Command.parse("mail from: SIZE=1024"); try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path); try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params); } { // Null reverse-path and a space after the colon. const cmd = try Command.parse("MAIL FROM: <>"); try std.testing.expectEqualStrings("", cmd.mail.path); } { // Obsolete source route is stripped. const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>"); try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path); } { // Quoted local-parts (from postfix's address corpora) may contain // spaces and even '>' or escaped quotes. const cmd = try Command.parse("MAIL FROM:<\"foo bar\"@example.com> SIZE=9"); try std.testing.expectEqualStrings("\"foo bar\"@example.com", cmd.mail.path); try std.testing.expectEqualStrings("SIZE=9", cmd.mail.params); } { const cmd = try Command.parse("RCPT TO:<\"a>b\"@example.com>"); try std.testing.expectEqualStrings("\"a>b\"@example.com", cmd.rcpt.path); } { const cmd = try Command.parse("RCPT TO:<\"a\\\">b\"@example.com>"); try std.testing.expectEqualStrings("\"a\\\">b\"@example.com", cmd.rcpt.path); } try std.testing.expectError(error.Syntax, Command.parse("MAIL FROM:<\"unterminated@example.com>")); { const cmd = try Command.parse("QUIT"); try std.testing.expectEqual(Command.quit, cmd); } { const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw=="); try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism); try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial); } { const cmd = try Command.parse("auth login"); try std.testing.expectEqualStrings("login", cmd.auth.mechanism); try std.testing.expectEqualStrings("", cmd.auth.initial); } { const cmd = try Command.parse("MADE UP"); try std.testing.expectEqualStrings("MADE UP", cmd.unknown); } { const cmd = try Command.parse("BDAT 1024"); try std.testing.expectEqual(@as(u64, 1024), cmd.bdat.size); try std.testing.expect(!cmd.bdat.last); } { const cmd = try Command.parse("bdat 0 last"); try std.testing.expectEqual(@as(u64, 0), cmd.bdat.size); try std.testing.expect(cmd.bdat.last); } try std.testing.expectError(error.Syntax, Command.parse("BDAT")); try std.testing.expectError(error.Syntax, Command.parse("BDAT nan")); try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 FIRST")); try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 LAST extra")); try std.testing.expectError(error.Syntax, Command.parse("AUTH")); try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:")); try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:")); try std.testing.expectError(error.Syntax, Command.parse("HELO")); } test writeStuffed { var buf: [256]u8 = undefined; { var w: Io.Writer = .fixed(&buf); try writeStuffed(&w, "line one\r\n.starts with dot\r\n"); try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered()); } { // LF-only input is normalized, missing final newline is added. var w: Io.Writer = .fixed(&buf); try writeStuffed(&w, "a\nb"); try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered()); } { // A lone "." line must not become a terminator. var w: Io.Writer = .fixed(&buf); try writeStuffed(&w, ".\n"); try std.testing.expectEqualStrings("..\r\n", w.buffered()); } { var w: Io.Writer = .fixed(&buf); try writeStuffed(&w, ""); try std.testing.expectEqualStrings("", w.buffered()); } } test "fuzz Command.parse" { try std.testing.fuzz({}, fuzzCommandParse, .{}); } fn fuzzCommandParse(context: void, smith: *std.testing.Smith) !void { _ = context; var line_buf: [512]u8 = undefined; const line = line_buf[0..smith.value(u9)]; smith.bytes(line); const command = Command.parse(line) catch return; // Payload slices must always lie within the parsed line. switch (command) { .helo, .ehlo, .lhlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len), .mail, .rcpt => |args| { try std.testing.expect(args.path.len <= line.len); try std.testing.expect(args.params.len <= line.len); }, .auth => |args| { try std.testing.expect(args.mechanism.len <= line.len); try std.testing.expect(args.initial.len <= line.len); }, .data, .rset, .noop, .quit, .help, .starttls, .bdat => {}, } } test "fuzz Reply.read" { try std.testing.fuzz({}, fuzzReplyRead, .{}); } fn fuzzReplyRead(context: void, smith: *std.testing.Smith) !void { _ = context; var input_buf: [1024]u8 = undefined; const input = input_buf[0..smith.value(u10)]; smith.bytes(input); var reader: Io.Reader = .fixed(input); var text_buf: [128]u8 = undefined; // Each successful read consumes at least one line, so this terminates. while (true) { const reply = Reply.read(&reader, &text_buf) catch break; try std.testing.expect(reply.code >= 100 and reply.code <= 599); } } test ParamIterator { const command = try Command.parse("MAIL FROM: SIZE=1024 BODY=8BITMIME FLAG"); var it = command.mail.paramIterator(); const size = it.next().?; try std.testing.expectEqualStrings("SIZE", size.keyword); try std.testing.expectEqualStrings("1024", size.value); const body = it.next().?; try std.testing.expectEqualStrings("BODY", body.keyword); try std.testing.expectEqualStrings("8BITMIME", body.value); const flag = it.next().?; try std.testing.expectEqualStrings("FLAG", flag.keyword); try std.testing.expectEqualStrings("", flag.value); try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next()); } test crlf { try std.testing.expectEqualStrings("\r\n", crlf); } test "RFC 5321 mailbox forms from the is_email corpus round-trip" { // Parses Dominic Sayers' is_email test suite (tests.xml and // tests-original.xml, embedded from the lazy `isemail` dependency by // `zig build test -Disemail-corpus`) and checks that every address // valid at the RFC 5321 layer passes through the lenient path parser // byte-for-byte, params intact. if (comptime @import("build_options").isemail_corpus) { const corpus = @embedFile("isemail_tests_xml") ++ "\n" ++ @embedFile("isemail_tests_original_xml"); var checked: usize = 0; var rest: []const u8 = corpus; while (std.mem.indexOf(u8, rest, "") orelse break; const block = rest[start_index..end_index]; rest = rest[end_index + "".len ..]; const category = xmlElementText(block, "category") orelse continue; if (!std.mem.eql(u8, category, "ISEMAIL_VALID_CATEGORY") and !std.mem.eql(u8, category, "ISEMAIL_RFC5321")) continue; const raw = xmlElementText(block, "address") orelse continue; var address_buf: [256]u8 = undefined; const address = try xmlUnescape(&address_buf, raw); var line_buf: [300]u8 = undefined; const line = try std.fmt.bufPrint(&line_buf, "MAIL FROM:<{s}> SIZE=1", .{address}); const command = try Command.parse(line); try std.testing.expectEqualStrings(address, command.mail.path); try std.testing.expectEqualStrings("SIZE=1", command.mail.params); checked += 1; } // The two files carry 125 RFC 5321-valid cases between them; fail // loudly if the extraction ever silently rots. try std.testing.expect(checked >= 120); } else return error.SkipZigTest; } fn xmlElementText(block: []const u8, comptime tag: []const u8) ?[]const u8 { const open = "<" ++ tag ++ ">"; const close = ""; const start = (std.mem.indexOf(u8, block, open) orelse return null) + open.len; const end = std.mem.indexOfPos(u8, block, start, close) orelse return null; return block[start..end]; } fn xmlUnescape(buffer: []u8, input: []const u8) ![]const u8 { var out: usize = 0; var i: usize = 0; while (i < input.len) { if (input[i] != '&') { buffer[out] = input[i]; out += 1; i += 1; continue; } const semi = std.mem.indexOfScalarPos(u8, input, i, ';') orelse return error.BadEntity; const entity = input[i + 1 .. semi]; i = semi + 1; if (std.mem.eql(u8, entity, "amp")) { buffer[out] = '&'; out += 1; } else if (std.mem.eql(u8, entity, "lt")) { buffer[out] = '<'; out += 1; } else if (std.mem.eql(u8, entity, "gt")) { buffer[out] = '>'; out += 1; } else if (std.mem.eql(u8, entity, "quot")) { buffer[out] = '"'; out += 1; } else if (std.mem.eql(u8, entity, "apos")) { buffer[out] = '\''; out += 1; } else if (std.mem.startsWith(u8, entity, "#x") or std.mem.startsWith(u8, entity, "#X")) { const codepoint = try std.fmt.parseInt(u21, entity[2..], 16); out += try std.unicode.utf8Encode(codepoint, buffer[out..]); } else if (std.mem.startsWith(u8, entity, "#")) { const codepoint = try std.fmt.parseInt(u21, entity[1..], 10); out += try std.unicode.utf8Encode(codepoint, buffer[out..]); } else return error.BadEntity; } return buffer[0..out]; }