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.

Initial commit: SMTP client and server library

Transport-agnostic SMTP (RFC 5321) over std.Io.Reader/Writer pairs:

- protocol.zig: reply parsing, command parsing, dot-stuffing
- Client.zig: EHLO/HELO, extensions, AUTH PLAIN, mail transactions
- Server.zig: single-connection session with handler vtable, command
sequencing, recipient/message-size limits
- main.zig: demo CLI (send via stdin, debug serve on loopback)

MIT licensed with SPDX headers; REUSE 3.3 compliant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx

author
Jeffrey C. Ollie
co-author
Claude Fable 5
date (Aug 29, 2026, 10:15 PM -0500) commit 28b8016c
+1565
+5
.gitignore
··· 1 + # SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + # SPDX-License-Identifier: MIT 3 + 4 + .zig-cache/ 5 + zig-out/
+74
README.md
··· 1 + <!-- 2 + SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 3 + SPDX-License-Identifier: MIT 4 + --> 5 + 6 + # zsmtp 7 + 8 + An SMTP client and server library for Zig (RFC 5321). 9 + 10 + Both the client and the server run over plain `std.Io.Reader`/`std.Io.Writer` 11 + pairs, so they are transport-agnostic: wrap a TCP stream for real use, or 12 + fixed in-memory buffers in tests. Requires Zig 0.16. 13 + 14 + ## Client 15 + 16 + ```zig 17 + const zsmtp = @import("zsmtp"); 18 + 19 + var reply_buf: [1024]u8 = undefined; 20 + var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 21 + 22 + _ = try client.greet(); // read the 220 greeting 23 + _ = try client.hello("my-host.example.com"); // EHLO (HELO fallback), returns extensions 24 + try client.sendMail("me@example.com", &.{"you@example.net"}, message); 25 + try client.quit(); 26 + ``` 27 + 28 + Line endings in the message are normalized to CRLF and leading dots are 29 + stuffed automatically. On `error.UnexpectedReply`, `client.last_reply` holds 30 + the server's actual code and text. `mailFrom`/`rcptTo`/`sendMessage` are also 31 + available individually, as is `authPlain`. 32 + 33 + ## Server 34 + 35 + ```zig 36 + var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{ 37 + .context = &my_state, 38 + .vtable = &.{ 39 + .rcptTo = onRcptTo, // optional; accept/reject each recipient 40 + .message = onMessage, // required; receives envelope + message data 41 + }, 42 + }, .{ .hostname = "mx.example.com" }); 43 + try session.run(gpa); 44 + ``` 45 + 46 + `run` serves one connection until QUIT or disconnect, enforcing command 47 + sequencing, recipient and message-size limits, and un-stuffing message data. 48 + Listening, accepting, and concurrency are up to the caller. 49 + 50 + ## Demo CLI 51 + 52 + ```sh 53 + zig build 54 + 55 + # Debug server that prints received messages to stdout: 56 + ./zig-out/bin/zsmtp serve 2525 57 + 58 + # Send a message read from stdin: 59 + printf 'Subject: hi\r\n\r\nhello\r\n' | \ 60 + ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net 61 + ``` 62 + 63 + ## Status 64 + 65 + Plaintext SMTP only for now — STARTTLS/implicit TLS is the next planned step 66 + (the transport-agnostic design is meant to make that a drop-in layer). 67 + Not yet implemented: TLS, streaming (non-slice) message bodies, AUTH beyond 68 + PLAIN, and ESMTP parameter handling (SIZE=, BODY=) on the server side. 69 + 70 + ## Tests 71 + 72 + ```sh 73 + zig build test 74 + ```
+9
REUSE.toml
··· 1 + # SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + # SPDX-License-Identifier: MIT 3 + 4 + version = 1 5 + 6 + [[annotations]] 7 + path = "flake.lock" 8 + SPDX-FileCopyrightText = "© 2026 Jeffrey C. Ollie <jeff@ocjtech.us>" 9 + SPDX-License-Identifier = "MIT"
+159
build.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + const std = @import("std"); 5 + 6 + // Although this function looks imperative, it does not perform the build 7 + // directly and instead it mutates the build graph (`b`) that will be then 8 + // executed by an external runner. The functions in `std.Build` implement a DSL 9 + // for defining build steps and express dependencies between them, allowing the 10 + // build runner to parallelize the build automatically (and the cache system to 11 + // know when a step doesn't need to be re-run). 12 + pub fn build(b: *std.Build) void { 13 + // Standard target options allow the person running `zig build` to choose 14 + // what target to build for. Here we do not override the defaults, which 15 + // means any target is allowed, and the default is native. Other options 16 + // for restricting supported target set are available. 17 + const target = b.standardTargetOptions(.{}); 18 + // Standard optimization options allow the person running `zig build` to select 19 + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not 20 + // set a preferred release mode, allowing the user to decide how to optimize. 21 + const optimize = b.standardOptimizeOption(.{}); 22 + // It's also possible to define more custom flags to toggle optional features 23 + // of this build script using `b.option()`. All defined flags (including 24 + // target and optimize options) will be listed when running `zig build --help` 25 + // in this directory. 26 + 27 + // This creates a module, which represents a collection of source files alongside 28 + // some compilation options, such as optimization mode and linked system libraries. 29 + // Zig modules are the preferred way of making Zig code available to consumers. 30 + // addModule defines a module that we intend to make available for importing 31 + // to our consumers. We must give it a name because a Zig package can expose 32 + // multiple modules and consumers will need to be able to specify which 33 + // module they want to access. 34 + const mod = b.addModule("zsmtp", .{ 35 + // The root source file is the "entry point" of this module. Users of 36 + // this module will only be able to access public declarations contained 37 + // in this file, which means that if you have declarations that you 38 + // intend to expose to consumers that were defined in other files part 39 + // of this module, you will have to make sure to re-export them from 40 + // the root file. 41 + .root_source_file = b.path("src/root.zig"), 42 + // Later on we'll use this module as the root module of a test executable 43 + // which requires us to specify a target. 44 + .target = target, 45 + }); 46 + 47 + // Here we define an executable. An executable needs to have a root module 48 + // which needs to expose a `main` function. While we could add a main function 49 + // to the module defined above, it's sometimes preferable to split business 50 + // logic and the CLI into two separate modules. 51 + // 52 + // If your goal is to create a Zig library for others to use, consider if 53 + // it might benefit from also exposing a CLI tool. A parser library for a 54 + // data serialization format could also bundle a CLI syntax checker, for example. 55 + // 56 + // If instead your goal is to create an executable, consider if users might 57 + // be interested in also being able to embed the core functionality of your 58 + // program in their own executable in order to avoid the overhead involved in 59 + // subprocessing your CLI tool. 60 + // 61 + // If neither case applies to you, feel free to delete the declaration you 62 + // don't need and to put everything under a single module. 63 + const exe = b.addExecutable(.{ 64 + .name = "zsmtp", 65 + .root_module = b.createModule(.{ 66 + // b.createModule defines a new module just like b.addModule but, 67 + // unlike b.addModule, it does not expose the module to consumers of 68 + // this package, which is why in this case we don't have to give it a name. 69 + .root_source_file = b.path("src/main.zig"), 70 + // Target and optimization levels must be explicitly wired in when 71 + // defining an executable or library (in the root module), and you 72 + // can also hardcode a specific target for an executable or library 73 + // definition if desireable (e.g. firmware for embedded devices). 74 + .target = target, 75 + .optimize = optimize, 76 + // List of modules available for import in source files part of the 77 + // root module. 78 + .imports = &.{ 79 + // Here "zsmtp" is the name you will use in your source code to 80 + // import this module (e.g. `@import("zsmtp")`). The name is 81 + // repeated because you are allowed to rename your imports, which 82 + // can be extremely useful in case of collisions (which can happen 83 + // importing modules from different packages). 84 + .{ .name = "zsmtp", .module = mod }, 85 + }, 86 + }), 87 + }); 88 + 89 + // This declares intent for the executable to be installed into the 90 + // install prefix when running `zig build` (i.e. when executing the default 91 + // step). By default the install prefix is `zig-out/` but can be overridden 92 + // by passing `--prefix` or `-p`. 93 + b.installArtifact(exe); 94 + 95 + // This creates a top level step. Top level steps have a name and can be 96 + // invoked by name when running `zig build` (e.g. `zig build run`). 97 + // This will evaluate the `run` step rather than the default step. 98 + // For a top level step to actually do something, it must depend on other 99 + // steps (e.g. a Run step, as we will see in a moment). 100 + const run_step = b.step("run", "Run the app"); 101 + 102 + // This creates a RunArtifact step in the build graph. A RunArtifact step 103 + // invokes an executable compiled by Zig. Steps will only be executed by the 104 + // runner if invoked directly by the user (in the case of top level steps) 105 + // or if another step depends on it, so it's up to you to define when and 106 + // how this Run step will be executed. In our case we want to run it when 107 + // the user runs `zig build run`, so we create a dependency link. 108 + const run_cmd = b.addRunArtifact(exe); 109 + run_step.dependOn(&run_cmd.step); 110 + 111 + // By making the run step depend on the default step, it will be run from the 112 + // installation directory rather than directly from within the cache directory. 113 + run_cmd.step.dependOn(b.getInstallStep()); 114 + 115 + // This allows the user to pass arguments to the application in the build 116 + // command itself, like this: `zig build run -- arg1 arg2 etc` 117 + if (b.args) |args| { 118 + run_cmd.addArgs(args); 119 + } 120 + 121 + // Creates an executable that will run `test` blocks from the provided module. 122 + // Here `mod` needs to define a target, which is why earlier we made sure to 123 + // set the releative field. 124 + const mod_tests = b.addTest(.{ 125 + .root_module = mod, 126 + }); 127 + 128 + // A run step that will run the test executable. 129 + const run_mod_tests = b.addRunArtifact(mod_tests); 130 + 131 + // Creates an executable that will run `test` blocks from the executable's 132 + // root module. Note that test executables only test one module at a time, 133 + // hence why we have to create two separate ones. 134 + const exe_tests = b.addTest(.{ 135 + .root_module = exe.root_module, 136 + }); 137 + 138 + // A run step that will run the second test executable. 139 + const run_exe_tests = b.addRunArtifact(exe_tests); 140 + 141 + // A top level step for running all tests. dependOn can be called multiple 142 + // times and since the two run steps do not depend on one another, this will 143 + // make the two of them run in parallel. 144 + const test_step = b.step("test", "Run tests"); 145 + test_step.dependOn(&run_mod_tests.step); 146 + test_step.dependOn(&run_exe_tests.step); 147 + 148 + // Just like flags, top level steps are also listed in the `--help` menu. 149 + // 150 + // The Zig build system is entirely implemented in userland, which means 151 + // that it cannot hook into private compiler APIs. All compilation work 152 + // orchestrated by the build system will result in other Zig compiler 153 + // subcommands being invoked with the right flags defined. You can observe 154 + // these invocations when one fails (or you pass a flag to increase 155 + // verbosity) to validate assumptions and diagnose problems. 156 + // 157 + // Lastly, the Zig build system is relatively simple and self-contained, 158 + // and reading its source code will allow you to master it. 159 + }
+82
build.zig.zon
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + .{ 5 + // This is the default name used by packages depending on this one. For 6 + // example, when a user runs `zig fetch --save <url>`, this field is used 7 + // as the key in the `dependencies` table. Although the user can choose a 8 + // different name, most users will stick with this provided value. 9 + // 10 + // It is redundant to include "zig" in this name because it is already 11 + // within the Zig package namespace. 12 + .name = .zsmtp, 13 + // This is a [Semantic Version](https://semver.org/). 14 + // In a future version of Zig it will be used for package deduplication. 15 + .version = "0.0.0", 16 + // Together with name, this represents a globally unique package 17 + // identifier. This field is generated by the Zig toolchain when the 18 + // package is first created, and then *never changes*. This allows 19 + // unambiguous detection of one package being an updated version of 20 + // another. 21 + // 22 + // When forking a Zig project, this id should be regenerated (delete the 23 + // field and run `zig build`) if the upstream project is still maintained. 24 + // Otherwise, the fork is *hostile*, attempting to take control over the 25 + // original project's identity. Thus it is recommended to leave the comment 26 + // on the following line intact, so that it shows up in code reviews that 27 + // modify the field. 28 + .fingerprint = 0x579395bcd3b65a42, // Changing this has security and trust implications. 29 + // Tracks the earliest Zig version that the package considers to be a 30 + // supported use case. 31 + .minimum_zig_version = "0.16.0", 32 + // This field is optional. 33 + // Each dependency must either provide a `url` and `hash`, or a `path`. 34 + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. 35 + // Once all dependencies are fetched, `zig build` no longer requires 36 + // internet connectivity. 37 + .dependencies = .{ 38 + // See `zig fetch --save <url>` for a command-line interface for adding dependencies. 39 + //.example = .{ 40 + // // When updating this field to a new URL, be sure to delete the corresponding 41 + // // `hash`, otherwise you are communicating that you expect to find the old hash at 42 + // // the new URL. If the contents of a URL change this will result in a hash mismatch 43 + // // which will prevent zig from using it. 44 + // .url = "https://example.com/foo.tar.gz", 45 + // 46 + // // This is computed from the file contents of the directory of files that is 47 + // // obtained after fetching `url` and applying the inclusion rules given by 48 + // // `paths`. 49 + // // 50 + // // This field is the source of truth; packages do not come from a `url`; they 51 + // // come from a `hash`. `url` is just one of many possible mirrors for how to 52 + // // obtain a package matching this `hash`. 53 + // // 54 + // // Uses the [multihash](https://multiformats.io/multihash/) format. 55 + // .hash = "...", 56 + // 57 + // // When this is provided, the package is found in a directory relative to the 58 + // // build root. In this case the package's hash is irrelevant and therefore not 59 + // // computed. This field and `url` are mutually exclusive. 60 + // .path = "foo", 61 + // 62 + // // When this is set to `true`, a package is declared to be lazily 63 + // // fetched. This makes the dependency only get fetched if it is 64 + // // actually used. 65 + // .lazy = false, 66 + //}, 67 + }, 68 + // Specifies the set of files and directories that are included in this package. 69 + // Only files and directories listed here are included in the `hash` that 70 + // is computed for this package. Only files listed here will remain on disk 71 + // when using the zig package manager. As a rule of thumb, one should list 72 + // files required for compilation plus any license(s). 73 + // Paths are relative to the build root. Use the empty string (`""`) to refer to 74 + // the build root itself. 75 + // A directory listed here means that all files within, recursively, are included. 76 + .paths = .{ 77 + "build.zig", 78 + "build.zig.zon", 79 + "src", 80 + "README.md", 81 + }, 82 + }
+24
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1787964612, 6 + "narHash": "sha256-qeiZaY+0tpZXliRA9odCNx+sOfIOhnkcUq9t+ctPrRA=", 7 + "rev": "e8be7818e19ada32105a8af937a6a473b38167ca", 8 + "type": "tarball", 9 + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1063758.e8be7818e19a/nixexprs.tar.zst" 10 + }, 11 + "original": { 12 + "type": "tarball", 13 + "url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.zst" 14 + } 15 + }, 16 + "root": { 17 + "inputs": { 18 + "nixpkgs": "nixpkgs" 19 + } 20 + } 21 + }, 22 + "root": "root", 23 + "version": 7 24 + }
+49
flake.nix
··· 1 + # SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + # SPDX-License-Identifier: MIT 3 + 4 + { 5 + description = ""; 6 + 7 + inputs = { 8 + nixpkgs = { 9 + url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.zst"; 10 + }; 11 + }; 12 + 13 + outputs = 14 + { 15 + nixpkgs, 16 + ... 17 + }: 18 + let 19 + inherit (nixpkgs) lib; 20 + linuxSystems = builtins.filter ( 21 + system: (lib.systems.elaborate system).isLinux 22 + ) lib.systems.flakeExposed; 23 + makePackages = 24 + system: 25 + import nixpkgs { 26 + inherit system; 27 + }; 28 + forAllSystems = lib.genAttrs linuxSystems; 29 + in 30 + { 31 + devShells = forAllSystems ( 32 + system: 33 + let 34 + pkgs = makePackages system; 35 + in 36 + { 37 + default = pkgs.mkShell { 38 + name = "zsmtp"; 39 + nativeBuildInputs = [ 40 + pkgs.kcov 41 + pkgs.radicle-node 42 + pkgs.reuse 43 + pkgs.zig_0_16 44 + ]; 45 + }; 46 + } 47 + ); 48 + }; 49 + }
+18
LICENSES/MIT.txt
··· 1 + MIT License 2 + 3 + Copyright (c) <year> <copyright holders> 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 + associated documentation files (the "Software"), to deal in the Software without restriction, including 7 + without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 + copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 + following conditions: 10 + 11 + The above copyright notice and this permission notice shall be included in all copies or substantial 12 + portions of the Software. 13 + 14 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 + LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 + EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 + USE OR OTHER DEALINGS IN THE SOFTWARE.
+280
src/Client.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! An SMTP client session over any `Io.Reader`/`Io.Writer` pair, which keeps 5 + //! it transport-agnostic: wrap a TCP stream for real use, or fixed buffers 6 + //! for testing. TLS can be layered in the same way once the transport 7 + //! supports it. 8 + //! 9 + //! Typical use: 10 + //! ``` 11 + //! var client: Client = .init(&stream_reader, &stream_writer, &reply_buf); 12 + //! _ = try client.greet(); 13 + //! _ = try client.hello("my-host.example.com"); 14 + //! try client.sendMail("me@example.com", &.{"you@example.net"}, message); 15 + //! try client.quit(); 16 + //! ``` 17 + 18 + const Client = @This(); 19 + 20 + const std = @import("std"); 21 + const Io = std.Io; 22 + const protocol = @import("protocol.zig"); 23 + const Reply = protocol.Reply; 24 + 25 + reader: *Io.Reader, 26 + writer: *Io.Writer, 27 + /// Backing storage for reply text; `last_reply.text` points into it. 28 + reply_buffer: []u8, 29 + /// The most recent reply read from the server. Useful for reporting the 30 + /// server's actual response after an `error.UnexpectedReply`. 31 + last_reply: ?Reply = null, 32 + 33 + pub const Error = error{ 34 + WriteFailed, 35 + ReadFailed, 36 + EndOfStream, 37 + LineTooLong, 38 + InvalidReply, 39 + ReplyTooLong, 40 + /// The server answered with an unexpected code; see `last_reply`. 41 + UnexpectedReply, 42 + }; 43 + 44 + /// Extensions advertised in the server's EHLO response. 45 + pub const Extensions = struct { 46 + pipelining: bool = false, 47 + eight_bit_mime: bool = false, 48 + starttls: bool = false, 49 + smtputf8: bool = false, 50 + enhanced_status_codes: bool = false, 51 + auth: bool = false, 52 + /// Value of the SIZE extension, if advertised with a value. 53 + max_size: ?u64 = null, 54 + 55 + fn parse(reply: Reply) Extensions { 56 + var ext: Extensions = .{}; 57 + var it = reply.lines(); 58 + _ = it.next(); // The first line is the server's greeting, not a keyword. 59 + while (it.next()) |line| { 60 + const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; 61 + const kw = line[0..kw_end]; 62 + const arg = if (kw_end < line.len) line[kw_end + 1 ..] else ""; 63 + if (ieql(kw, "PIPELINING")) { 64 + ext.pipelining = true; 65 + } else if (ieql(kw, "8BITMIME")) { 66 + ext.eight_bit_mime = true; 67 + } else if (ieql(kw, "STARTTLS")) { 68 + ext.starttls = true; 69 + } else if (ieql(kw, "SMTPUTF8")) { 70 + ext.smtputf8 = true; 71 + } else if (ieql(kw, "ENHANCEDSTATUSCODES")) { 72 + ext.enhanced_status_codes = true; 73 + } else if (ieql(kw, "AUTH")) { 74 + ext.auth = true; 75 + } else if (ieql(kw, "SIZE")) { 76 + ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null; 77 + } 78 + } 79 + return ext; 80 + } 81 + 82 + fn ieql(a: []const u8, b: []const u8) bool { 83 + return std.ascii.eqlIgnoreCase(a, b); 84 + } 85 + }; 86 + 87 + /// `reply_buffer` must be large enough for the largest expected reply text 88 + /// (the EHLO response is usually the largest); 512 bytes is plenty in 89 + /// practice. 90 + pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client { 91 + return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer }; 92 + } 93 + 94 + /// Reads the server's 220 greeting. Call once, right after connecting. 95 + pub fn greet(c: *Client) Error!Reply { 96 + return c.expect(220); 97 + } 98 + 99 + /// Sends EHLO and returns the extensions the server advertised, falling back 100 + /// to plain HELO for servers that do not speak ESMTP. 101 + pub fn hello(c: *Client, client_name: []const u8) Error!Extensions { 102 + try c.send("EHLO {s}", .{client_name}); 103 + const reply = try c.readReply(); 104 + if (reply.isPositiveCompletion()) return Extensions.parse(reply); 105 + if (reply.code == 500 or reply.code == 502) { 106 + try c.send("HELO {s}", .{client_name}); 107 + _ = try c.expectClass(2); 108 + return .{}; 109 + } 110 + return error.UnexpectedReply; 111 + } 112 + 113 + /// Authenticates with AUTH PLAIN (RFC 4616). Pass an empty `authzid` unless 114 + /// you need to act on behalf of another identity. Note that sending 115 + /// credentials over an unencrypted connection exposes them to the network. 116 + pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) (Error || error{CredentialsTooLong})!void { 117 + var plain_buf: [512]u8 = undefined; 118 + var plain: Io.Writer = .fixed(&plain_buf); 119 + plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch 120 + return error.CredentialsTooLong; 121 + var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined; 122 + const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered()); 123 + try c.send("AUTH PLAIN {s}", .{b64}); 124 + _ = try c.expect(235); 125 + } 126 + 127 + /// Starts a mail transaction. An empty `from` sends the null reverse-path 128 + /// (`MAIL FROM:<>`), used for bounces. 129 + pub fn mailFrom(c: *Client, from: []const u8) Error!void { 130 + try c.send("MAIL FROM:<{s}>", .{from}); 131 + _ = try c.expectClass(2); 132 + } 133 + 134 + pub fn rcptTo(c: *Client, to: []const u8) Error!void { 135 + try c.send("RCPT TO:<{s}>", .{to}); 136 + _ = try c.expectClass(2); 137 + } 138 + 139 + /// Sends the message content for the current transaction (DATA). Line 140 + /// endings in `data` are normalized to CRLF and leading dots are stuffed. 141 + pub fn sendMessage(c: *Client, data: []const u8) Error!void { 142 + try c.send("DATA", .{}); 143 + _ = try c.expect(354); 144 + try protocol.writeStuffed(c.writer, data); 145 + try c.writer.writeAll("." ++ protocol.crlf); 146 + try c.writer.flush(); 147 + _ = try c.expectClass(2); 148 + } 149 + 150 + /// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient, 151 + /// then DATA. Call after `greet` and `hello`. 152 + pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, data: []const u8) Error!void { 153 + try c.mailFrom(from); 154 + for (recipients) |recipient| try c.rcptTo(recipient); 155 + try c.sendMessage(data); 156 + } 157 + 158 + /// Aborts the current mail transaction. 159 + pub fn rset(c: *Client) Error!void { 160 + try c.send("RSET", .{}); 161 + _ = try c.expectClass(2); 162 + } 163 + 164 + pub fn noop(c: *Client) Error!void { 165 + try c.send("NOOP", .{}); 166 + _ = try c.expectClass(2); 167 + } 168 + 169 + /// Ends the session. The connection should be closed afterwards. 170 + pub fn quit(c: *Client) Error!void { 171 + try c.send("QUIT", .{}); 172 + _ = try c.expect(221); 173 + } 174 + 175 + fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void { 176 + try c.writer.print(fmt ++ protocol.crlf, args); 177 + try c.writer.flush(); 178 + } 179 + 180 + fn readReply(c: *Client) Error!Reply { 181 + const reply = try Reply.read(c.reader, c.reply_buffer); 182 + c.last_reply = reply; 183 + return reply; 184 + } 185 + 186 + fn expect(c: *Client, code: u16) Error!Reply { 187 + const reply = try c.readReply(); 188 + if (reply.code != code) return error.UnexpectedReply; 189 + return reply; 190 + } 191 + 192 + fn expectClass(c: *Client, class: u16) Error!Reply { 193 + const reply = try c.readReply(); 194 + if (reply.code / 100 != class) return error.UnexpectedReply; 195 + return reply; 196 + } 197 + 198 + test "full transaction against a scripted server" { 199 + const responses = "220 mx.example.com ESMTP\r\n" ++ 200 + "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++ 201 + "250 2.1.0 Ok\r\n" ++ 202 + "250 2.1.5 Ok\r\n" ++ 203 + "354 End data with <CR><LF>.<CR><LF>\r\n" ++ 204 + "250 2.0.0 Ok\r\n" ++ 205 + "221 2.0.0 Bye\r\n"; 206 + var reader: Io.Reader = .fixed(responses); 207 + var out_buf: [1024]u8 = undefined; 208 + var writer: Io.Writer = .fixed(&out_buf); 209 + var reply_buf: [512]u8 = undefined; 210 + var client: Client = .init(&reader, &writer, &reply_buf); 211 + 212 + _ = try client.greet(); 213 + const ext = try client.hello("client.example.org"); 214 + try std.testing.expect(ext.pipelining); 215 + try std.testing.expect(ext.eight_bit_mime); 216 + try std.testing.expect(!ext.starttls); 217 + try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size); 218 + 219 + try client.sendMail( 220 + "alice@example.com", 221 + &.{"bob@example.net"}, 222 + "Subject: hi\r\n\r\n.leading dot\r\n", 223 + ); 224 + try client.quit(); 225 + 226 + try std.testing.expectEqualStrings( 227 + "EHLO client.example.org\r\n" ++ 228 + "MAIL FROM:<alice@example.com>\r\n" ++ 229 + "RCPT TO:<bob@example.net>\r\n" ++ 230 + "DATA\r\n" ++ 231 + "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++ 232 + "QUIT\r\n", 233 + writer.buffered(), 234 + ); 235 + } 236 + 237 + test "HELO fallback for non-ESMTP servers" { 238 + const responses = "220 old.example.com\r\n" ++ 239 + "502 command not implemented\r\n" ++ 240 + "250 old.example.com\r\n"; 241 + var reader: Io.Reader = .fixed(responses); 242 + var out_buf: [256]u8 = undefined; 243 + var writer: Io.Writer = .fixed(&out_buf); 244 + var reply_buf: [256]u8 = undefined; 245 + var client: Client = .init(&reader, &writer, &reply_buf); 246 + 247 + _ = try client.greet(); 248 + const ext = try client.hello("client.example.org"); 249 + try std.testing.expectEqual(Extensions{}, ext); 250 + try std.testing.expectEqualStrings( 251 + "EHLO client.example.org\r\nHELO client.example.org\r\n", 252 + writer.buffered(), 253 + ); 254 + } 255 + 256 + test "rejected recipient surfaces the reply" { 257 + const responses = "550 5.1.1 No such user\r\n"; 258 + var reader: Io.Reader = .fixed(responses); 259 + var out_buf: [256]u8 = undefined; 260 + var writer: Io.Writer = .fixed(&out_buf); 261 + var reply_buf: [256]u8 = undefined; 262 + var client: Client = .init(&reader, &writer, &reply_buf); 263 + 264 + try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com")); 265 + try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code); 266 + try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text); 267 + } 268 + 269 + test "authPlain encodes credentials" { 270 + const responses = "235 2.7.0 Accepted\r\n"; 271 + var reader: Io.Reader = .fixed(responses); 272 + var out_buf: [256]u8 = undefined; 273 + var writer: Io.Writer = .fixed(&out_buf); 274 + var reply_buf: [256]u8 = undefined; 275 + var client: Client = .init(&reader, &writer, &reply_buf); 276 + 277 + try client.authPlain("", "user", "pass"); 278 + // base64("\x00user\x00pass") 279 + try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 280 + }
+413
src/Server.zig
··· 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 + 18 + const Server = @This(); 19 + 20 + const std = @import("std"); 21 + const Io = std.Io; 22 + const protocol = @import("protocol.zig"); 23 + 24 + reader: *Io.Reader, 25 + writer: *Io.Writer, 26 + handler: Handler, 27 + options: Options, 28 + 29 + pub 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. 38 + pub 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 + 49 + pub 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. 57 + pub 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 + 72 + pub 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 + 76 + pub 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. 81 + pub 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. 201 + fn 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 + 238 + fn 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. 245 + fn 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 + 252 + const 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 + 297 + fn 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 + 305 + test "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 + 345 + test "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 + 367 + test "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 + 391 + test "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 + }
+119
src/main.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! Demo CLI for the zsmtp library. 5 + //! 6 + //! zsmtp send <host> <port> <from> <to>... send a message read from stdin 7 + //! zsmtp serve <port> run a debug server on 127.0.0.1 8 + //! that prints received messages 9 + 10 + const std = @import("std"); 11 + const Io = std.Io; 12 + const zsmtp = @import("zsmtp"); 13 + 14 + pub fn main(init: std.process.Init) !void { 15 + const arena = init.arena.allocator(); 16 + const io = init.io; 17 + const args = try init.minimal.args.toSlice(arena); 18 + 19 + if (args.len >= 6 and std.mem.eql(u8, args[1], "send")) { 20 + return send(io, arena, args[2], args[3], args[4], args[5..]); 21 + } 22 + if (args.len == 3 and std.mem.eql(u8, args[1], "serve")) { 23 + return serve(io, arena, args[2]); 24 + } 25 + std.log.err( 26 + \\usage: 27 + \\ zsmtp send <host> <port> <from> <to>... (message is read from stdin) 28 + \\ zsmtp serve <port> 29 + , .{}); 30 + std.process.exit(1); 31 + } 32 + 33 + fn send( 34 + io: Io, 35 + arena: std.mem.Allocator, 36 + host_arg: []const u8, 37 + port_arg: []const u8, 38 + from: []const u8, 39 + recipients: []const []const u8, 40 + ) !void { 41 + const host = try Io.net.HostName.init(host_arg); 42 + const port = try std.fmt.parseInt(u16, port_arg, 10); 43 + 44 + var stdin_buf: [4096]u8 = undefined; 45 + var stdin: Io.File.Reader = .init(.stdin(), io, &stdin_buf); 46 + const message = try stdin.interface.allocRemaining(arena, .unlimited); 47 + 48 + const stream = try host.connect(io, port, .{ .mode = .stream }); 49 + defer stream.close(io); 50 + var read_buf: [4096]u8 = undefined; 51 + var write_buf: [4096]u8 = undefined; 52 + var stream_reader = stream.reader(io, &read_buf); 53 + var stream_writer = stream.writer(io, &write_buf); 54 + 55 + var reply_buf: [1024]u8 = undefined; 56 + var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 57 + 58 + _ = try client.greet(); 59 + _ = try client.hello("localhost"); 60 + client.sendMail(from, recipients, message) catch |err| { 61 + if (err == error.UnexpectedReply) { 62 + const reply = client.last_reply.?; 63 + std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text }); 64 + } 65 + return err; 66 + }; 67 + try client.quit(); 68 + std.log.info("message sent to {d} recipient(s)", .{recipients.len}); 69 + } 70 + 71 + fn serve(io: Io, gpa: std.mem.Allocator, port_arg: []const u8) !void { 72 + const port = try std.fmt.parseInt(u16, port_arg, 10); 73 + const address: Io.net.IpAddress = .{ .ip4 = .loopback(port) }; 74 + var listener = try address.listen(io, .{}); 75 + defer listener.deinit(io); 76 + std.log.info("listening on 127.0.0.1:{d}", .{port}); 77 + 78 + var stdout_buf: [4096]u8 = undefined; 79 + var stdout: Io.File.Writer = .init(.stdout(), io, &stdout_buf); 80 + 81 + var printer: MessagePrinter = .{ .out = &stdout.interface }; 82 + while (true) { 83 + const stream = try listener.accept(io); 84 + defer stream.close(io); 85 + var read_buf: [4096]u8 = undefined; 86 + var write_buf: [4096]u8 = undefined; 87 + var stream_reader = stream.reader(io, &read_buf); 88 + var stream_writer = stream.writer(io, &write_buf); 89 + var session: zsmtp.Server = .init( 90 + &stream_reader.interface, 91 + &stream_writer.interface, 92 + .{ .context = &printer, .vtable = &.{ .message = MessagePrinter.onMessage } }, 93 + .{ .hostname = "localhost" }, 94 + ); 95 + session.run(gpa) catch |err| { 96 + std.log.warn("session ended with error: {t}", .{err}); 97 + }; 98 + } 99 + } 100 + 101 + const MessagePrinter = struct { 102 + out: *Io.Writer, 103 + 104 + fn onMessage(context: ?*anyopaque, envelope: zsmtp.Server.Envelope, data: []const u8) zsmtp.Server.Decision { 105 + const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 106 + printer.print(envelope, data) catch 107 + return .{ .reject = .{ .code = 451, .text = "4.3.0 Local error" } }; 108 + return .accept; 109 + } 110 + 111 + fn print(printer: *MessagePrinter, envelope: zsmtp.Server.Envelope, data: []const u8) !void { 112 + try printer.out.print("--- message from <{s}> to", .{envelope.from}); 113 + for (envelope.recipients) |recipient| { 114 + try printer.out.print(" <{s}>", .{recipient}); 115 + } 116 + try printer.out.print(" ({d} bytes)\n{s}---\n", .{ data.len, data }); 117 + try printer.out.flush(); 118 + } 119 + };
+310
src/protocol.zig
··· 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 + 8 + const std = @import("std"); 9 + const Io = std.Io; 10 + 11 + pub const crlf = "\r\n"; 12 + 13 + pub 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. 23 + pub 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. 33 + pub 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. 102 + pub 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 + /// Unrecognized command verb; the payload is the full line. 116 + unknown: []const u8, 117 + 118 + pub const PathArgs = struct { 119 + /// The mailbox, with angle brackets and any obsolete source route 120 + /// stripped. 121 + path: []const u8, 122 + /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024". 123 + params: []const u8 = "", 124 + }; 125 + 126 + pub const ParseError = error{Syntax}; 127 + 128 + /// Parses one command line (without its line ending). Returned slices 129 + /// point into `line`. 130 + pub fn parse(line: []const u8) ParseError!Command { 131 + const trimmed = std.mem.trim(u8, line, " \t"); 132 + const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len; 133 + const verb = trimmed[0..verb_end]; 134 + const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t"); 135 + 136 + if (ieql(verb, "HELO")) { 137 + if (rest.len == 0) return error.Syntax; 138 + return .{ .helo = rest }; 139 + } 140 + if (ieql(verb, "EHLO")) { 141 + if (rest.len == 0) return error.Syntax; 142 + return .{ .ehlo = rest }; 143 + } 144 + if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") }; 145 + if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") }; 146 + if (ieql(verb, "DATA")) return .data; 147 + if (ieql(verb, "RSET")) return .rset; 148 + if (ieql(verb, "NOOP")) return .noop; 149 + if (ieql(verb, "QUIT")) return .quit; 150 + if (ieql(verb, "VRFY")) return .{ .vrfy = rest }; 151 + if (ieql(verb, "HELP")) return .help; 152 + return .{ .unknown = line }; 153 + } 154 + 155 + fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs { 156 + if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword)) 157 + return error.Syntax; 158 + const after = std.mem.trimStart(u8, rest[keyword.len..], " \t"); 159 + if (after.len == 0 or after[0] != '<') { 160 + // Lenient: accept a bare address ending at whitespace. 161 + const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len; 162 + if (end == 0) return error.Syntax; 163 + return .{ 164 + .path = after[0..end], 165 + .params = std.mem.trimStart(u8, after[end..], " \t"), 166 + }; 167 + } 168 + const close = std.mem.indexOfScalar(u8, after, '>') orelse return error.Syntax; 169 + var path = after[1..close]; 170 + // Strip an obsolete source route: <@relay1,@relay2:user@host>. 171 + if (path.len > 0 and path[0] == '@') { 172 + const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax; 173 + path = path[colon + 1 ..]; 174 + } 175 + return .{ 176 + .path = path, 177 + .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"), 178 + }; 179 + } 180 + 181 + fn ieql(a: []const u8, b: []const u8) bool { 182 + return std.ascii.eqlIgnoreCase(a, b); 183 + } 184 + }; 185 + 186 + /// Writes `data` as SMTP message content: line endings are normalized to CRLF 187 + /// and lines beginning with '.' are dot-stuffed (RFC 5321 §4.5.2). Does not 188 + /// write the terminating ".\r\n". 189 + pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void { 190 + var rest = data; 191 + while (rest.len > 0) { 192 + var line: []const u8 = undefined; 193 + if (std.mem.indexOfScalar(u8, rest, '\n')) |i| { 194 + line = rest[0..i]; 195 + rest = rest[i + 1 ..]; 196 + } else { 197 + line = rest; 198 + rest = rest[rest.len..]; 199 + } 200 + if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; 201 + if (line.len > 0 and line[0] == '.') try writer.writeByte('.'); 202 + try writer.writeAll(line); 203 + try writer.writeAll(crlf); 204 + } 205 + } 206 + 207 + test "readLine strips CRLF and LF" { 208 + var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n"); 209 + try std.testing.expectEqualStrings("first", try readLine(&reader)); 210 + try std.testing.expectEqualStrings("second", try readLine(&reader)); 211 + try std.testing.expectEqualStrings("third", try readLine(&reader)); 212 + try std.testing.expectError(error.EndOfStream, readLine(&reader)); 213 + } 214 + 215 + test "Reply.read single line" { 216 + var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 217 + var buf: [128]u8 = undefined; 218 + const reply = try Reply.read(&reader, &buf); 219 + try std.testing.expectEqual(@as(u16, 250), reply.code); 220 + try std.testing.expectEqualStrings("2.0.0 Ok", reply.text); 221 + try std.testing.expect(reply.isPositiveCompletion()); 222 + } 223 + 224 + test "Reply.read multiline" { 225 + var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n"); 226 + var buf: [128]u8 = undefined; 227 + const reply = try Reply.read(&reader, &buf); 228 + try std.testing.expectEqual(@as(u16, 250), reply.code); 229 + try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text); 230 + var it = reply.lines(); 231 + try std.testing.expectEqualStrings("mx.example.com", it.next().?); 232 + try std.testing.expectEqualStrings("PIPELINING", it.next().?); 233 + try std.testing.expectEqualStrings("SIZE 1000", it.next().?); 234 + try std.testing.expectEqual(@as(?[]const u8, null), it.next()); 235 + } 236 + 237 + test "Reply.read rejects malformed replies" { 238 + var buf: [128]u8 = undefined; 239 + { 240 + var reader: Io.Reader = .fixed("2x0 hello\r\n"); 241 + try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 242 + } 243 + { 244 + var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n"); 245 + try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 246 + } 247 + { 248 + var reader: Io.Reader = .fixed("42\r\n"); 249 + try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 250 + } 251 + } 252 + 253 + test "Command.parse" { 254 + { 255 + const cmd = try Command.parse("EHLO client.example.com"); 256 + try std.testing.expectEqualStrings("client.example.com", cmd.ehlo); 257 + } 258 + { 259 + const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024"); 260 + try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path); 261 + try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params); 262 + } 263 + { 264 + // Null reverse-path and a space after the colon. 265 + const cmd = try Command.parse("MAIL FROM: <>"); 266 + try std.testing.expectEqualStrings("", cmd.mail.path); 267 + } 268 + { 269 + // Obsolete source route is stripped. 270 + const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>"); 271 + try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path); 272 + } 273 + { 274 + const cmd = try Command.parse("QUIT"); 275 + try std.testing.expectEqual(Command.quit, cmd); 276 + } 277 + { 278 + const cmd = try Command.parse("MADE UP"); 279 + try std.testing.expectEqualStrings("MADE UP", cmd.unknown); 280 + } 281 + try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>")); 282 + try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:")); 283 + try std.testing.expectError(error.Syntax, Command.parse("HELO")); 284 + } 285 + 286 + test "writeStuffed" { 287 + var buf: [256]u8 = undefined; 288 + { 289 + var w: Io.Writer = .fixed(&buf); 290 + try writeStuffed(&w, "line one\r\n.starts with dot\r\n"); 291 + try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered()); 292 + } 293 + { 294 + // LF-only input is normalized, missing final newline is added. 295 + var w: Io.Writer = .fixed(&buf); 296 + try writeStuffed(&w, "a\nb"); 297 + try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered()); 298 + } 299 + { 300 + // A lone "." line must not become a terminator. 301 + var w: Io.Writer = .fixed(&buf); 302 + try writeStuffed(&w, ".\n"); 303 + try std.testing.expectEqualStrings("..\r\n", w.buffered()); 304 + } 305 + { 306 + var w: Io.Writer = .fixed(&buf); 307 + try writeStuffed(&w, ""); 308 + try std.testing.expectEqualStrings("", w.buffered()); 309 + } 310 + }
+23
src/root.zig
··· 1 + // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + // SPDX-License-Identifier: MIT 3 + 4 + //! zsmtp — an SMTP client and server library for Zig (RFC 5321). 5 + //! 6 + //! Both `Client` and `Server` run over plain `std.Io.Reader`/`std.Io.Writer` 7 + //! pairs, so they work with any transport: TCP streams, in-memory buffers in 8 + //! tests, or (eventually) a TLS layer. The lower-level protocol pieces — 9 + //! reply parsing, command parsing, dot-stuffing — are exposed via `protocol`. 10 + 11 + const std = @import("std"); 12 + 13 + pub const protocol = @import("protocol.zig"); 14 + pub const Reply = protocol.Reply; 15 + pub const Command = protocol.Command; 16 + pub const Client = @import("Client.zig"); 17 + pub const Server = @import("Server.zig"); 18 + 19 + test { 20 + _ = protocol; 21 + _ = Client; 22 + _ = Server; 23 + }