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
13 kB 340 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! Shared SMTP protocol primitives (RFC 5321): line reading, reply parsing, 5//! command parsing, and message data dot-stuffing. Used by both the client 6//! and server layers, and usable directly for custom protocol handling. 7 8const std = @import("std"); 9const Io = std.Io; 10 11pub const crlf = "\r\n"; 12 13pub const ReadLineError = error{ 14 ReadFailed, 15 EndOfStream, 16 /// The line did not fit in the reader's buffer. 17 LineTooLong, 18}; 19 20/// Reads one CRLF- (or bare LF-) terminated line, returning it without the 21/// line ending. The returned slice points into the reader's buffer and is 22/// invalidated by the next read. 23pub fn readLine(reader: *Io.Reader) ReadLineError![]u8 { 24 const line = reader.takeSentinel('\n') catch |err| switch (err) { 25 error.StreamTooLong => return error.LineTooLong, 26 error.ReadFailed, error.EndOfStream => |e| return e, 27 }; 28 if (line.len > 0 and line[line.len - 1] == '\r') return line[0 .. line.len - 1]; 29 return line; 30} 31 32/// A server reply: a 3-digit code and one or more lines of text. 33pub const Reply = struct { 34 code: u16, 35 /// Text of all reply lines joined with '\n', with codes and separators 36 /// stripped. Points into the buffer passed to `read`. 37 text: []const u8, 38 39 pub const ReadError = ReadLineError || error{ 40 InvalidReply, 41 /// The reply text did not fit in the provided buffer. 42 ReplyTooLong, 43 }; 44 45 /// Reads one (possibly multiline) reply. The text is copied into `buffer` 46 /// and the returned reply's `text` field points into it. 47 pub fn read(reader: *Io.Reader, buffer: []u8) ReadError!Reply { 48 var text: Io.Writer = .fixed(buffer); 49 var code: ?u16 = null; 50 var first = true; 51 while (true) { 52 const line = try readLine(reader); 53 if (line.len < 3) return error.InvalidReply; 54 const line_code = std.fmt.parseInt(u16, line[0..3], 10) catch 55 return error.InvalidReply; 56 if (line_code < 100 or line_code > 599) return error.InvalidReply; 57 if (code) |prev| { 58 // All lines of a multiline reply must carry the same code. 59 if (prev != line_code) return error.InvalidReply; 60 } else { 61 code = line_code; 62 } 63 var last = true; 64 var line_text: []const u8 = ""; 65 if (line.len > 3) { 66 switch (line[3]) { 67 ' ' => {}, 68 '-' => last = false, 69 else => return error.InvalidReply, 70 } 71 line_text = line[4..]; 72 } 73 if (!first) text.writeByte('\n') catch return error.ReplyTooLong; 74 text.writeAll(line_text) catch return error.ReplyTooLong; 75 first = false; 76 if (last) break; 77 } 78 return .{ .code = code.?, .text = text.buffered() }; 79 } 80 81 /// Iterates over the individual text lines of the reply. 82 pub fn lines(r: *const Reply) std.mem.SplitIterator(u8, .scalar) { 83 return std.mem.splitScalar(u8, r.text, '\n'); 84 } 85 86 // Reply classes per RFC 5321 §4.2.1. 87 pub fn isPositiveCompletion(r: Reply) bool { 88 return r.code >= 200 and r.code < 300; 89 } 90 pub fn isPositiveIntermediate(r: Reply) bool { 91 return r.code >= 300 and r.code < 400; 92 } 93 pub fn isTransientFailure(r: Reply) bool { 94 return r.code >= 400 and r.code < 500; 95 } 96 pub fn isPermanentFailure(r: Reply) bool { 97 return r.code >= 500 and r.code < 600; 98 } 99}; 100 101/// A parsed client command, as seen by a server. 102pub const Command = union(enum) { 103 helo: []const u8, 104 ehlo: []const u8, 105 /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`). 106 mail: PathArgs, 107 /// RCPT TO. 108 rcpt: PathArgs, 109 data, 110 rset, 111 noop, 112 quit, 113 vrfy: []const u8, 114 help, 115 starttls, 116 /// AUTH (RFC 4954). 117 auth: AuthArgs, 118 /// Unrecognized command verb; the payload is the full line. 119 unknown: []const u8, 120 121 pub const AuthArgs = struct { 122 mechanism: []const u8, 123 /// Raw base64 initial response, if the client sent one ("=" denotes 124 /// an empty initial response). 125 initial: []const u8 = "", 126 }; 127 128 pub const PathArgs = struct { 129 /// The mailbox, with angle brackets and any obsolete source route 130 /// stripped. 131 path: []const u8, 132 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024". 133 params: []const u8 = "", 134 }; 135 136 pub const ParseError = error{Syntax}; 137 138 /// Parses one command line (without its line ending). Returned slices 139 /// point into `line`. 140 pub fn parse(line: []const u8) ParseError!Command { 141 const trimmed = std.mem.trim(u8, line, " \t"); 142 const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len; 143 const verb = trimmed[0..verb_end]; 144 const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t"); 145 146 if (ieql(verb, "HELO")) { 147 if (rest.len == 0) return error.Syntax; 148 return .{ .helo = rest }; 149 } 150 if (ieql(verb, "EHLO")) { 151 if (rest.len == 0) return error.Syntax; 152 return .{ .ehlo = rest }; 153 } 154 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") }; 155 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") }; 156 if (ieql(verb, "DATA")) return .data; 157 if (ieql(verb, "RSET")) return .rset; 158 if (ieql(verb, "NOOP")) return .noop; 159 if (ieql(verb, "QUIT")) return .quit; 160 if (ieql(verb, "VRFY")) return .{ .vrfy = rest }; 161 if (ieql(verb, "HELP")) return .help; 162 if (ieql(verb, "STARTTLS")) return .starttls; 163 if (ieql(verb, "AUTH")) { 164 const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len; 165 if (mech_end == 0) return error.Syntax; 166 return .{ .auth = .{ 167 .mechanism = rest[0..mech_end], 168 .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"), 169 } }; 170 } 171 return .{ .unknown = line }; 172 } 173 174 fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs { 175 if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword)) 176 return error.Syntax; 177 const after = std.mem.trimStart(u8, rest[keyword.len..], " \t"); 178 if (after.len == 0 or after[0] != '<') { 179 // Lenient: accept a bare address ending at whitespace. 180 const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len; 181 if (end == 0) return error.Syntax; 182 return .{ 183 .path = after[0..end], 184 .params = std.mem.trimStart(u8, after[end..], " \t"), 185 }; 186 } 187 const close = std.mem.indexOfScalar(u8, after, '>') orelse return error.Syntax; 188 var path = after[1..close]; 189 // Strip an obsolete source route: <@relay1,@relay2:user@host>. 190 if (path.len > 0 and path[0] == '@') { 191 const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax; 192 path = path[colon + 1 ..]; 193 } 194 return .{ 195 .path = path, 196 .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"), 197 }; 198 } 199 200 fn ieql(a: []const u8, b: []const u8) bool { 201 return std.ascii.eqlIgnoreCase(a, b); 202 } 203}; 204 205/// Writes `data` as SMTP message content: line endings are normalized to CRLF 206/// and lines beginning with '.' are dot-stuffed (RFC 5321 §4.5.2). Does not 207/// write the terminating ".\r\n". 208pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void { 209 var rest = data; 210 while (rest.len > 0) { 211 var line: []const u8 = undefined; 212 if (std.mem.indexOfScalar(u8, rest, '\n')) |i| { 213 line = rest[0..i]; 214 rest = rest[i + 1 ..]; 215 } else { 216 line = rest; 217 rest = rest[rest.len..]; 218 } 219 if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; 220 if (line.len > 0 and line[0] == '.') try writer.writeByte('.'); 221 try writer.writeAll(line); 222 try writer.writeAll(crlf); 223 } 224} 225 226test readLine { 227 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n"); 228 try std.testing.expectEqualStrings("first", try readLine(&reader)); 229 try std.testing.expectEqualStrings("second", try readLine(&reader)); 230 try std.testing.expectEqualStrings("third", try readLine(&reader)); 231 try std.testing.expectError(error.EndOfStream, readLine(&reader)); 232} 233 234test Reply { 235 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 236 var buf: [128]u8 = undefined; 237 const reply = try Reply.read(&reader, &buf); 238 try std.testing.expectEqual(@as(u16, 250), reply.code); 239 try std.testing.expectEqualStrings("2.0.0 Ok", reply.text); 240 try std.testing.expect(reply.isPositiveCompletion()); 241} 242 243test "Reply.read multiline" { 244 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n"); 245 var buf: [128]u8 = undefined; 246 const reply = try Reply.read(&reader, &buf); 247 try std.testing.expectEqual(@as(u16, 250), reply.code); 248 try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text); 249 var it = reply.lines(); 250 try std.testing.expectEqualStrings("mx.example.com", it.next().?); 251 try std.testing.expectEqualStrings("PIPELINING", it.next().?); 252 try std.testing.expectEqualStrings("SIZE 1000", it.next().?); 253 try std.testing.expectEqual(@as(?[]const u8, null), it.next()); 254} 255 256test "Reply.read rejects malformed replies" { 257 var buf: [128]u8 = undefined; 258 { 259 var reader: Io.Reader = .fixed("2x0 hello\r\n"); 260 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 261 } 262 { 263 var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n"); 264 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 265 } 266 { 267 var reader: Io.Reader = .fixed("42\r\n"); 268 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 269 } 270} 271 272test Command { 273 { 274 const cmd = try Command.parse("EHLO client.example.com"); 275 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo); 276 } 277 { 278 const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024"); 279 try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path); 280 try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params); 281 } 282 { 283 // Null reverse-path and a space after the colon. 284 const cmd = try Command.parse("MAIL FROM: <>"); 285 try std.testing.expectEqualStrings("", cmd.mail.path); 286 } 287 { 288 // Obsolete source route is stripped. 289 const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>"); 290 try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path); 291 } 292 { 293 const cmd = try Command.parse("QUIT"); 294 try std.testing.expectEqual(Command.quit, cmd); 295 } 296 { 297 const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw=="); 298 try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism); 299 try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial); 300 } 301 { 302 const cmd = try Command.parse("auth login"); 303 try std.testing.expectEqualStrings("login", cmd.auth.mechanism); 304 try std.testing.expectEqualStrings("", cmd.auth.initial); 305 } 306 { 307 const cmd = try Command.parse("MADE UP"); 308 try std.testing.expectEqualStrings("MADE UP", cmd.unknown); 309 } 310 try std.testing.expectError(error.Syntax, Command.parse("AUTH")); 311 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>")); 312 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:")); 313 try std.testing.expectError(error.Syntax, Command.parse("HELO")); 314} 315 316test writeStuffed { 317 var buf: [256]u8 = undefined; 318 { 319 var w: Io.Writer = .fixed(&buf); 320 try writeStuffed(&w, "line one\r\n.starts with dot\r\n"); 321 try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered()); 322 } 323 { 324 // LF-only input is normalized, missing final newline is added. 325 var w: Io.Writer = .fixed(&buf); 326 try writeStuffed(&w, "a\nb"); 327 try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered()); 328 } 329 { 330 // A lone "." line must not become a terminator. 331 var w: Io.Writer = .fixed(&buf); 332 try writeStuffed(&w, ".\n"); 333 try std.testing.expectEqualStrings("..\r\n", w.buffered()); 334 } 335 { 336 var w: Io.Writer = .fixed(&buf); 337 try writeStuffed(&w, ""); 338 try std.testing.expectEqualStrings("", w.buffered()); 339 } 340}