2026-10-03 18:03:43 +03:00
|
|
|
{
|
|
|
|
|
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";
|
2026-10-05 11:25:11 +03:00
|
|
|
fumi = {
|
|
|
|
|
url = "git+ssh://git@code.randogoth.com:2222/randogoth/fumi.git";
|
2026-10-03 18:03:43 +03:00
|
|
|
flake = false;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-05 11:25:11 +03:00
|
|
|
outputs = { self, nixpkgs, flake-utils, fumi }:
|
2026-10-03 18:03:43 +03:00
|
|
|
flake-utils.lib.eachDefaultSystem (system:
|
|
|
|
|
let
|
|
|
|
|
pkgs = import nixpkgs { inherit system; };
|
2026-10-05 11:25:11 +03:00
|
|
|
|
|
|
|
|
pneumatubePkg = pkgs.rustPlatform.buildRustPackage {
|
2026-10-03 18:03:43 +03:00
|
|
|
pname = "pneumatube";
|
|
|
|
|
version = "0.1.0";
|
|
|
|
|
src = ./.;
|
2026-10-05 11:25:11 +03:00
|
|
|
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;
|
2026-10-03 18:03:43 +03:00
|
|
|
};
|
|
|
|
|
in
|
|
|
|
|
{
|
|
|
|
|
packages.default = pneumatubePkg;
|
|
|
|
|
|
|
|
|
|
devShells.default = pkgs.mkShell {
|
2026-10-05 11:25:11 +03:00
|
|
|
packages = with pkgs; [ cargo rustc rustfmt clippy sqlite ];
|
2026-10-03 18:03:43 +03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
) // {
|
|
|
|
|
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" ];
|
2026-10-05 11:25:11 +03:00
|
|
|
# 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 <host> <server-key>
|
|
|
|
|
# fumi ... register <user>@<host>
|
2026-10-03 18:03:43 +03:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|