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 / Server.zig
18 kB 461 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! A single-connection SMTP server session. Like the client, it runs over 5//! any `Io.Reader`/`Io.Writer` pair; accept a TCP connection and hand its 6//! stream reader/writer to `run`. Accepting connections, concurrency, and 7//! message storage are left to the caller — the session just speaks the 8//! protocol and forwards decisions to a `Handler`. 9//! 10//! Typical use: 11//! ``` 12//! var session: Server = .init(&stream_reader, &stream_writer, handler, .{ 13//! .hostname = "mx.example.com", 14//! }); 15//! try session.run(gpa); 16//! ``` 17 18const Server = @This(); 19 20const std = @import("std"); 21const Io = std.Io; 22const tls = @import("tls"); 23const protocol = @import("protocol.zig"); 24 25reader: *Io.Reader, 26writer: *Io.Writer, 27handler: Handler, 28options: Options, 29/// True once a STARTTLS handshake has completed for this session. 30secured: bool = false, 31tls_connection: tls.Connection = undefined, 32tls_reader: tls.Connection.Reader = undefined, 33tls_writer: tls.Connection.Writer = undefined, 34tls_read_buffer: [4096]u8 = undefined, 35tls_write_buffer: [4096]u8 = undefined, 36 37pub const Options = struct { 38 /// Hostname announced in the greeting and the EHLO response. 39 hostname: []const u8 = "localhost", 40 /// Advertised via the SIZE extension and enforced during DATA. 41 max_message_size: usize = 16 * 1024 * 1024, 42 max_recipients: usize = 100, 43 /// When set, STARTTLS is advertised and accepted. The underlying stream 44 /// reader/writer handed to `init` must then have buffers of at least 45 /// `tls.input_buffer_len` and `tls.output_buffer_len` bytes, since the 46 /// handshake and TLS records run over them. 47 starttls: ?StartTls = null, 48}; 49 50pub const StartTls = struct { 51 io: Io, 52 /// Server certificate chain and private key presented to clients. 53 auth: *tls.config.CertKeyPair, 54}; 55 56/// A handler's verdict on an envelope step or a complete message. 57pub const Decision = union(enum) { 58 accept, 59 reject: Rejection, 60 61 pub const Rejection = struct { 62 /// Use 4xx for "try again later", 5xx for permanent rejection. 63 code: u16 = 550, 64 text: []const u8 = "5.7.1 Rejected", 65 }; 66}; 67 68pub const Envelope = struct { 69 /// Empty for the null reverse-path (`MAIL FROM:<>`). 70 from: []const u8, 71 recipients: []const []const u8, 72}; 73 74/// Callbacks invoked during a session. All slices passed to callbacks are 75/// only valid for the duration of the call. 76pub const Handler = struct { 77 context: ?*anyopaque = null, 78 vtable: *const VTable, 79 80 pub const VTable = struct { 81 /// Called for MAIL FROM. Null accepts every sender. 82 mailFrom: ?*const fn (context: ?*anyopaque, from: []const u8) Decision = null, 83 /// Called for each RCPT TO. Null accepts every recipient. 84 rcptTo: ?*const fn (context: ?*anyopaque, to: []const u8) Decision = null, 85 /// Called once the complete message has been received. The data has 86 /// CRLF line endings and dot-stuffing already removed. 87 message: *const fn (context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision, 88 }; 89}; 90 91pub fn init(reader: *Io.Reader, writer: *Io.Writer, handler: Handler, options: Options) Server { 92 return .{ .reader = reader, .writer = writer, .handler = handler, .options = options }; 93} 94 95pub const RunError = error{ WriteFailed, ReadFailed, OutOfMemory, TlsHandshakeFailed }; 96 97/// Serves the session until the client sends QUIT or disconnects. `gpa` 98/// backs per-transaction storage (envelope and message data); everything is 99/// freed on return. 100pub fn run(s: *Server, gpa: std.mem.Allocator) RunError!void { 101 var arena_state: std.heap.ArenaAllocator = .init(gpa); 102 defer arena_state.deinit(); 103 const arena = arena_state.allocator(); 104 105 var greeted = false; 106 var from: ?[]const u8 = null; 107 var recipients: std.ArrayList([]const u8) = .empty; 108 109 try s.writer.print("220 {s} ESMTP ready" ++ protocol.crlf, .{s.options.hostname}); 110 try s.writer.flush(); 111 112 while (true) { 113 const line = protocol.readLine(s.reader) catch |err| switch (err) { 114 error.EndOfStream => return, // Client disconnected. 115 error.ReadFailed => return error.ReadFailed, 116 error.LineTooLong => { 117 try s.discardLine(); 118 try s.reply(500, "5.5.2 Line too long"); 119 continue; 120 }, 121 }; 122 const command = protocol.Command.parse(line) catch { 123 try s.reply(501, "5.5.4 Syntax error in parameters"); 124 continue; 125 }; 126 switch (command) { 127 .helo => { 128 greeted = true; 129 from = null; 130 recipients = .empty; 131 _ = arena_state.reset(.retain_capacity); 132 try s.reply(250, s.options.hostname); 133 }, 134 .ehlo => { 135 greeted = true; 136 from = null; 137 recipients = .empty; 138 _ = arena_state.reset(.retain_capacity); 139 try s.writer.print("250-{s}\r\n250-PIPELINING\r\n250-8BITMIME\r\n", .{s.options.hostname}); 140 if (s.options.starttls != null and !s.secured) 141 try s.writer.writeAll("250-STARTTLS\r\n"); 142 try s.writer.print("250 SIZE {d}\r\n", .{s.options.max_message_size}); 143 try s.writer.flush(); 144 }, 145 .mail => |args| { 146 if (!greeted) { 147 try s.reply(503, "5.5.1 Send EHLO first"); 148 continue; 149 } 150 if (from != null) { 151 try s.reply(503, "5.5.1 Nested MAIL command"); 152 continue; 153 } 154 if (s.handler.vtable.mailFrom) |callback| { 155 switch (callback(s.handler.context, args.path)) { 156 .accept => {}, 157 .reject => |r| { 158 try s.reply(r.code, r.text); 159 continue; 160 }, 161 } 162 } 163 from = try arena.dupe(u8, args.path); 164 try s.reply(250, "2.1.0 Ok"); 165 }, 166 .rcpt => |args| { 167 if (from == null) { 168 try s.reply(503, "5.5.1 Need MAIL command first"); 169 continue; 170 } 171 if (recipients.items.len >= s.options.max_recipients) { 172 try s.reply(452, "4.5.3 Too many recipients"); 173 continue; 174 } 175 if (s.handler.vtable.rcptTo) |callback| { 176 switch (callback(s.handler.context, args.path)) { 177 .accept => {}, 178 .reject => |r| { 179 try s.reply(r.code, r.text); 180 continue; 181 }, 182 } 183 } 184 try recipients.append(arena, try arena.dupe(u8, args.path)); 185 try s.reply(250, "2.1.5 Ok"); 186 }, 187 .data => { 188 if (recipients.items.len == 0) { 189 try s.reply(503, "5.5.1 Need RCPT command first"); 190 continue; 191 } 192 try s.receiveData(arena, .{ 193 .from = from.?, 194 .recipients = recipients.items, 195 }); 196 from = null; 197 recipients = .empty; 198 _ = arena_state.reset(.retain_capacity); 199 }, 200 .rset => { 201 from = null; 202 recipients = .empty; 203 _ = arena_state.reset(.retain_capacity); 204 try s.reply(250, "2.0.0 Ok"); 205 }, 206 .noop => try s.reply(250, "2.0.0 Ok"), 207 .vrfy => try s.reply(252, "2.5.2 Cannot VRFY user"), 208 .help => try s.reply(214, "2.0.0 See RFC 5321"), 209 .starttls => { 210 const config = s.options.starttls orelse { 211 try s.reply(502, "5.5.1 STARTTLS not supported"); 212 continue; 213 }; 214 if (s.secured) { 215 try s.reply(503, "5.5.1 TLS already active"); 216 continue; 217 } 218 try s.reply(220, "2.0.0 Ready to start TLS"); 219 var rng_source: std.Random.IoSource = .{ .io = config.io }; 220 s.tls_connection = tls.server(s.reader, s.writer, .{ 221 .auth = config.auth, 222 .rng = rng_source.interface(), 223 .now = Io.Clock.real.now(config.io), 224 }) catch return error.TlsHandshakeFailed; 225 s.tls_reader = s.tls_connection.reader(&s.tls_read_buffer); 226 s.tls_writer = s.tls_connection.writer(&s.tls_write_buffer); 227 s.reader = &s.tls_reader.interface; 228 s.writer = &s.tls_writer.interface; 229 s.secured = true; 230 // RFC 3207 §4.2: both sides return to their initial state; 231 // the client must EHLO again. 232 greeted = false; 233 from = null; 234 recipients = .empty; 235 _ = arena_state.reset(.retain_capacity); 236 }, 237 .quit => { 238 try s.reply(221, "2.0.0 Bye"); 239 if (s.secured) s.tls_connection.close() catch {}; 240 return; 241 }, 242 .unknown => try s.reply(500, "5.5.2 Command not recognized"), 243 } 244 } 245} 246 247/// Reads message content after DATA up to the terminating ".\r\n", 248/// un-stuffing dots, then asks the handler to accept or reject. 249fn receiveData(s: *Server, arena: std.mem.Allocator, envelope: Envelope) RunError!void { 250 try s.reply(354, "End data with <CR><LF>.<CR><LF>"); 251 var data: std.ArrayList(u8) = .empty; 252 var oversize = false; 253 while (true) { 254 const line = protocol.readLine(s.reader) catch |err| switch (err) { 255 error.EndOfStream => return, // Client disconnected mid-message. 256 error.ReadFailed => return error.ReadFailed, 257 error.LineTooLong => { 258 // Longer than our reader buffer; RFC 5321 caps text lines at 259 // 1000 octets, so treat it as oversize but keep scanning for 260 // the terminator. 261 try s.discardLine(); 262 oversize = true; 263 continue; 264 }, 265 }; 266 if (std.mem.eql(u8, line, ".")) break; 267 const content = if (line.len > 0 and line[0] == '.') line[1..] else line; 268 if (oversize) continue; 269 if (data.items.len + content.len + protocol.crlf.len > s.options.max_message_size) { 270 oversize = true; 271 continue; 272 } 273 try data.appendSlice(arena, content); 274 try data.appendSlice(arena, protocol.crlf); 275 } 276 if (oversize) { 277 try s.reply(552, "5.3.4 Message exceeds maximum size"); 278 return; 279 } 280 switch (s.handler.vtable.message(s.handler.context, envelope, data.items)) { 281 .accept => try s.reply(250, "2.0.0 Ok, message accepted"), 282 .reject => |r| try s.reply(r.code, r.text), 283 } 284} 285 286fn reply(s: *Server, code: u16, text: []const u8) error{WriteFailed}!void { 287 try s.writer.print("{d} {s}" ++ protocol.crlf, .{ code, text }); 288 try s.writer.flush(); 289} 290 291/// Discards input through the next newline after `error.LineTooLong`, which 292/// leaves the reader positioned at the start of the oversized line. 293fn discardLine(s: *Server) error{ReadFailed}!void { 294 _ = s.reader.discardDelimiterInclusive('\n') catch |err| switch (err) { 295 error.EndOfStream => {}, 296 error.ReadFailed => return error.ReadFailed, 297 }; 298} 299 300const TestHandler = struct { 301 from: std.ArrayList(u8) = .empty, 302 recipients: std.ArrayList(u8) = .empty, 303 data: std.ArrayList(u8) = .empty, 304 messages_accepted: usize = 0, 305 reject_recipient: ?[]const u8 = null, 306 307 fn deinit(h: *TestHandler) void { 308 h.from.deinit(std.testing.allocator); 309 h.recipients.deinit(std.testing.allocator); 310 h.data.deinit(std.testing.allocator); 311 } 312 313 fn handler(h: *TestHandler) Handler { 314 return .{ .context = h, .vtable = &.{ 315 .rcptTo = onRcptTo, 316 .message = onMessage, 317 } }; 318 } 319 320 fn onRcptTo(context: ?*anyopaque, to: []const u8) Decision { 321 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 322 if (h.reject_recipient) |rejected| { 323 if (std.mem.eql(u8, to, rejected)) return .{ .reject = .{ 324 .code = 550, 325 .text = "5.1.1 No such user", 326 } }; 327 } 328 return .accept; 329 } 330 331 fn onMessage(context: ?*anyopaque, envelope: Envelope, data: []const u8) Decision { 332 const h: *TestHandler = @ptrCast(@alignCast(context.?)); 333 const gpa = std.testing.allocator; 334 h.from.appendSlice(gpa, envelope.from) catch return .{ .reject = .{} }; 335 for (envelope.recipients) |recipient| { 336 h.recipients.appendSlice(gpa, recipient) catch return .{ .reject = .{} }; 337 h.recipients.append(gpa, ';') catch return .{ .reject = .{} }; 338 } 339 h.data.appendSlice(gpa, data) catch return .{ .reject = .{} }; 340 h.messages_accepted += 1; 341 return .accept; 342 } 343}; 344 345fn runScript(input: []const u8, out_buf: []u8, handler: Handler, options: Options) ![]const u8 { 346 var reader: Io.Reader = .fixed(input); 347 var writer: Io.Writer = .fixed(out_buf); 348 var session: Server = .init(&reader, &writer, handler, options); 349 try session.run(std.testing.allocator); 350 return writer.buffered(); 351} 352 353test "complete session" { 354 var h: TestHandler = .{}; 355 defer h.deinit(); 356 357 var out_buf: [1024]u8 = undefined; 358 const output = try runScript( 359 "EHLO client.example.org\r\n" ++ 360 "MAIL FROM:<alice@example.com>\r\n" ++ 361 "RCPT TO:<bob@example.net>\r\n" ++ 362 "RCPT TO:<carol@example.net>\r\n" ++ 363 "DATA\r\n" ++ 364 "Subject: hi\r\n" ++ 365 "\r\n" ++ 366 "..stuffed line\r\n" ++ 367 "body\r\n" ++ 368 ".\r\n" ++ 369 "QUIT\r\n", 370 &out_buf, 371 h.handler(), 372 .{ .hostname = "mx.test" }, 373 ); 374 375 try std.testing.expectEqualStrings("alice@example.com", h.from.items); 376 try std.testing.expectEqualStrings("bob@example.net;carol@example.net;", h.recipients.items); 377 try std.testing.expectEqualStrings("Subject: hi\r\n\r\n.stuffed line\r\nbody\r\n", h.data.items); 378 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 379 380 try std.testing.expectEqualStrings( 381 "220 mx.test ESMTP ready\r\n" ++ 382 "250-mx.test\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 16777216\r\n" ++ 383 "250 2.1.0 Ok\r\n" ++ 384 "250 2.1.5 Ok\r\n" ++ 385 "250 2.1.5 Ok\r\n" ++ 386 "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 387 "250 2.0.0 Ok, message accepted\r\n" ++ 388 "221 2.0.0 Bye\r\n", 389 output, 390 ); 391} 392 393test "command sequencing is enforced" { 394 var h: TestHandler = .{}; 395 defer h.deinit(); 396 397 var out_buf: [1024]u8 = undefined; 398 const output = try runScript( 399 "MAIL FROM:<early@example.com>\r\n" ++ 400 "EHLO client.example.org\r\n" ++ 401 "RCPT TO:<bob@example.net>\r\n" ++ 402 "DATA\r\n" ++ 403 "QUIT\r\n", 404 &out_buf, 405 h.handler(), 406 .{}, 407 ); 408 409 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 410 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Send EHLO first") != null); 411 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need MAIL command first") != null); 412 try std.testing.expect(std.mem.indexOf(u8, output, "503 5.5.1 Need RCPT command first") != null); 413} 414 415test "handler can reject a recipient" { 416 var h: TestHandler = .{ .reject_recipient = "nobody@example.net" }; 417 defer h.deinit(); 418 419 var out_buf: [1024]u8 = undefined; 420 const output = try runScript( 421 "EHLO client.example.org\r\n" ++ 422 "MAIL FROM:<alice@example.com>\r\n" ++ 423 "RCPT TO:<nobody@example.net>\r\n" ++ 424 "RCPT TO:<bob@example.net>\r\n" ++ 425 "DATA\r\n" ++ 426 "hello\r\n" ++ 427 ".\r\n" ++ 428 "QUIT\r\n", 429 &out_buf, 430 h.handler(), 431 .{}, 432 ); 433 434 try std.testing.expect(std.mem.indexOf(u8, output, "550 5.1.1 No such user") != null); 435 try std.testing.expectEqualStrings("bob@example.net;", h.recipients.items); 436 try std.testing.expectEqual(@as(usize, 1), h.messages_accepted); 437} 438 439test "oversize message is rejected but session continues" { 440 var h: TestHandler = .{}; 441 defer h.deinit(); 442 443 var out_buf: [1024]u8 = undefined; 444 const output = try runScript( 445 "EHLO client.example.org\r\n" ++ 446 "MAIL FROM:<alice@example.com>\r\n" ++ 447 "RCPT TO:<bob@example.net>\r\n" ++ 448 "DATA\r\n" ++ 449 "0123456789012345678901234567890123456789\r\n" ++ 450 ".\r\n" ++ 451 "NOOP\r\n" ++ 452 "QUIT\r\n", 453 &out_buf, 454 h.handler(), 455 .{ .max_message_size = 16 }, 456 ); 457 458 try std.testing.expectEqual(@as(usize, 0), h.messages_accepted); 459 try std.testing.expect(std.mem.indexOf(u8, output, "552 5.3.4") != null); 460 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 461}