{ description = "Gateway bridging a smolmail account and an SMTP mailbox"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; fumi = { url = "git+ssh://git@code.randogoth.com:2222/randogoth/fumi.git"; flake = false; }; }; outputs = { self, nixpkgs, flake-utils, fumi }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; pneumatubePkg = pkgs.rustPlatform.buildRustPackage { pname = "pneumatube"; version = "0.1.0"; src = ./.; cargoLock.lockFile = ./Cargo.lock; # Cargo.toml path-depends on a sibling fumi checkout (../fumi/core), # the same convention the devbox/docker workflows document. The # build cannot see the sibling, so the flake input's checkout is # placed inside the source and the path rewritten to match; the # lockfile entry of a path dependency carries no checksum, so it # stays valid for either location. postPatch = '' substituteInPlace Cargo.toml \ --replace-fail 'path = "../fumi/core"' 'path = "fumi/core"' mkdir -p fumi cp -r ${fumi}/core fumi/core ''; # The unit tests are offline (stubs and an in-memory store). doCheck = true; }; in { packages.default = pneumatubePkg; devShells.default = pkgs.mkShell { packages = with pkgs; [ cargo rustc rustfmt clippy sqlite ]; }; } ) // { nixosModules.default = { config, lib, pkgs, ... }: let cfg = config.services.pneumatube; stateDir = "/var/lib/pneumatube"; in { options.services.pneumatube = with lib; { enable = mkEnableOption "the pneumatube smolmail/SMTP gateway"; package = mkOption { type = types.package; default = self.packages.${pkgs.system}.default; description = "pneumatube package to run."; }; smolmailDomain = mkOption { type = types.str; description = '' The smolmail server address used in "user@..." addresses for both directions (e.g. "smol.example.org" or "host:port"). ''; }; emailDomain = mkOption { type = types.str; description = "Email domain this gateway accepts inbound mail for."; }; fetchInterval = mkOption { type = types.ints.positive; default = 30; description = "Seconds between smolmail inbox polls."; }; listenHost = mkOption { type = types.str; default = "127.0.0.1"; description = "Bind address for the LMTP delivery backend."; }; listenPort = mkOption { type = types.port; default = 2424; }; lookupHost = mkOption { type = types.str; default = "127.0.0.1"; description = '' Bind address for the Postfix check_recipient_access lookup service (tcp_table(5)), which turns an unknown recipient into a live RCPT-time reject instead of a queued-then- bounced message. ''; }; lookupPort = mkOption { type = types.port; default = 2526; }; fromAddress = mkOption { type = types.str; description = "From: address used on mail forwarded to SMTP."; }; relayHost = mkOption { type = types.str; description = "Outbound SMTP relay for the smolmail -> email direction."; }; relayPort = mkOption { type = types.port; default = 587; }; relayUser = mkOption { type = types.nullOr types.str; default = null; }; relayPasswordFile = mkOption { type = types.nullOr types.path; default = null; description = '' Path, outside the Nix store, to a systemd EnvironmentFile containing a single `PNEUMATUBE_SMTP_RELAY_PASSWORD=...` line -- e.g. provisioned by sops-nix or agenix. Never set the password through another option: it would end up world-readable in the Nix store. ''; }; relayStartTls = mkOption { type = types.bool; default = true; }; postfix.enable = mkEnableOption '' a local Postfix configured to front this gateway over LMTP: relay_domains/transport for emailDomain, and check_recipient_access against the lookup service above. Set to false to wire up Postfix (on this host or elsewhere) yourself ''; }; config = lib.mkIf cfg.enable { systemd.services.pneumatube = { description = "pneumatube smolmail/SMTP gateway"; after = [ "network.target" ] ++ lib.optional cfg.postfix.enable "postfix.service"; wantedBy = [ "multi-user.target" ]; # The gateway's own smolmail account (key + store) is # bootstrapped with the fumi CLI against these paths, the # same way any smolmail client registers an account: # fumi --key /var/lib/pneumatube/gateway.key \ # --db /var/lib/pneumatube/gateway.db keygen # fumi ... trust # fumi ... register @ environment = { PNEUMATUBE_SMOLMAIL_KEY = "${stateDir}/gateway.key"; PNEUMATUBE_SMOLMAIL_DB = "${stateDir}/gateway.db"; PNEUMATUBE_SMOLMAIL_DOMAIN = cfg.smolmailDomain; PNEUMATUBE_EMAIL_DOMAIN = cfg.emailDomain; PNEUMATUBE_FETCH_INTERVAL = toString cfg.fetchInterval; PNEUMATUBE_LISTEN_PROTOCOL = "lmtp"; PNEUMATUBE_LISTEN_HOST = cfg.listenHost; PNEUMATUBE_LISTEN_PORT = toString cfg.listenPort; PNEUMATUBE_LOOKUP_HOST = cfg.lookupHost; PNEUMATUBE_LOOKUP_PORT = toString cfg.lookupPort; PNEUMATUBE_SMTP_RELAY_HOST = cfg.relayHost; PNEUMATUBE_SMTP_RELAY_PORT = toString cfg.relayPort; PNEUMATUBE_SMTP_RELAY_STARTTLS = if cfg.relayStartTls then "1" else "0"; PNEUMATUBE_GATEWAY_FROM_ADDRESS = cfg.fromAddress; } // lib.optionalAttrs (cfg.relayUser != null) { PNEUMATUBE_SMTP_RELAY_USER = cfg.relayUser; }; serviceConfig = { ExecStart = "${cfg.package}/bin/pneumatube"; DynamicUser = true; StateDirectory = "pneumatube"; WorkingDirectory = stateDir; NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; Restart = "on-failure"; RestartSec = "5s"; } // lib.optionalAttrs (cfg.relayPasswordFile != null) { EnvironmentFile = cfg.relayPasswordFile; }; }; services.postfix = lib.mkIf cfg.postfix.enable { enable = true; settings.main = { relay_domains = [ cfg.emailDomain ]; smtpd_relay_restrictions = [ "permit_mynetworks" "reject_unauth_destination" ]; smtpd_recipient_restrictions = [ "check_recipient_access tcp:${cfg.lookupHost}:${toString cfg.lookupPort}" ]; }; transport = "${cfg.emailDomain} lmtp:inet:${cfg.listenHost}:${toString cfg.listenPort}\n"; # Both are loopback-only literal IPs here, so this is purely # defensive: unlike a container deployment behind a hostname, # there is no DNS/chroot interaction to break. See the # project's Docker Postfix notes for why this mattered there. settings.master = { smtp_inet.chroot = false; lmtp.chroot = false; }; }; }; }; }; }