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