From fe5a577d93bf617e3cab425935adedb6df07784f Mon Sep 17 00:00:00 2001 From: ibizaman Date: Sat, 23 May 2026 15:03:19 +0200 Subject: [PATCH] mailserver: update to e33fbde and add test --- CHANGELOG.md | 13 + docs/redirects.json | 3 + flake.nix | 1 + modules/services/mailserver.nix | 107 +++++--- modules/services/mailserver/docs/default.md | 2 + test/services/mailserver.nix | 278 ++++++++++++++++++++ 6 files changed, 368 insertions(+), 36 deletions(-) create mode 100644 test/services/mailserver.nix diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e929f8..bb35fdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,19 @@ Template: # Upcoming Release +## Breaking Changes + +- Updated simple-nixos-mailserver. Update all options following this pattern: + + ```diff + - mailserver.mailboxes..specialUse = "value" + + mailserver.mailboxes..special_use = "\\value" + ``` + + Note that simple-nixos-mailserver wanted to upgrade the location of the storage path + to include the ldap UID instead of the email but I kept the email address. + This means there is no migration to do. + # v0.8.0 ## Breaking Changes diff --git a/docs/redirects.json b/docs/redirects.json index 06b99e9..554a819 100644 --- a/docs/redirects.json +++ b/docs/redirects.json @@ -5009,6 +5009,9 @@ "services-mailserver-options-shb.mailserver.ssl.systemdService": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ssl.systemdService" ], + "services-mailserver-options-shb.mailserver.stateVersion": [ + "services-mailserver.html#services-mailserver-options-shb.mailserver.stateVersion" + ], "services-mailserver-options-shb.mailserver.subdomain": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.subdomain" ], diff --git a/flake.nix b/flake.nix index 545faac..e71b2f0 100644 --- a/flake.nix +++ b/flake.nix @@ -403,6 +403,7 @@ // (vm_test "homepage" ./test/services/homepage.nix) // (vm_test "jellyfin" ./test/services/jellyfin.nix) // (vm_test "karakeep" ./test/services/karakeep.nix) + // (vm_test "mailserver" ./test/services/mailserver.nix) // (vm_test "nextcloud" ./test/services/nextcloud.nix) // (vm_test "open-webui" ./test/services/open-webui.nix) // (vm_test "paperless" ./test/services/paperless.nix) diff --git a/modules/services/mailserver.nix b/modules/services/mailserver.nix index 8f2ac47..d7ad238 100644 --- a/modules/services/mailserver.nix +++ b/modules/services/mailserver.nix @@ -14,7 +14,7 @@ in builtins.fetchGit { url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver.git"; ref = "master"; - rev = "7d433bf89882f61621f95082e90a4ab91eb0bdd3"; + rev = "e33fbde199eaad513ef5d0746db19d5878150232"; } + "/default.nix" ) @@ -105,7 +105,7 @@ in description = '' Accounts to sync emails from using IMAP. - Emails will be stored under `''${config.mailserver.mailDirectory}/''${name}/''${username}` + Emails will be stored under `''${config.mailserver.storage.path}/''${name}/''${username}` ''; type = lib.types.attrsOf ( lib.types.submodule { @@ -137,7 +137,7 @@ in type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; - owner = config.mailserver.vmailUserName; + owner = config.mailserver.storage.owner; restartUnits = [ "mbsync.service" ]; }; }; @@ -267,7 +267,7 @@ in Enabling this app will create a new LDAP configuration or update one that exists with the given host. ''; - default = { }; + default = null; type = lib.types.nullOr ( lib.types.submodule { options = { @@ -333,6 +333,26 @@ in ); }; + stateVersion = lib.mkOption { + type = lib.types.ints.positive; + # SHB started at stateVersion 3. + default = 4; + description = '' + Tracking stateful version changes as an incrementing number. + + When a new release comes out we may require manual migration steps to + be completed, before the new version can be put into production. + + If your `stateVersion` is too low one or multiple assertions may + trigger to give you instructions on what migrations steps are required + to continue. Increase the `stateVersion` as instructed by the assertion + message. + + See https://nixos-mailserver.readthedocs.io/en/latest/release-notes.html + and https://nixos-mailserver.readthedocs.io/en/latest/migrations.html + ''; + }; + backup = lib.mkOption { description = '' Backup emails, index and sieve. @@ -340,16 +360,16 @@ in default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { - user = config.mailserver.vmailUserName; + user = config.mailserver.storage.owner; sourceDirectories = builtins.filter (x: x != null) [ config.mailserver.indexDir - config.mailserver.mailDirectory + config.mailserver.storage.path config.mailserver.sieveDirectory ]; sourceDirectoriesText = '' [ config.mailserver.indexDir - config.mailserver.mailDirectory + config.mailserver.storage.path config.mailserver.sieveDirectory ] ''; @@ -367,7 +387,7 @@ in user = config.services.rspamd.user; userText = "services.rspamd.user"; sourceDirectories = builtins.filter (x: x != null) [ - config.mailserver.dkimKeyDirectory + config.mailserver.dkim.keyDirectory ]; sourceDirectoriesText = '' [ @@ -385,14 +405,14 @@ in type = lib.types.attrsOf lib.types.str; default = { index = config.mailserver.indexDir; - mail = config.mailserver.mailDirectory; + mail = config.mailserver.storage.path; sieve = config.mailserver.sieveDirectory; dkim = config.mailserver.dkimKeyDirectory; }; defaultText = lib.literalExpression '' { index = config.mailserver.indexDir; - mail = config.mailserver.mailDirectory; + mail = config.mailserver.storage.path; sieve = config.mailserver.sieveDirectory; dkim = config.mailserver.dkimKeyDirectory; } @@ -417,8 +437,8 @@ in (lib.mkIf cfg.enable { mailserver = { enable = true; - stateVersion = 3; fqdn = "${cfg.subdomain}.${cfg.domain}"; + inherit (cfg) stateVersion; domains = [ cfg.domain ]; localDnsResolver = false; @@ -433,18 +453,22 @@ in # Using / is needed for iOS mail. # Both following options are used to organize subfolders in subdirectories. hierarchySeparator = "/"; - useFsLayout = true; + storage = { + directoryLayout = "fs"; + }; }; - services.postfix.config = { + services.postfix.settings.main = { smtpd_tls_security_level = lib.mkForce "encrypt"; }; # Is probably needed for iOS mail. - services.dovecot2.extraConfig = '' - ssl_min_protocol = TLSv1.2 - ssl_cipher_list = HIGH:!aNULL:!MD5 - ''; + services.dovecot2.settings = { + ssl_min_protocol = "TLSv1.2"; + # This conflicts with the default setting which seems much better than this one. + # So I'm leaving the default settings and we'll see if something breaks. + # ssl_cipher_list = "HIGH:!aNULL:!MD5"; + }; services.nginx = { enable = true; @@ -482,6 +506,8 @@ in in { forceSSL = true; # Redirect HTTP → HTTPS + sslCertificate = cfg.ssl.paths.cert; + sslCertificateKey = cfg.ssl.paths.key; root = "/var/www"; # Dummy root locations."/.well-known/autoconfig/mail/" = { alias = "${announce}/"; @@ -501,6 +527,8 @@ in in { forceSSL = true; # Redirect HTTP → HTTPS + sslCertificate = cfg.ssl.paths.cert; + sslCertificateKey = cfg.ssl.paths.key; root = "/var/www"; # Dummy root locations."/" = { alias = "${landingPage}/"; @@ -548,37 +576,44 @@ in uris = [ "ldap://${cfg.ldap.host}:${toString cfg.ldap.port}" ]; - searchBase = "ou=people,${cfg.ldap.dcdomain}"; - searchScope = "sub"; + base = "ou=people,${cfg.ldap.dcdomain}"; + scope = "sub"; bind = { dn = "uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain}"; passwordFile = cfg.ldap.adminPassword.result.path; }; - # Note that nixos simple mailserver sets auth_bind=yes - # which means authentication binds are used. - # https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_bind/#authentication-ldap-bind dovecot = let filter = "(&(objectClass=inetOrgPerson)(mail=%{user})(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))"; in { - passAttrs = "user=user"; passFilter = filter; - userAttrs = lib.concatStringsSep "," [ - "=home=${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u" - # "mail=maildir:${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u/mail" - "uid=${config.mailserver.vmailUserName}" - "gid=${config.mailserver.vmailGroupName}" - ]; userFilter = filter; }; + # username needs to be set to mail so postfix maps correctly the mail address. + # Otherwise we get error messages when sending + # Sender address rejected: not owned by user + attributes = { + username = "mail"; + }; postfix = { filter = "(&(objectClass=inetOrgPerson)(mail=%s)(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))"; - mailAttribute = "mail"; - uidAttribute = "mail"; }; }; }; + services.dovecot2.settings = { + "userdb ldap" = { + fields.home = lib.mkForce "${config.mailserver.storage.path}/${cfg.ldap.account}/%{user}"; + fields.mail_index_path = lib.mkForce "${config.mailserver.storage.path}/${cfg.ldap.account}/%{user}"; + }; + "passdb ldap" = { + # LLDAP does not understand ldap user password setup. + fields.password = lib.mkForce null; + # We use bind DN auth. + # https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_bind/#authentication-ldap-bind + bind = true; + }; + }; }) (lib.mkIf (cfg.enable && cfg.imapSync != null) { systemd.services.mbsync = @@ -601,11 +636,11 @@ in Account ${name} MaildirStore ${name}-local - INBOX ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/ + INBOX ${config.mailserver.storage.path}/${name}/${acct.username}/mail/ # Maps subfolders on far side to actual subfolders on disk. # The other option is Maildir++ but then the mailserver.hierarchySeparator must be set to a dot '.' SubFolders Verbatim - Path ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/ + Path ${config.mailserver.storage.path}/${name}/${acct.username}/mail/ Channel ${name}-main Far :${name}-remote: @@ -672,7 +707,7 @@ in description = "Sync mailbox"; serviceConfig = { Type = "oneshot"; - User = config.mailserver.vmailUserName; + User = config.mailserver.storage.owner; }; script = let @@ -689,8 +724,8 @@ in name: acct: # The equal sign makes sure parent directories have the corret user and group too. [ - "d '${config.mailserver.mailDirectory}/${name}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -" - "d '${config.mailserver.mailDirectory}/${name}/${acct.username}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -" + "d '${config.mailserver.storage.path}/${name}' 0750 ${config.mailserver.storage.owner} ${config.mailserver.storage.group} - -" + "d '${config.mailserver.storage.path}/${name}/${acct.username}' 0750 ${config.mailserver.storage.owner} ${config.mailserver.storage.group} - -" ]; in lib.flatten (lib.mapAttrsToList mkAccount cfg.imapSync.accounts); diff --git a/modules/services/mailserver/docs/default.md b/modules/services/mailserver/docs/default.md index fd9f99e..96d0453 100644 --- a/modules/services/mailserver/docs/default.md +++ b/modules/services/mailserver/docs/default.md @@ -128,6 +128,8 @@ in } ``` +With the example above, the mails are stored under `/var/vmail/fastmail/`. + ### Secrets {#services-mailserver-usage-secrets} Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. diff --git a/test/services/mailserver.nix b/test/services/mailserver.nix new file mode 100644 index 0000000..98724de --- /dev/null +++ b/test/services/mailserver.nix @@ -0,0 +1,278 @@ +{ + lib, + pkgs, + shb, + ... +}: +let + domain = "example.com"; + subdomain = "imap"; + fqdn = "${subdomain}.${domain}"; + + trySendMail = pkgs.writeShellApplication { + name = "trySendMail"; + runtimeInputs = [ + pkgs.swaks + ]; + text = '' + USER="''${1:-}" + PASSWORD="''${2:-}" + + if [ -z "$USER" ]; then + echo "No user given" + exit 1 + fi + + if [ -z "$PASSWORD" ]; then + swaks \ + --server ${fqdn} \ + --port 465 \ + --tls-on-connect \ + --from "$USER" \ + --to "$USER" + else + swaks \ + --server ${fqdn} \ + --port 465 \ + --tls-on-connect \ + --auth LOGIN \ + --auth-user "$USER" \ + --auth-password "$PASSWORD" \ + --from "$USER" \ + --to "$USER" + fi + ''; + }; + + tryRcvMail = pkgs.writeShellApplication { + name = "tryRcvMail"; + runtimeInputs = [ + pkgs.curl + ]; + text = '' + USER="''${1:-}" + PASSWORD="''${2:-}" + + if [ -z "$USER" ]; then + echo "No user given" + exit 1 + fi + if [ -z "$PASSWORD" ]; then + echo "No password given" + exit 1 + fi + + curl -s --url "imaps://imap.example.com/INBOX;UID=1" \ + --user "$USER:$PASSWORD" + ''; + }; +in +{ + basic = shb.test.runNixOSTest { + name = "mailserver_basic"; + + # Connect to the running VM started with driverInteractive: + # ssh-keygen -R 'vsock-mux//run/user/1000/tmpcf3hs4yp/server_host.socket'; ssh -o User=root vsock-mux//run/user/1000/tmpcf3hs4yp/server_host.socket + interactive.sshBackdoor.enable = true; + + nodes.server = + { config, ... }: + { + imports = [ + ../../modules/blocks/ssl.nix + ../../modules/services/mailserver.nix + ]; + + networking.hosts = { + "127.0.0.1" = [ fqdn ]; + }; + + shb.certs.cas.selfsigned.myca = { + name = "My CA"; + }; + shb.certs.certs.selfsigned = { + ${domain} = { + ca = config.shb.certs.cas.selfsigned.myca; + + domain = "*.${domain}"; + group = "nginx"; + }; + }; + + shb.mailserver = { + enable = true; + inherit subdomain domain; + stateVersion = 4; + ssl = config.shb.certs.certs.selfsigned.${domain}; + + # imapSync = { + # # syncTimer = "10s"; + # # debug = false; + # # accounts.fastmail = { + # # host = "imap.fastmail.com"; + # # port = 993; + # # username = email; + # # password.result = config.shb.sops.secret."mailserver/imap/fastmail/password".result; + # # mapSpecialJunk = "Spam"; + # # }; + # }; + + # smtpRelay = { + # host = "smtp.fastmail.com"; + # port = 587; + # username = email; + # password.result = config.shb.sops.secret."mailserver/smtp/fastmail/password".result; + # }; + + }; + + mailserver.mailboxes = { + Junk = { + auto = "subscribe"; + special_use = "\\Junk"; + }; + }; + + environment.systemPackages = [ + pkgs.openssl + pkgs.swaks + trySendMail + tryRcvMail + ]; + + specialisation = { + ldap.configuration = + { config, ... }: + { + imports = [ + ../../modules/blocks/hardcodedsecret.nix + ../../modules/blocks/lldap.nix + ]; + + networking.hosts = { + "127.0.0.1" = [ "ldap.${domain}" ]; + }; + + environment.systemPackages = [ + pkgs.openldap + ]; + + shb.hardcodedsecret.ldapUserPassword = { + request = config.shb.lldap.ldapUserPassword.request; + settings.content = "ldapUserPassword"; + }; + shb.hardcodedsecret.jwtSecret = { + request = config.shb.lldap.jwtSecret.request; + settings.content = "jwtSecrets"; + }; + shb.hardcodedsecret.alice = { + request = config.shb.lldap.ensureUsers.alice.password.request; + settings.content = "alicePassword"; + }; + shb.hardcodedsecret.charlie = { + request = config.shb.lldap.ensureUsers.charlie.password.request; + settings.content = "charliePassword"; + }; + + shb.lldap = { + enable = true; + inherit domain; + subdomain = "ldap"; + ldapPort = 3890; + webUIListenPort = 17170; + dcdomain = "dc=example,dc=com"; + ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result; + jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result; + debug = true; + + ensureUsers = { + alice = { + email = "alice@example.com"; + groups = [ "user_group" ]; + password.result = config.shb.hardcodedsecret.alice.result; + }; + charlie = { + email = "charlie@example.com"; + groups = [ "other_group" ]; + password.result = config.shb.hardcodedsecret.charlie.result; + }; + }; + + ensureGroups = { + user_group = { }; + admin_group = { }; + other_group = { }; + }; + }; + + shb.mailserver = { + ldap = { + enable = true; + host = "127.0.0.1"; + port = config.shb.lldap.ldapPort; + dcdomain = config.shb.lldap.dcdomain; + adminName = "admin"; + adminPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result; + account = "fastmail"; + userGroup = "user_group"; + }; + }; + }; + }; + }; + + testScript = + { nodes, ... }: + let + specialisations = "${nodes.server.system.build.toplevel}/specialisation"; + switch = name: "${specialisations}/${name}/bin/switch-to-configuration test"; + + ldapSearch = + let + config = nodes.server.specialisation.ldap.configuration; + in + lib.concatStringsSep " " [ + "ldapsearch" + "-H ldap://ldap.example.com:${toString config.shb.lldap.ldapPort}" + ]; + ldapSearchAdmin = + let + config = nodes.server.specialisation.ldap.configuration; + in + lib.concatStringsSep " " [ + "ldapsearch" + "-H ldap://ldap.example.com:${toString config.shb.lldap.ldapPort}" + "-D uid=admin,ou=people,${config.shb.lldap.dcdomain}" + "-w ${config.shb.hardcodedsecret.ldapUserPassword.settings.content}" + ]; + in + '' + server.wait_for_unit("multi-user.target") + server.wait_for_unit("dovecot") + + with subtest("ldap"): + server.succeed("${switch "ldap"}") + + server.wait_for_unit("lldap") + print(server.succeed("${ldapSearch} -LLL -D uid=alice,ou=people,dc=example,dc=com -w alicePassword -b cn=user_group,ou=groups,dc=example,dc=com | grep uniquemember | grep alice")) + print(server.succeed("${ldapSearchAdmin} -LLL -b cn=user_group,ou=groups,dc=example,dc=com | grep uniquemember | grep alice")) + + server.wait_for_unit("dovecot") + print(server.fail("doveadm auth test auser@example.com apassword")) + print(server.fail("doveadm auth test charlie@example.com charliePassword")) + print(server.succeed("doveadm auth test alice@example.com alicePassword")) + + server.wait_for_unit("postfix") + print(server.fail("trySendMail alice@example.com")) + print(server.fail("trySendMail auser@example.com apassword")) + print(server.fail("trySendMail charlie@example.com charliePassword")) + print(server.succeed("trySendMail alice@example.com alicePassword")) + + print(server.fail("tryRcvMail charlie@example.com charliePassword")) + print(server.succeed("tryRcvMail alice@example.com alicePassword")) + + print(server.succeed("find /var/vmail")) + print(server.succeed("find /var/vmail/fastmail/alice@example.com")) + ''; + }; +}