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
22 kB 578 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 14pub const ReadLineError = error{ 15 ReadFailed, 16 EndOfStream, 17 /// The line did not fit in the reader's buffer. 18 LineTooLong, 19}; 20 21/// Reads one CRLF- (or bare LF-) terminated line, returning it without the 22/// line ending. The returned slice points into the reader's buffer and is 23/// invalidated by the next read. 24pub fn readLine(reader: *Io.Reader) ReadLineError![]u8 { 25 const line = reader.takeSentinel('\n') catch |err| switch (err) { 26 error.StreamTooLong => return error.LineTooLong, 27 error.ReadFailed, error.EndOfStream => |e| return e, 28 }; 29 if (line.len > 0 and line[line.len - 1] == '\r') return line[0 .. line.len - 1]; 30 return line; 31} 32 33/// A server reply: a 3-digit code and one or more lines of text. 34pub const Reply = struct { 35 code: u16, 36 /// Text of all reply lines joined with '\n', with codes and separators 37 /// stripped. Points into the buffer passed to `read`. 38 text: []const u8, 39 40 pub const ReadError = ReadLineError || error{ 41 InvalidReply, 42 /// The reply text did not fit in the provided buffer. 43 ReplyTooLong, 44 }; 45 46 /// Reads one (possibly multiline) reply. The text is copied into `buffer` 47 /// and the returned reply's `text` field points into it. 48 pub fn read(reader: *Io.Reader, buffer: []u8) ReadError!Reply { 49 var text: Io.Writer = .fixed(buffer); 50 var code: ?u16 = null; 51 var first = true; 52 while (true) { 53 const line = try readLine(reader); 54 if (line.len < 3) return error.InvalidReply; 55 const line_code = std.fmt.parseInt(u16, line[0..3], 10) catch 56 return error.InvalidReply; 57 if (line_code < 100 or line_code > 599) return error.InvalidReply; 58 if (code) |prev| { 59 // All lines of a multiline reply must carry the same code. 60 if (prev != line_code) return error.InvalidReply; 61 } else { 62 code = line_code; 63 } 64 var last = true; 65 var line_text: []const u8 = ""; 66 if (line.len > 3) { 67 switch (line[3]) { 68 ' ' => {}, 69 '-' => last = false, 70 else => return error.InvalidReply, 71 } 72 line_text = line[4..]; 73 } 74 if (!first) text.writeByte('\n') catch return error.ReplyTooLong; 75 text.writeAll(line_text) catch return error.ReplyTooLong; 76 first = false; 77 if (last) break; 78 } 79 return .{ .code = code.?, .text = text.buffered() }; 80 } 81 82 /// Iterates over the individual text lines of the reply. 83 pub fn lines(r: *const Reply) std.mem.SplitIterator(u8, .scalar) { 84 return std.mem.splitScalar(u8, r.text, '\n'); 85 } 86 87 // Reply classes per RFC 5321 §4.2.1 88 // (https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.1). 89 pub fn isPositiveCompletion(r: Reply) bool { 90 return r.code >= 200 and r.code < 300; 91 } 92 pub fn isPositiveIntermediate(r: Reply) bool { 93 return r.code >= 300 and r.code < 400; 94 } 95 pub fn isTransientFailure(r: Reply) bool { 96 return r.code >= 400 and r.code < 500; 97 } 98 pub fn isPermanentFailure(r: Reply) bool { 99 return r.code >= 500 and r.code < 600; 100 } 101 102 test read { 103 var reader: Io.Reader = .fixed("250-first\r\n250 second\r\n"); 104 var buffer: [64]u8 = undefined; 105 const reply = try read(&reader, &buffer); 106 try std.testing.expectEqual(@as(u16, 250), reply.code); 107 try std.testing.expectEqualStrings("first\nsecond", reply.text); 108 } 109 110 test lines { 111 const reply: Reply = .{ .code = 250, .text = "one\ntwo" }; 112 var it = reply.lines(); 113 try std.testing.expectEqualStrings("one", it.next().?); 114 try std.testing.expectEqualStrings("two", it.next().?); 115 try std.testing.expectEqual(@as(?[]const u8, null), it.next()); 116 } 117 118 test isPositiveCompletion { 119 try std.testing.expect((Reply{ .code = 250, .text = "" }).isPositiveCompletion()); 120 try std.testing.expect(!(Reply{ .code = 354, .text = "" }).isPositiveCompletion()); 121 } 122 123 test isPositiveIntermediate { 124 try std.testing.expect((Reply{ .code = 354, .text = "" }).isPositiveIntermediate()); 125 } 126 127 test isTransientFailure { 128 try std.testing.expect((Reply{ .code = 451, .text = "" }).isTransientFailure()); 129 } 130 131 test isPermanentFailure { 132 try std.testing.expect((Reply{ .code = 550, .text = "" }).isPermanentFailure()); 133 } 134}; 135 136/// A parsed client command, as seen by a server. 137pub const Command = union(enum) { 138 helo: []const u8, 139 ehlo: []const u8, 140 /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`). 141 mail: PathArgs, 142 /// RCPT TO. 143 rcpt: PathArgs, 144 data, 145 rset, 146 noop, 147 quit, 148 vrfy: []const u8, 149 help, 150 starttls, 151 /// AUTH ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 152 auth: AuthArgs, 153 /// BDAT, the CHUNKING extension 154 /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). The 155 /// command line is followed by exactly `size` raw octets. 156 bdat: BdatArgs, 157 /// Unrecognized command verb; the payload is the full line. 158 unknown: []const u8, 159 160 pub const BdatArgs = struct { 161 size: u64, 162 /// True for the final chunk of the message ("BDAT n LAST"). 163 last: bool = false, 164 }; 165 166 pub const AuthArgs = struct { 167 mechanism: []const u8, 168 /// Raw base64 initial response, if the client sent one ("=" denotes 169 /// an empty initial response). 170 initial: []const u8 = "", 171 }; 172 173 pub const PathArgs = struct { 174 /// The mailbox, with angle brackets and any obsolete source route 175 /// stripped. 176 path: []const u8, 177 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024". 178 params: []const u8 = "", 179 180 pub fn paramIterator(args: PathArgs) ParamIterator { 181 return .init(args.params); 182 } 183 184 test paramIterator { 185 const args: PathArgs = .{ .path = "a@example.com", .params = "SIZE=7" }; 186 var it = args.paramIterator(); 187 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); 188 } 189 }; 190 191 pub const ParseError = error{Syntax}; 192 193 /// Parses one command line (without its line ending). Returned slices 194 /// point into `line`. 195 pub fn parse(line: []const u8) ParseError!Command { 196 const trimmed = std.mem.trim(u8, line, " \t"); 197 const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len; 198 const verb = trimmed[0..verb_end]; 199 const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t"); 200 201 if (ieql(verb, "HELO")) { 202 if (rest.len == 0) return error.Syntax; 203 return .{ .helo = rest }; 204 } 205 if (ieql(verb, "EHLO")) { 206 if (rest.len == 0) return error.Syntax; 207 return .{ .ehlo = rest }; 208 } 209 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") }; 210 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") }; 211 if (ieql(verb, "DATA")) return .data; 212 if (ieql(verb, "RSET")) return .rset; 213 if (ieql(verb, "NOOP")) return .noop; 214 if (ieql(verb, "QUIT")) return .quit; 215 if (ieql(verb, "VRFY")) return .{ .vrfy = rest }; 216 if (ieql(verb, "HELP")) return .help; 217 if (ieql(verb, "STARTTLS")) return .starttls; 218 if (ieql(verb, "BDAT")) { 219 var it = std.mem.tokenizeAny(u8, rest, " \t"); 220 const size_token = it.next() orelse return error.Syntax; 221 const size = std.fmt.parseInt(u64, size_token, 10) catch return error.Syntax; 222 var last = false; 223 if (it.next()) |token| { 224 if (!ieql(token, "LAST")) return error.Syntax; 225 last = true; 226 } 227 if (it.next() != null) return error.Syntax; 228 return .{ .bdat = .{ .size = size, .last = last } }; 229 } 230 if (ieql(verb, "AUTH")) { 231 const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len; 232 if (mech_end == 0) return error.Syntax; 233 return .{ .auth = .{ 234 .mechanism = rest[0..mech_end], 235 .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"), 236 } }; 237 } 238 return .{ .unknown = line }; 239 } 240 241 fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs { 242 if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword)) 243 return error.Syntax; 244 const after = std.mem.trimStart(u8, rest[keyword.len..], " \t"); 245 if (after.len == 0 or after[0] != '<') { 246 // Lenient: accept a bare address ending at whitespace. 247 const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len; 248 if (end == 0) return error.Syntax; 249 return .{ 250 .path = after[0..end], 251 .params = std.mem.trimStart(u8, after[end..], " \t"), 252 }; 253 } 254 // The closing bracket must be found outside any quoted local-part: 255 // <"a>b"@example.com> is legal (RFC 5321 quoted-string, with 256 // backslash escapes). 257 const close = close: { 258 var in_quotes = false; 259 var i: usize = 1; 260 while (i < after.len) : (i += 1) { 261 const byte = after[i]; 262 if (in_quotes) { 263 if (byte == '\\') { 264 i += 1; 265 } else if (byte == '"') { 266 in_quotes = false; 267 } 268 } else if (byte == '"') { 269 in_quotes = true; 270 } else if (byte == '>') { 271 break :close i; 272 } 273 } 274 return error.Syntax; 275 }; 276 var path = after[1..close]; 277 // Strip an obsolete source route: <@relay1,@relay2:user@host>. 278 if (path.len > 0 and path[0] == '@') { 279 const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax; 280 path = path[colon + 1 ..]; 281 } 282 return .{ 283 .path = path, 284 .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"), 285 }; 286 } 287 288 fn ieql(a: []const u8, b: []const u8) bool { 289 return std.ascii.eqlIgnoreCase(a, b); 290 } 291 292 test parse { 293 const command = try parse("RCPT TO:<bob@example.net>"); 294 try std.testing.expectEqualStrings("bob@example.net", command.rcpt.path); 295 try std.testing.expectError(error.Syntax, parse("MAIL <missing-keyword>")); 296 } 297}; 298 299/// Iterates the ESMTP parameters of a MAIL or RCPT command 300/// ([RFC 5321 §4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2)), 301/// e.g. "SIZE=1024 BODY=8BITMIME". 302pub const ParamIterator = struct { 303 rest: []const u8, 304 305 pub const Param = struct { 306 keyword: []const u8, 307 /// Empty when the parameter carries no value. 308 value: []const u8 = "", 309 }; 310 311 pub fn init(params: []const u8) ParamIterator { 312 return .{ .rest = params }; 313 } 314 315 pub fn next(it: *ParamIterator) ?Param { 316 it.rest = std.mem.trimStart(u8, it.rest, " \t"); 317 if (it.rest.len == 0) return null; 318 const end = std.mem.indexOfAny(u8, it.rest, " \t") orelse it.rest.len; 319 const token = it.rest[0..end]; 320 it.rest = it.rest[end..]; 321 if (std.mem.indexOfScalar(u8, token, '=')) |eq| { 322 return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] }; 323 } 324 return .{ .keyword = token }; 325 } 326 327 test init { 328 var it: ParamIterator = .init("SIZE=42"); 329 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); 330 } 331 332 test next { 333 var it: ParamIterator = .init("BODY=8BITMIME CUSTOM"); 334 const body = it.next().?; 335 try std.testing.expectEqualStrings("BODY", body.keyword); 336 try std.testing.expectEqualStrings("8BITMIME", body.value); 337 const custom = it.next().?; 338 try std.testing.expectEqualStrings("CUSTOM", custom.keyword); 339 try std.testing.expectEqualStrings("", custom.value); 340 try std.testing.expectEqual(@as(?Param, null), it.next()); 341 } 342}; 343 344/// Writes `data` as SMTP message content: line endings are normalized to CRLF 345/// and lines beginning with '.' are dot-stuffed 346/// ([RFC 5321 §4.5.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.2)). Does not 347/// write the terminating ".\r\n". 348pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void { 349 var rest = data; 350 while (rest.len > 0) { 351 var line: []const u8 = undefined; 352 if (std.mem.indexOfScalar(u8, rest, '\n')) |i| { 353 line = rest[0..i]; 354 rest = rest[i + 1 ..]; 355 } else { 356 line = rest; 357 rest = rest[rest.len..]; 358 } 359 if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; 360 if (line.len > 0 and line[0] == '.') try writer.writeByte('.'); 361 try writer.writeAll(line); 362 try writer.writeAll(crlf); 363 } 364} 365 366test readLine { 367 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n"); 368 try std.testing.expectEqualStrings("first", try readLine(&reader)); 369 try std.testing.expectEqualStrings("second", try readLine(&reader)); 370 try std.testing.expectEqualStrings("third", try readLine(&reader)); 371 try std.testing.expectError(error.EndOfStream, readLine(&reader)); 372} 373 374test Reply { 375 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 376 var buf: [128]u8 = undefined; 377 const reply = try Reply.read(&reader, &buf); 378 try std.testing.expectEqual(@as(u16, 250), reply.code); 379 try std.testing.expectEqualStrings("2.0.0 Ok", reply.text); 380 try std.testing.expect(reply.isPositiveCompletion()); 381} 382 383test "Reply.read multiline" { 384 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n"); 385 var buf: [128]u8 = undefined; 386 const reply = try Reply.read(&reader, &buf); 387 try std.testing.expectEqual(@as(u16, 250), reply.code); 388 try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text); 389 var it = reply.lines(); 390 try std.testing.expectEqualStrings("mx.example.com", it.next().?); 391 try std.testing.expectEqualStrings("PIPELINING", it.next().?); 392 try std.testing.expectEqualStrings("SIZE 1000", it.next().?); 393 try std.testing.expectEqual(@as(?[]const u8, null), it.next()); 394} 395 396test "Reply.read rejects malformed replies" { 397 var buf: [128]u8 = undefined; 398 { 399 var reader: Io.Reader = .fixed("2x0 hello\r\n"); 400 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 401 } 402 { 403 var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n"); 404 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 405 } 406 { 407 var reader: Io.Reader = .fixed("42\r\n"); 408 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 409 } 410} 411 412test Command { 413 { 414 const cmd = try Command.parse("EHLO client.example.com"); 415 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo); 416 } 417 { 418 const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024"); 419 try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path); 420 try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params); 421 } 422 { 423 // Null reverse-path and a space after the colon. 424 const cmd = try Command.parse("MAIL FROM: <>"); 425 try std.testing.expectEqualStrings("", cmd.mail.path); 426 } 427 { 428 // Obsolete source route is stripped. 429 const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>"); 430 try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path); 431 } 432 { 433 // Quoted local-parts (from postfix's address corpora) may contain 434 // spaces and even '>' or escaped quotes. 435 const cmd = try Command.parse("MAIL FROM:<\"foo bar\"@example.com> SIZE=9"); 436 try std.testing.expectEqualStrings("\"foo bar\"@example.com", cmd.mail.path); 437 try std.testing.expectEqualStrings("SIZE=9", cmd.mail.params); 438 } 439 { 440 const cmd = try Command.parse("RCPT TO:<\"a>b\"@example.com>"); 441 try std.testing.expectEqualStrings("\"a>b\"@example.com", cmd.rcpt.path); 442 } 443 { 444 const cmd = try Command.parse("RCPT TO:<\"a\\\">b\"@example.com>"); 445 try std.testing.expectEqualStrings("\"a\\\">b\"@example.com", cmd.rcpt.path); 446 } 447 try std.testing.expectError(error.Syntax, Command.parse("MAIL FROM:<\"unterminated@example.com>")); 448 { 449 const cmd = try Command.parse("QUIT"); 450 try std.testing.expectEqual(Command.quit, cmd); 451 } 452 { 453 const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw=="); 454 try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism); 455 try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial); 456 } 457 { 458 const cmd = try Command.parse("auth login"); 459 try std.testing.expectEqualStrings("login", cmd.auth.mechanism); 460 try std.testing.expectEqualStrings("", cmd.auth.initial); 461 } 462 { 463 const cmd = try Command.parse("MADE UP"); 464 try std.testing.expectEqualStrings("MADE UP", cmd.unknown); 465 } 466 { 467 const cmd = try Command.parse("BDAT 1024"); 468 try std.testing.expectEqual(@as(u64, 1024), cmd.bdat.size); 469 try std.testing.expect(!cmd.bdat.last); 470 } 471 { 472 const cmd = try Command.parse("bdat 0 last"); 473 try std.testing.expectEqual(@as(u64, 0), cmd.bdat.size); 474 try std.testing.expect(cmd.bdat.last); 475 } 476 try std.testing.expectError(error.Syntax, Command.parse("BDAT")); 477 try std.testing.expectError(error.Syntax, Command.parse("BDAT nan")); 478 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 FIRST")); 479 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 LAST extra")); 480 try std.testing.expectError(error.Syntax, Command.parse("AUTH")); 481 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>")); 482 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:")); 483 try std.testing.expectError(error.Syntax, Command.parse("HELO")); 484} 485 486test writeStuffed { 487 var buf: [256]u8 = undefined; 488 { 489 var w: Io.Writer = .fixed(&buf); 490 try writeStuffed(&w, "line one\r\n.starts with dot\r\n"); 491 try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered()); 492 } 493 { 494 // LF-only input is normalized, missing final newline is added. 495 var w: Io.Writer = .fixed(&buf); 496 try writeStuffed(&w, "a\nb"); 497 try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered()); 498 } 499 { 500 // A lone "." line must not become a terminator. 501 var w: Io.Writer = .fixed(&buf); 502 try writeStuffed(&w, ".\n"); 503 try std.testing.expectEqualStrings("..\r\n", w.buffered()); 504 } 505 { 506 var w: Io.Writer = .fixed(&buf); 507 try writeStuffed(&w, ""); 508 try std.testing.expectEqualStrings("", w.buffered()); 509 } 510} 511 512test "fuzz Command.parse" { 513 try std.testing.fuzz({}, fuzzCommandParse, .{}); 514} 515 516fn fuzzCommandParse(context: void, smith: *std.testing.Smith) !void { 517 _ = context; 518 var line_buf: [512]u8 = undefined; 519 const line = line_buf[0..smith.value(u9)]; 520 smith.bytes(line); 521 522 const command = Command.parse(line) catch return; 523 // Payload slices must always lie within the parsed line. 524 switch (command) { 525 .helo, .ehlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len), 526 .mail, .rcpt => |args| { 527 try std.testing.expect(args.path.len <= line.len); 528 try std.testing.expect(args.params.len <= line.len); 529 }, 530 .auth => |args| { 531 try std.testing.expect(args.mechanism.len <= line.len); 532 try std.testing.expect(args.initial.len <= line.len); 533 }, 534 .data, .rset, .noop, .quit, .help, .starttls, .bdat => {}, 535 } 536} 537 538test "fuzz Reply.read" { 539 try std.testing.fuzz({}, fuzzReplyRead, .{}); 540} 541 542fn fuzzReplyRead(context: void, smith: *std.testing.Smith) !void { 543 _ = context; 544 var input_buf: [1024]u8 = undefined; 545 const input = input_buf[0..smith.value(u10)]; 546 smith.bytes(input); 547 548 var reader: Io.Reader = .fixed(input); 549 var text_buf: [128]u8 = undefined; 550 // Each successful read consumes at least one line, so this terminates. 551 while (true) { 552 const reply = Reply.read(&reader, &text_buf) catch break; 553 try std.testing.expect(reply.code >= 100 and reply.code <= 599); 554 } 555} 556 557test ParamIterator { 558 const command = try Command.parse("MAIL FROM:<a@example.com> SIZE=1024 BODY=8BITMIME FLAG"); 559 var it = command.mail.paramIterator(); 560 561 const size = it.next().?; 562 try std.testing.expectEqualStrings("SIZE", size.keyword); 563 try std.testing.expectEqualStrings("1024", size.value); 564 565 const body = it.next().?; 566 try std.testing.expectEqualStrings("BODY", body.keyword); 567 try std.testing.expectEqualStrings("8BITMIME", body.value); 568 569 const flag = it.next().?; 570 try std.testing.expectEqualStrings("FLAG", flag.keyword); 571 try std.testing.expectEqualStrings("", flag.value); 572 573 try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next()); 574} 575 576test crlf { 577 try std.testing.expectEqualStrings("\r\n", crlf); 578}