{ self, config, lib, pkgs, ... }: let relayHosts = map (name: self.inputs.kartei.hosts.${name}.nets.retiolum.ip6.addr) [ "aergia" "ignavia" "coaxmetal" "green" "mors" ]; mynetworks = map (ip: "[${ip}]") relayHosts; in { imports = [ self.inputs.nixos-mailserver.nixosModules.default ./hermes-mail-vars.nix ]; mailserver = { enable = true; fqdn = "mail.lassul.us"; # neoprism.r: retiolum-internal mail (cron, root@neoprism.r from other # krebs hosts) lands in lass's mailbox via catchAll. domains = [ "lassul.us" "neoprism.r" ]; loginAccounts = { "lass@lassul.us" = { hashedPasswordFile = config.clan.core.vars.generators.mailserver-lass.files."lass-mail-password-hash".path; aliases = [ "postmaster@lassul.us" "root@lassul.us" "abuse@lassul.us" "hostmaster@lassul.us" "webmaster@lassul.us" "security@lassul.us" "noc@lassul.us" "mailer-daemon@lassul.us" "nobody@lassul.us" "lassulus@lassul.us" ]; catchAll = [ "lassul.us" "neoprism.r" ]; }; "bot@lassul.us" = { hashedPasswordFile = config.clan.core.vars.generators.mailserver-bot.files."bot-mail-password-hash".path; }; # Zitadel (configs/zitadel.nix): verification codes and password resets. # Zitadel 2.71 needs a password for an SMTP provider. "id@lassul.us" = { hashedPasswordFile = config.clan.core.vars.generators.mailserver-id.files."password-hash".path; sendOnly = true; }; # Hermes agent on coaxmetal; shared password var, see hermes-mail-vars.nix. "hermes@lassul.us" = { hashedPasswordFile = config.clan.core.vars.generators.hermes-mail.files."password-hash".path; }; # Fetched by paperless (configs/services/paperless) via IMAP. "scan@lassul.us" = { hashedPasswordFile = config.clan.core.vars.generators.mailserver-scan.files."password-hash".path; }; }; # TLS via existing ACME cert x509.useACMEHost = "mail.lassul.us"; # DKIM — auto-managed by module dkimSigning = true; dkimSelector = "mail"; # ManageSieve: lets mail clients manage sieve rules dynamically (port 4190) enableManageSieve = true; # Full-text search for IMAP clients fullTextSearch = { enable = true; memoryLimit = 500; }; # 5: Sieve scripts now live in each user's Dovecot home (~/sieve) instead of # the old global /var/sieve. Before deploying this bump, run the migration # against /var/sieve on neoprism (see migrations/nixos-mailserver-migration-05.py # in the nixos-mailserver input, or the migration docs). stateVersion = 5; # virtual.All mailbox needs more memory to sync all folders imapMemoryLimit = 1024; }; lass.backup.state.mailserver.folders = [ "/var/vmail" "/var/dkim" # global sieve dir from before stateVersion 5 "/var/sieve" ]; # nixos-mailserver mkForces virtualMail without homeMode; match its priority users.users.virtualMail = lib.mkOverride 50 { homeMode = "2770"; }; # POSIX ACL default ensures new files inherit lass access (needed for muchsync hardlinks) systemd.services.dovecot.serviceConfig.ExecStartPost = [ "+${pkgs.acl}/bin/setfacl -R -m u:lass:rwX /var/vmail/lassul.us/lass/mail" "+${pkgs.acl}/bin/setfacl -R -d -m u:lass:rwX /var/vmail/lassul.us/lass/mail" ]; # Password generation for mail accounts clan.core.vars.generators.mailserver-lass = { files."lass-mail-password" = { }; files."lass-mail-password-hash" = { }; runtimeInputs = with pkgs; [ coreutils mkpasswd pwgen ]; script = '' pwgen -s 32 1 > "$out/lass-mail-password" mkpasswd -sm bcrypt < "$out/lass-mail-password" > "$out/lass-mail-password-hash" ''; }; clan.core.vars.generators.mailserver-bot = { files."bot-mail-password" = { }; files."bot-mail-password-hash" = { }; files."mail.env" = { }; runtimeInputs = with pkgs; [ coreutils mkpasswd pwgen ]; script = '' pwgen -s 32 1 > "$out/bot-mail-password" mkpasswd -sm bcrypt < "$out/bot-mail-password" > "$out/bot-mail-password-hash" cat > "$out/mail.env" << EOF IMAP_USER=bot@lassul.us IMAP_PASSWORD=$(cat "$out/bot-mail-password") IMAP_HOST=localhost EOF ''; }; # the plaintext is read by zitadel-reconcile (root) clan.core.vars.generators.mailserver-id = { files."password" = { }; files."password-hash" = { }; runtimeInputs = with pkgs; [ coreutils mkpasswd pwgen ]; script = '' pwgen -s 32 1 | tr -d '\n' > "$out/password" mkpasswd -sm bcrypt < "$out/password" > "$out/password-hash" ''; }; # the plaintext goes into paperless' mail account (root-only on neoprism) clan.core.vars.generators.mailserver-scan = { files."password" = { }; files."password-hash" = { }; runtimeInputs = with pkgs; [ coreutils mkpasswd pwgen ]; script = '' pwgen -s 32 1 | tr -d '\n' > "$out/password" mkpasswd -sm bcrypt < "$out/password" > "$out/password-hash" ''; }; # Retiolum relay: allow krebs hosts to relay through postfix services.postfix.settings.main.mynetworks = lib.mkForce ( [ "127.0.0.0/8" "[::1]/128" ] ++ mynetworks ); # ACME cert — reuse existing setup security.acme.certs."mail.lassul.us" = { group = "lasscert"; webroot = "/var/lib/acme/acme-challenge"; }; users.groups.lasscert.members = [ "dovecot2" "postfix" "nginx" ]; # Firewall: SMTP(25), submission(587), submissions(465), IMAPS(993), ManageSieve(4190) networking.firewall.allowedTCPPorts = [ 25 587 465 993 4190 ]; # muchsync: lass user needs access to vmail for notmuch/muchsync users.groups.virtualMail.members = [ "lass" ]; systemd.tmpfiles.rules = [ "L+ /home/lass/Maildir - - - - /var/vmail/lassul.us/lass/mail" "z /var/vmail/lassul.us 2770 virtualMail virtualMail -" "z /var/vmail/lassul.us/lass 2770 virtualMail virtualMail -" "d /var/vmail/lassul.us/lass/mail 2770 virtualMail virtualMail -" ]; # Thunderbird autoconfig services.nginx.virtualHosts."autoconfig.lassul.us" = { forceSSL = true; enableACME = true; locations."= /mail/config-v1.1.xml".extraConfig = '' default_type application/xml; return 200 ' lassul.us lassul.us Mail lassul.us mail.lassul.us 993 SSL password-cleartext %EMAILADDRESS% mail.lassul.us 465 SSL password-cleartext %EMAILADDRESS% '; ''; }; # Provision virtual mailbox config to a writable directory (nix store is read-only, # and the ACL plugin needs to write dovecot-acl-list next to the virtual config) systemd.tmpfiles.settings."dovecot-virtual" = { "/var/vmail/virtual-config".d = { user = "virtualMail"; group = "virtualMail"; mode = "0750"; }; "/var/vmail/virtual-config/Unread".d = { user = "virtualMail"; group = "virtualMail"; mode = "0750"; }; "/var/vmail/virtual-config/Unread/dovecot-virtual".f = { user = "virtualMail"; group = "virtualMail"; mode = "0640"; argument = "*\n-notmuch\n-fts-flatcurve\n unseen\n"; }; "/var/vmail/virtual-config/All".d = { user = "virtualMail"; group = "virtualMail"; mode = "0750"; }; "/var/vmail/virtual-config/All/dovecot-virtual".f = { user = "virtualMail"; group = "virtualMail"; mode = "0640"; argument = "*\n-notmuch\n-fts-flatcurve\n all\n"; }; }; # Dovecot virtual mailbox + ACL-restricted shared namespace for bot services.dovecot2.settings = { mail_plugins = { virtual = true; acl = true; }; # ACL plugin (dovecot 2.4): `acl_driver` is top-level; per-identifier # default rights live in `acl { acl_rights = ...; }` named # filters where is owner/anyone/authenticated/user=.../etc. # `acl_shared_dict` was removed; the bot accesses shared mail via # explicit subscriptions. acl_driver = "vfile"; "acl owner" = { acl_rights = "lrwstipekxa"; }; # In dovecot 2.4 the per-namespace `location = "driver:path:opts"` was # replaced by separate mail_driver / mail_path / mail_index_path settings. "namespace shared" = { type = "shared"; separator = "."; prefix = "shared.%{user | username}."; mail_driver = "maildir"; mail_path = "/var/vmail/lassul.us/%{user | username}/mail"; subscriptions = false; list = "children"; }; "namespace virtual" = { prefix = "virtual."; separator = "."; mail_driver = "virtual"; mail_path = "/var/vmail/virtual-config"; mail_index_path = "/var/vmail/virtual-indexes/%{user}"; # virtual storage doesn't support maildir++ layout, override the global mailbox_list_layout = "fs"; subscriptions = false; "mailbox Unread".auto = "subscribe"; "mailbox All".auto = "subscribe"; }; }; # Write dovecot-acl files to grant bot read-only + flag access to lass's mail systemd.services.dovecot-acl-sync = { description = "Sync dovecot ACL files for bot shared access"; after = [ "dovecot.service" ]; serviceConfig = { Type = "oneshot"; User = "virtualMail"; Group = "virtualMail"; }; # l=lookup, r=read, w=write-flags, s=write-seen (no insert/delete/expunge) script = '' acl_line="user=bot@lassul.us lrws" # Only write ACL in mailbox root dirs, skip cur/new/tmp/notmuch subdirs find /var/vmail/lassul.us/lass/mail -type d \ -not -name cur -not -name new -not -name tmp \ -not -path '*/.notmuch/*' -not -name .notmuch \ -not -path '*/fts-flatcurve/*' -not -name fts-flatcurve \ | while IFS= read -r dir; do acl_file="$dir/dovecot-acl" if [ ! -f "$acl_file" ] || [ "$(cat "$acl_file")" != "$acl_line" ]; then echo "$acl_line" > "$acl_file" fi done ''; }; systemd.timers.dovecot-acl-sync = { wantedBy = [ "timers.target" ]; timerConfig = { OnBootSec = "1min"; OnUnitActiveSec = "1h"; }; }; # notmuch + muchsync + msmtp for CLI mail access environment.systemPackages = [ self.packages.${pkgs.system}.notmuch pkgs.muchsync pkgs.msmtp ]; # After muchsync writes tags to notmuch DB, the Maildir filenames don't get updated # (muchsync bypasses notmuch's flag sync). This watches the xapian DB for changes and # renames files to add/remove the S (Seen) flag based on the notmuch unread tag. systemd.paths.notmuch-flag-sync = { wantedBy = [ "multi-user.target" ]; pathConfig.PathChanged = "/var/vmail/lassul.us/lass/mail/.notmuch/xapian"; }; systemd.services.notmuch-flag-sync = { description = "Sync notmuch tags to Maildir flags after muchsync"; serviceConfig = { Type = "oneshot"; User = "lass"; Group = "virtualMail"; ExecStart = pkgs.writeShellScript "notmuch-flag-sync" '' export NOTMUCH_CONFIG="${ self.packages.${pkgs.system}.notmuch.passthru.configuration.configFile.path }" # First, pick up any IMAP flag changes into notmuch ${pkgs.notmuch}/bin/notmuch new --quiet 2>/dev/null || true # Add S flag to files notmuch considers read but that lack it ${pkgs.notmuch}/bin/notmuch search --output=files 'NOT tag:unread' \ | ${pkgs.gnugrep}/bin/grep '/cur/' \ | while IFS= read -r f; do [ -f "$f" ] || continue flags="''${f##*:2,}" case "$flags" in *S*) continue ;; esac base="''${f%:2,*}" mv "$f" "''${base}:2,''${flags}S" done # Remove S flag from files notmuch considers unread but that have it ${pkgs.notmuch}/bin/notmuch search --output=files 'tag:unread' \ | ${pkgs.gnugrep}/bin/grep '/cur/' \ | while IFS= read -r f; do [ -f "$f" ] || continue flags="''${f##*:2,}" case "$flags" in *S*) ;; *) continue ;; esac base="''${f%:2,*}" newflags="''${flags//S/}" mv "$f" "''${base}:2,''${newflags}" done # Update notmuch DB with the renames ${pkgs.notmuch}/bin/notmuch new --quiet 2>/dev/null || true ''; }; }; }