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.

Add Nix package and NixOS VM interop tests

nix/package.nix builds zsmtp with zig_0_16 and runs the unit tests; the
tls.zig dependency is provided offline by materializing it into the
project-local zig-pkg/<hash>/ directory with only the files from the
dependency's paths list, so Zig's content hash matches.

nix/interop-test.nix exercises zsmtp against third-party
implementations in one VM:
- zsmtp client -> Postfix: plaintext (25), STARTTLS (25), implicit TLS
(465, submissions wrapper mode), verified via alice's maildir spool
- swaks -> zsmtp server: plaintext and STARTTLS (snakeoil EC cert),
verified via the server's journal

Exposed as packages.zsmtp/default and checks.{zsmtp,interop}. Postfix
on NixOS delivers maildir-style (mail_spool_directory has a trailing
slash), so assertions grep the directory recursively, with 60s
timeouts to fail fast.

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

+235
+2
.gitignore
··· 4 4 .zig-cache/ 5 5 zig-out/ 6 6 zig-pkg/ 7 + result 8 + result-*
+10
README.md
··· 134 134 ```sh 135 135 zig build test 136 136 ``` 137 + 138 + Interoperability against third-party implementations is covered by a NixOS 139 + VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix 140 + over plaintext, STARTTLS, and implicit TLS, and swaks delivers to the zsmtp 141 + server over plaintext and STARTTLS. 142 + 143 + ```sh 144 + nix build .#zsmtp # build the package 145 + nix build .#checks.x86_64-linux.interop # run the VM interop test 146 + ```
+22
flake.nix
··· 28 28 forAllSystems = lib.genAttrs linuxSystems; 29 29 in 30 30 { 31 + packages = forAllSystems ( 32 + system: 33 + let 34 + pkgs = makePackages system; 35 + in 36 + rec { 37 + zsmtp = pkgs.callPackage ./nix/package.nix { }; 38 + default = zsmtp; 39 + } 40 + ); 41 + 42 + checks = forAllSystems ( 43 + system: 44 + let 45 + pkgs = makePackages system; 46 + in 47 + { 48 + zsmtp = pkgs.callPackage ./nix/package.nix { }; 49 + interop = pkgs.callPackage ./nix/interop-test.nix { }; 50 + } 51 + ); 52 + 31 53 devShells = forAllSystems ( 32 54 system: 33 55 let
+142
nix/interop-test.nix
··· 1 + # SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + # SPDX-License-Identifier: MIT 3 + 4 + # NixOS VM test exercising zsmtp against third-party implementations: 5 + # - zsmtp client -> Postfix: plaintext, STARTTLS (port 25), implicit TLS 6 + # (port 465), verified by checking local delivery to alice's spool 7 + # - swaks -> zsmtp server: plaintext and STARTTLS, verified by checking 8 + # the received message in the server's journal 9 + 10 + { 11 + testers, 12 + callPackage, 13 + runCommand, 14 + openssl, 15 + }: 16 + let 17 + zsmtp = callPackage ./package.nix { }; 18 + 19 + snakeoil = 20 + runCommand "zsmtp-test-cert" 21 + { 22 + nativeBuildInputs = [ openssl ]; 23 + } 24 + '' 25 + mkdir -p $out 26 + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ 27 + -keyout $out/key.pem -out $out/cert.pem -days 36500 -nodes \ 28 + -subj "/CN=localhost" \ 29 + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" 30 + ''; 31 + in 32 + testers.runNixOSTest { 33 + name = "zsmtp-interop"; 34 + 35 + nodes.machine = 36 + { pkgs, ... }: 37 + { 38 + environment.systemPackages = [ 39 + zsmtp 40 + pkgs.swaks 41 + ]; 42 + 43 + users.users.alice.isNormalUser = true; 44 + 45 + services.postfix = { 46 + enable = true; 47 + # Implicit-TLS smtpd on port 465; allow loopback without SASL. 48 + enableSubmissions = true; 49 + submissionsOptions = { 50 + smtpd_client_restrictions = "permit_mynetworks,reject"; 51 + }; 52 + settings.main = { 53 + mydestination = [ 54 + "localhost" 55 + "$myhostname" 56 + ]; 57 + smtpd_tls_security_level = "may"; 58 + smtpd_tls_chain_files = [ 59 + "${snakeoil}/key.pem" 60 + "${snakeoil}/cert.pem" 61 + ]; 62 + }; 63 + }; 64 + 65 + systemd.services.zsmtp-server = { 66 + description = "zsmtp debug server (plaintext)"; 67 + wantedBy = [ "multi-user.target" ]; 68 + serviceConfig = { 69 + ExecStart = "${zsmtp}/bin/zsmtp serve 2525"; 70 + DynamicUser = true; 71 + }; 72 + }; 73 + 74 + systemd.services.zsmtp-server-tls = { 75 + description = "zsmtp debug server (STARTTLS)"; 76 + wantedBy = [ "multi-user.target" ]; 77 + serviceConfig = { 78 + ExecStart = "${zsmtp}/bin/zsmtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem 2526"; 79 + DynamicUser = true; 80 + }; 81 + }; 82 + }; 83 + 84 + testScript = '' 85 + machine.wait_for_unit("postfix.service") 86 + machine.wait_for_open_port(25) 87 + machine.wait_for_open_port(465) 88 + machine.wait_for_unit("zsmtp-server.service") 89 + machine.wait_for_unit("zsmtp-server-tls.service") 90 + machine.wait_for_open_port(2525) 91 + machine.wait_for_open_port(2526) 92 + 93 + with subtest("zsmtp client to postfix, plaintext"): 94 + machine.succeed( 95 + "printf 'Subject: interop\\r\\n\\r\\nzsmtp to postfix plain\\r\\n'" 96 + " | zsmtp send 127.0.0.1 25 bob@example.com alice@localhost" 97 + ) 98 + machine.wait_until_succeeds( 99 + "grep -r 'zsmtp to postfix plain' /var/spool/mail/alice/", timeout=60 100 + ) 101 + 102 + with subtest("zsmtp client to postfix, STARTTLS"): 103 + machine.succeed( 104 + "printf 'Subject: interop\\r\\n\\r\\nzsmtp to postfix starttls\\r\\n'" 105 + " | zsmtp send --starttls --insecure 127.0.0.1 25" 106 + " bob@example.com alice@localhost" 107 + ) 108 + machine.wait_until_succeeds( 109 + "grep -r 'zsmtp to postfix starttls' /var/spool/mail/alice/", timeout=60 110 + ) 111 + 112 + with subtest("zsmtp client to postfix, implicit TLS"): 113 + machine.succeed( 114 + "printf 'Subject: interop\\r\\n\\r\\nzsmtp to postfix smtps\\r\\n'" 115 + " | zsmtp send --tls --insecure 127.0.0.1 465" 116 + " bob@example.com alice@localhost" 117 + ) 118 + machine.wait_until_succeeds( 119 + "grep -r 'zsmtp to postfix smtps' /var/spool/mail/alice/", timeout=60 120 + ) 121 + 122 + with subtest("swaks to zsmtp server, plaintext"): 123 + machine.succeed( 124 + "swaks --server 127.0.0.1:2525 --from bob@example.com" 125 + " --to alice@example.net --header 'Subject: swaks plain'" 126 + " --body 'swaks to zsmtp plain'" 127 + ) 128 + machine.wait_until_succeeds( 129 + "journalctl -u zsmtp-server | grep 'swaks to zsmtp plain'", timeout=60 130 + ) 131 + 132 + with subtest("swaks to zsmtp server, STARTTLS"): 133 + machine.succeed( 134 + "swaks --tls --server 127.0.0.1:2526 --from bob@example.com" 135 + " --to alice@example.net --header 'Subject: swaks starttls'" 136 + " --body 'swaks to zsmtp starttls'" 137 + ) 138 + machine.wait_until_succeeds( 139 + "journalctl -u zsmtp-server-tls | grep 'swaks to zsmtp starttls'", timeout=60 140 + ) 141 + ''; 142 + }
+59
nix/package.nix
··· 1 + # SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2 + # SPDX-License-Identifier: MIT 3 + 4 + { 5 + lib, 6 + stdenv, 7 + zig_0_16, 8 + fetchzip, 9 + }: 10 + let 11 + # Must match the url/hash pinned in build.zig.zon. Only the files listed in 12 + # the dependency's `paths` may end up in zig-pkg/, since Zig hashes the 13 + # package contents against the directory name. 14 + tlsDep = fetchzip { 15 + name = "tls.zig-src"; 16 + url = "https://github.com/ianic/tls.zig/archive/e04ae448ce7ee70c136d4d48b059314543203809.tar.gz"; 17 + hash = "sha256-afPTfauIV49IATX0szuOdKLloyqI4XKsyNk8KkasAvg="; 18 + }; 19 + tlsDepId = "tls-0.1.0-ER2e0jGpBgCkVC-Yp12NgSdHNUtZr52MleJ8roHlUa54"; 20 + in 21 + stdenv.mkDerivation { 22 + pname = "zsmtp"; 23 + version = "0.0.0"; 24 + 25 + src = lib.cleanSource ../.; 26 + 27 + nativeBuildInputs = [ zig_0_16 ]; 28 + 29 + # Provide the dependency offline through the project-local package 30 + # directory, which the build consults before fetching. 31 + postPatch = '' 32 + mkdir -p zig-pkg/${tlsDepId} 33 + cp -r ${tlsDep}/{build.zig,build.zig.zon,readme.md,src} zig-pkg/${tlsDepId}/ 34 + ''; 35 + 36 + dontConfigure = true; 37 + 38 + buildPhase = '' 39 + runHook preBuild 40 + export ZIG_GLOBAL_CACHE_DIR=$TMPDIR/zig-global-cache 41 + zig build install -Doptimize=ReleaseSafe --prefix $out 42 + runHook postBuild 43 + ''; 44 + 45 + doCheck = true; 46 + checkPhase = '' 47 + runHook preCheck 48 + zig build test 49 + runHook postCheck 50 + ''; 51 + 52 + dontInstall = true; 53 + 54 + meta = { 55 + description = "SMTP client and server library for Zig"; 56 + license = lib.licenses.mit; 57 + mainProgram = "zsmtp"; 58 + }; 59 + }