// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Shared SMTP protocol primitives (RFC 5321): 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"; 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. 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; } }; /// A parsed client command, as seen by a server. pub const Command = union(enum) { helo: []const u8, ehlo: []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). auth: AuthArgs, /// Unrecognized command verb; the payload is the full line. unknown: []const u8, 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 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, "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, "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"), }; } const close = std.mem.indexOfScalar(u8, after, '>') orelse 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); } }; /// Writes `data` as SMTP message content: line endings are normalized to CRLF /// and lines beginning with '.' are dot-stuffed (RFC 5321 §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 strips CRLF and LF" { 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.read single line" { 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.parse" { { 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); } { 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); } 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()); } }