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
19 kB 501 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 test read { 101 var reader: Io.Reader = .fixed("250-first\r\n250 second\r\n"); 102 var buffer: [64]u8 = undefined; 103 const reply = try read(&reader, &buffer); 104 try std.testing.expectEqual(@as(u16, 250), reply.code); 105 try std.testing.expectEqualStrings("first\nsecond", reply.text); 106 } 107 108 test lines { 109 const reply: Reply = .{ .code = 250, .text = "one\ntwo" }; 110 var it = reply.lines(); 111 try std.testing.expectEqualStrings("one", it.next().?); 112 try std.testing.expectEqualStrings("two", it.next().?); 113 try std.testing.expectEqual(@as(?[]const u8, null), it.next()); 114 } 115 116 test isPositiveCompletion { 117 try std.testing.expect((Reply{ .code = 250, .text = "" }).isPositiveCompletion()); 118 try std.testing.expect(!(Reply{ .code = 354, .text = "" }).isPositiveCompletion()); 119 } 120 121 test isPositiveIntermediate { 122 try std.testing.expect((Reply{ .code = 354, .text = "" }).isPositiveIntermediate()); 123 } 124 125 test isTransientFailure { 126 try std.testing.expect((Reply{ .code = 451, .text = "" }).isTransientFailure()); 127 } 128 129 test isPermanentFailure { 130 try std.testing.expect((Reply{ .code = 550, .text = "" }).isPermanentFailure()); 131 } 132}; 133 134/// A parsed client command, as seen by a server. 135pub const Command = union(enum) { 136 helo: []const u8, 137 ehlo: []const u8, 138 /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`). 139 mail: PathArgs, 140 /// RCPT TO. 141 rcpt: PathArgs, 142 data, 143 rset, 144 noop, 145 quit, 146 vrfy: []const u8, 147 help, 148 starttls, 149 /// AUTH (RFC 4954). 150 auth: AuthArgs, 151 /// Unrecognized command verb; the payload is the full line. 152 unknown: []const u8, 153 154 pub const AuthArgs = struct { 155 mechanism: []const u8, 156 /// Raw base64 initial response, if the client sent one ("=" denotes 157 /// an empty initial response). 158 initial: []const u8 = "", 159 }; 160 161 pub const PathArgs = struct { 162 /// The mailbox, with angle brackets and any obsolete source route 163 /// stripped. 164 path: []const u8, 165 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024". 166 params: []const u8 = "", 167 168 pub fn paramIterator(args: PathArgs) ParamIterator { 169 return .init(args.params); 170 } 171 172 test paramIterator { 173 const args: PathArgs = .{ .path = "a@example.com", .params = "SIZE=7" }; 174 var it = args.paramIterator(); 175 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); 176 } 177 }; 178 179 pub const ParseError = error{Syntax}; 180 181 /// Parses one command line (without its line ending). Returned slices 182 /// point into `line`. 183 pub fn parse(line: []const u8) ParseError!Command { 184 const trimmed = std.mem.trim(u8, line, " \t"); 185 const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len; 186 const verb = trimmed[0..verb_end]; 187 const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t"); 188 189 if (ieql(verb, "HELO")) { 190 if (rest.len == 0) return error.Syntax; 191 return .{ .helo = rest }; 192 } 193 if (ieql(verb, "EHLO")) { 194 if (rest.len == 0) return error.Syntax; 195 return .{ .ehlo = rest }; 196 } 197 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") }; 198 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") }; 199 if (ieql(verb, "DATA")) return .data; 200 if (ieql(verb, "RSET")) return .rset; 201 if (ieql(verb, "NOOP")) return .noop; 202 if (ieql(verb, "QUIT")) return .quit; 203 if (ieql(verb, "VRFY")) return .{ .vrfy = rest }; 204 if (ieql(verb, "HELP")) return .help; 205 if (ieql(verb, "STARTTLS")) return .starttls; 206 if (ieql(verb, "AUTH")) { 207 const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len; 208 if (mech_end == 0) return error.Syntax; 209 return .{ .auth = .{ 210 .mechanism = rest[0..mech_end], 211 .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"), 212 } }; 213 } 214 return .{ .unknown = line }; 215 } 216 217 fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs { 218 if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword)) 219 return error.Syntax; 220 const after = std.mem.trimStart(u8, rest[keyword.len..], " \t"); 221 if (after.len == 0 or after[0] != '<') { 222 // Lenient: accept a bare address ending at whitespace. 223 const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len; 224 if (end == 0) return error.Syntax; 225 return .{ 226 .path = after[0..end], 227 .params = std.mem.trimStart(u8, after[end..], " \t"), 228 }; 229 } 230 const close = std.mem.indexOfScalar(u8, after, '>') orelse return error.Syntax; 231 var path = after[1..close]; 232 // Strip an obsolete source route: <@relay1,@relay2:user@host>. 233 if (path.len > 0 and path[0] == '@') { 234 const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax; 235 path = path[colon + 1 ..]; 236 } 237 return .{ 238 .path = path, 239 .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"), 240 }; 241 } 242 243 fn ieql(a: []const u8, b: []const u8) bool { 244 return std.ascii.eqlIgnoreCase(a, b); 245 } 246 247 test parse { 248 const command = try parse("RCPT TO:<bob@example.net>"); 249 try std.testing.expectEqualStrings("bob@example.net", command.rcpt.path); 250 try std.testing.expectError(error.Syntax, parse("MAIL <missing-keyword>")); 251 } 252}; 253 254/// Iterates the ESMTP parameters of a MAIL or RCPT command 255/// (RFC 5321 §4.1.2), e.g. "SIZE=1024 BODY=8BITMIME". 256pub const ParamIterator = struct { 257 rest: []const u8, 258 259 pub const Param = struct { 260 keyword: []const u8, 261 /// Empty when the parameter carries no value. 262 value: []const u8 = "", 263 }; 264 265 pub fn init(params: []const u8) ParamIterator { 266 return .{ .rest = params }; 267 } 268 269 pub fn next(it: *ParamIterator) ?Param { 270 it.rest = std.mem.trimStart(u8, it.rest, " \t"); 271 if (it.rest.len == 0) return null; 272 const end = std.mem.indexOfAny(u8, it.rest, " \t") orelse it.rest.len; 273 const token = it.rest[0..end]; 274 it.rest = it.rest[end..]; 275 if (std.mem.indexOfScalar(u8, token, '=')) |eq| { 276 return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] }; 277 } 278 return .{ .keyword = token }; 279 } 280 281 test init { 282 var it: ParamIterator = .init("SIZE=42"); 283 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); 284 } 285 286 test next { 287 var it: ParamIterator = .init("BODY=8BITMIME CUSTOM"); 288 const body = it.next().?; 289 try std.testing.expectEqualStrings("BODY", body.keyword); 290 try std.testing.expectEqualStrings("8BITMIME", body.value); 291 const custom = it.next().?; 292 try std.testing.expectEqualStrings("CUSTOM", custom.keyword); 293 try std.testing.expectEqualStrings("", custom.value); 294 try std.testing.expectEqual(@as(?Param, null), it.next()); 295 } 296}; 297 298/// Writes `data` as SMTP message content: line endings are normalized to CRLF 299/// and lines beginning with '.' are dot-stuffed (RFC 5321 §4.5.2). Does not 300/// write the terminating ".\r\n". 301pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void { 302 var rest = data; 303 while (rest.len > 0) { 304 var line: []const u8 = undefined; 305 if (std.mem.indexOfScalar(u8, rest, '\n')) |i| { 306 line = rest[0..i]; 307 rest = rest[i + 1 ..]; 308 } else { 309 line = rest; 310 rest = rest[rest.len..]; 311 } 312 if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; 313 if (line.len > 0 and line[0] == '.') try writer.writeByte('.'); 314 try writer.writeAll(line); 315 try writer.writeAll(crlf); 316 } 317} 318 319test readLine { 320 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n"); 321 try std.testing.expectEqualStrings("first", try readLine(&reader)); 322 try std.testing.expectEqualStrings("second", try readLine(&reader)); 323 try std.testing.expectEqualStrings("third", try readLine(&reader)); 324 try std.testing.expectError(error.EndOfStream, readLine(&reader)); 325} 326 327test Reply { 328 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 329 var buf: [128]u8 = undefined; 330 const reply = try Reply.read(&reader, &buf); 331 try std.testing.expectEqual(@as(u16, 250), reply.code); 332 try std.testing.expectEqualStrings("2.0.0 Ok", reply.text); 333 try std.testing.expect(reply.isPositiveCompletion()); 334} 335 336test "Reply.read multiline" { 337 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n"); 338 var buf: [128]u8 = undefined; 339 const reply = try Reply.read(&reader, &buf); 340 try std.testing.expectEqual(@as(u16, 250), reply.code); 341 try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text); 342 var it = reply.lines(); 343 try std.testing.expectEqualStrings("mx.example.com", it.next().?); 344 try std.testing.expectEqualStrings("PIPELINING", it.next().?); 345 try std.testing.expectEqualStrings("SIZE 1000", it.next().?); 346 try std.testing.expectEqual(@as(?[]const u8, null), it.next()); 347} 348 349test "Reply.read rejects malformed replies" { 350 var buf: [128]u8 = undefined; 351 { 352 var reader: Io.Reader = .fixed("2x0 hello\r\n"); 353 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 354 } 355 { 356 var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n"); 357 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 358 } 359 { 360 var reader: Io.Reader = .fixed("42\r\n"); 361 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 362 } 363} 364 365test Command { 366 { 367 const cmd = try Command.parse("EHLO client.example.com"); 368 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo); 369 } 370 { 371 const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024"); 372 try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path); 373 try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params); 374 } 375 { 376 // Null reverse-path and a space after the colon. 377 const cmd = try Command.parse("MAIL FROM: <>"); 378 try std.testing.expectEqualStrings("", cmd.mail.path); 379 } 380 { 381 // Obsolete source route is stripped. 382 const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>"); 383 try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path); 384 } 385 { 386 const cmd = try Command.parse("QUIT"); 387 try std.testing.expectEqual(Command.quit, cmd); 388 } 389 { 390 const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw=="); 391 try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism); 392 try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial); 393 } 394 { 395 const cmd = try Command.parse("auth login"); 396 try std.testing.expectEqualStrings("login", cmd.auth.mechanism); 397 try std.testing.expectEqualStrings("", cmd.auth.initial); 398 } 399 { 400 const cmd = try Command.parse("MADE UP"); 401 try std.testing.expectEqualStrings("MADE UP", cmd.unknown); 402 } 403 try std.testing.expectError(error.Syntax, Command.parse("AUTH")); 404 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>")); 405 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:")); 406 try std.testing.expectError(error.Syntax, Command.parse("HELO")); 407} 408 409test writeStuffed { 410 var buf: [256]u8 = undefined; 411 { 412 var w: Io.Writer = .fixed(&buf); 413 try writeStuffed(&w, "line one\r\n.starts with dot\r\n"); 414 try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered()); 415 } 416 { 417 // LF-only input is normalized, missing final newline is added. 418 var w: Io.Writer = .fixed(&buf); 419 try writeStuffed(&w, "a\nb"); 420 try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered()); 421 } 422 { 423 // A lone "." line must not become a terminator. 424 var w: Io.Writer = .fixed(&buf); 425 try writeStuffed(&w, ".\n"); 426 try std.testing.expectEqualStrings("..\r\n", w.buffered()); 427 } 428 { 429 var w: Io.Writer = .fixed(&buf); 430 try writeStuffed(&w, ""); 431 try std.testing.expectEqualStrings("", w.buffered()); 432 } 433} 434 435test "fuzz Command.parse" { 436 try std.testing.fuzz({}, fuzzCommandParse, .{}); 437} 438 439fn fuzzCommandParse(context: void, smith: *std.testing.Smith) !void { 440 _ = context; 441 var line_buf: [512]u8 = undefined; 442 const line = line_buf[0..smith.value(u9)]; 443 smith.bytes(line); 444 445 const command = Command.parse(line) catch return; 446 // Payload slices must always lie within the parsed line. 447 switch (command) { 448 .helo, .ehlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len), 449 .mail, .rcpt => |args| { 450 try std.testing.expect(args.path.len <= line.len); 451 try std.testing.expect(args.params.len <= line.len); 452 }, 453 .auth => |args| { 454 try std.testing.expect(args.mechanism.len <= line.len); 455 try std.testing.expect(args.initial.len <= line.len); 456 }, 457 .data, .rset, .noop, .quit, .help, .starttls => {}, 458 } 459} 460 461test "fuzz Reply.read" { 462 try std.testing.fuzz({}, fuzzReplyRead, .{}); 463} 464 465fn fuzzReplyRead(context: void, smith: *std.testing.Smith) !void { 466 _ = context; 467 var input_buf: [1024]u8 = undefined; 468 const input = input_buf[0..smith.value(u10)]; 469 smith.bytes(input); 470 471 var reader: Io.Reader = .fixed(input); 472 var text_buf: [128]u8 = undefined; 473 // Each successful read consumes at least one line, so this terminates. 474 while (true) { 475 const reply = Reply.read(&reader, &text_buf) catch break; 476 try std.testing.expect(reply.code >= 100 and reply.code <= 599); 477 } 478} 479 480test ParamIterator { 481 const command = try Command.parse("MAIL FROM:<a@example.com> SIZE=1024 BODY=8BITMIME FLAG"); 482 var it = command.mail.paramIterator(); 483 484 const size = it.next().?; 485 try std.testing.expectEqualStrings("SIZE", size.keyword); 486 try std.testing.expectEqualStrings("1024", size.value); 487 488 const body = it.next().?; 489 try std.testing.expectEqualStrings("BODY", body.keyword); 490 try std.testing.expectEqualStrings("8BITMIME", body.value); 491 492 const flag = it.next().?; 493 try std.testing.expectEqualStrings("FLAG", flag.keyword); 494 try std.testing.expectEqualStrings("", flag.value); 495 496 try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next()); 497} 498 499test crlf { 500 try std.testing.expectEqualStrings("\r\n", crlf); 501}