From 5fdb686c66d3977da7cf9a2472d4f3b7f2e6ebea Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 20 Mar 2026 01:06:11 +0100 Subject: [PATCH 1/5] docs: improve login account options --- default.nix | 102 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 67 insertions(+), 35 deletions(-) diff --git a/default.nix b/default.nix index 8461eb1..cc4f7a7 100644 --- a/default.nix +++ b/default.nix @@ -25,7 +25,6 @@ let inherit (lib) literalExpression literalMD - mkDefault mkEnableOption mkOption mkOptionType @@ -145,8 +144,13 @@ in options = { name = mkOption { type = types.str; + default = name; example = "user1@example.com"; - description = "Username"; + readOnly = true; + internal = true; + description = '' + The login username for this account. + ''; }; hashedPassword = mkOption { @@ -154,24 +158,33 @@ in default = null; example = "$y$j9T$vfGrwkAaXCjCEWtVNMQck1$383uIXQmn2z0hnmVAA8kwFQmjNj78.nYbvWeyNLIaP1"; description = '' - The user's hashed password. Use `mkpasswd` as follows + The hashed login password for this account. + Use `mkpasswd` to create password hashes: ``` nix-shell -p mkpasswd --run 'mkpasswd -s' ``` - Warning: this is stored in plaintext in the Nix store! - Use {option}`mailserver.loginAccounts..hashedPasswordFile` instead. + :::{note} + This is a convenience option, when your threat model allows + storing hashed secrets in the world-readable Nix store. + + Passing the hash through + {option}`mailserver.loginAccounts..hashedPasswordFile` + allows relying on filesystem discretionary access control as + another security boundary. + ::: ''; }; hashedPasswordFile = mkOption { type = with types; nullOr path; default = null; - example = "/run/keys/user1-passwordhash"; + example = "/run/keys/user1-pw-hash"; description = '' - A file containing the user's hashed password. Use `mkpasswd` as follows + The hashed login password for this account read from a file. + Use `mkpasswd to create password hashes: ``` nix-shell -p mkpasswd --run 'mkpasswd -s' ``` @@ -185,9 +198,13 @@ in inStore = false; }); default = null; - example = "/run/keys/user1-password"; + example = "/run/keys/user1-pw"; description = '' - A file containing the user's plain text password. The value will be hashed at runtime. + The plaintext login password for this account read from a file. + + :::{note} + The password is hashed before it is passed on to Dovecot. + ::: ''; }; @@ -199,9 +216,13 @@ in ]; default = [ ]; description = '' - A list of aliases of this login account. - Note: Use list entries like "@example.com" to create a catchAll - that allows sending from all email addresses in these domain. + List of additional mail addresses (aliases) that get routed to this account. + + :::{admonition} Catch-all with sending permissions + :class: tip + Configure `@example.com` to create a catch-all for this domain + that also allows sending from all addresses. + ::: ''; }; @@ -224,7 +245,12 @@ in default = [ ]; description = '' For which domains should this account act as a catch all? - Note: Does not allow sending from all addresses of these domains. + + :::{warning} + Does not allow sending from all addresses of these domains. + Use {option}`mailserver.loginAccounts..aliases` if that + is required. + ::: ''; }; @@ -266,9 +292,10 @@ in default = false; description = '' Specifies if the account should be a send-only account. - Emails sent to send-only accounts will be rejected from - unauthorized senders with the `sendOnlyRejectMessage` - stating the reason. + + Emails sent to send-only accounts will + be rejected with the reason configured in + {option}`mailserver.loginAccounts..sendOnlyRejectMessage`. ''; }; @@ -276,33 +303,38 @@ in type = types.str; default = "This account cannot receive emails."; description = '' - The message that will be returned to the sender when an email is - sent to a send-only account. Only used if the account is marked - as send-only. + The message returned to the sender for a send-only account. + + See {option}`mailserver.loginAccounts..sendOnly`. ''; }; }; - - config.name = mkDefault name; } ) ); - example = { - user1 = { - hashedPassword = "$y$j9T$y6eZ1o.IvVNfdGMAsUEvh1$6K/llP52uw2iDh4iSwtAn54/JYy7FzCcoCHmjmx00H5"; - }; - user2 = { - hashedPassword = "$y$j9T$hZ.ubq0M897Hw.znxnGG9.$14EJBoOwbwKeWt.W4vpnBPEBZC9mYz4fWI9kOCLoZf4"; - }; - }; + example = lib.literalExpression '' + { + user1 = { + # This password hash leaks into the Nix store + hashedPassword = "$y$j9T$y6eZ1o.IvVNfdGMAsUEvh1$6K/llP52uw2iDh4iSwtAn54/JYy7FzCcoCHmjmx00H5"; + }; + user2 = { + # Hashed password passed as a file + hashedPasswordFile = "/run/keys/user2-pw-hash"; + }; + user3 = { + # Plaintext password file + passwordFile = "/run/keys/user3-pw"; + }; + } + ''; description = '' - The login account of the domain. Every account is mapped to a unix user, - e.g. `user1@example.com`. To generate the passwords use `mkpasswd` as - follows + Attribute set of mail accounts. - ``` - nix-shell -p mkpasswd --run 'mkpasswd -s' - ``` + Each entry defines a mailbox and login credentials, where the attribute + name is used as the login username and optionally routed mail address. + + Use `mkpasswd` to generate password hashes. ''; default = { }; }; From e9337b346fcdea45dfebe30de3bc6c8182174ff4 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 20 Mar 2026 01:14:43 +0100 Subject: [PATCH 2/5] Rename mailserver.loginAccounts to mailserver.accounts The "login" prefix makes this option more confusing rather than clearer, because what other account types are there? LDAP ones for example, but you can login with those too, so the prefix is pointless. --- default.nix | 19 ++++++++++--------- docs/faq.rst | 2 +- docs/radicale.nix | 2 +- docs/radicale.rst | 2 +- docs/release-notes.rst | 2 +- docs/setup-example.nix | 2 +- mail-server/common.nix | 8 ++++---- mail-server/dovecot.nix | 6 +++--- mail-server/postfix.nix | 8 ++++---- mail-server/users.nix | 6 +++--- scripts/generate-options.py | 2 +- tests/clamav.nix | 2 +- tests/external.nix | 2 +- tests/internal.nix | 2 +- tests/ldap.nix | 2 +- tests/multiple.nix | 2 +- 16 files changed, 35 insertions(+), 34 deletions(-) diff --git a/default.nix b/default.nix index cc4f7a7..575cc6c 100644 --- a/default.nix +++ b/default.nix @@ -136,7 +136,7 @@ in description = "Message size limit enforced by Postfix."; }; - loginAccounts = mkOption { + accounts = mkOption { type = types.attrsOf ( types.submodule ( { name, ... }: @@ -170,7 +170,7 @@ in storing hashed secrets in the world-readable Nix store. Passing the hash through - {option}`mailserver.loginAccounts..hashedPasswordFile` + {option}`mailserver.accounts..hashedPasswordFile` allows relying on filesystem discretionary access control as another security boundary. ::: @@ -231,7 +231,7 @@ in example = [ ''/^tom\..*@domain\.com$/'' ]; default = [ ]; description = '' - Same as {option}`mailserver.loginAccounts..aliases` but + Same as {option}`mailserver.accounts..aliases` but using PCRE (Perl compatible regex). ''; }; @@ -248,7 +248,7 @@ in :::{warning} Does not allow sending from all addresses of these domains. - Use {option}`mailserver.loginAccounts..aliases` if that + Use {option}`mailserver.accounts..aliases` if that is required. ::: ''; @@ -295,7 +295,7 @@ in Emails sent to send-only accounts will be rejected with the reason configured in - {option}`mailserver.loginAccounts..sendOnlyRejectMessage`. + {option}`mailserver.accounts..sendOnlyRejectMessage`. ''; }; @@ -305,7 +305,7 @@ in description = '' The message returned to the sender for a send-only account. - See {option}`mailserver.loginAccounts..sendOnly`. + See {option}`mailserver.accounts..sendOnly`. ''; }; }; @@ -684,13 +684,13 @@ in aliases = mkOption { type = let - loginAccount = mkOptionType { + account = mkOptionType { name = "Login Account"; - check = account: builtins.elem account (builtins.attrNames cfg.loginAccounts); + check = account: builtins.elem account (builtins.attrNames cfg.accounts); }; in with types; - attrsOf (either loginAccount (nonEmptyListOf loginAccount)); + attrsOf (either account (nonEmptyListOf account)); example = { "postmaster@example.com" = "user1@example.com"; "abuse@example.com" = "user1@example.com"; @@ -1714,5 +1714,6 @@ in [ "mailserver" "ldap" "attributes" "mail" ] ) (mkRenamedOptionModule [ "mailserver" "extraVirtualAliases" ] [ "mailserver" "aliases" ]) + (mkRenamedOptionModule [ "mailserver" "loginAccounts" ] [ "mailserver" "accounts" ]) ]; } diff --git a/docs/faq.rst b/docs/faq.rst index ef306de..674ee0a 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -13,7 +13,7 @@ domain ``example.com`` and send mails with any address of this domain: .. code:: nix - mailserver.loginAccounts = { + mailserver.accounts = { "user@example.com" = { aliases = [ "@example.com" ]; }; diff --git a/docs/radicale.nix b/docs/radicale.nix index e740fb7..6ccecc7 100644 --- a/docs/radicale.nix +++ b/docs/radicale.nix @@ -12,7 +12,7 @@ let mapAttrsToList ; - mailAccounts = config.mailserver.loginAccounts; + mailAccounts = config.mailserver.accounts; htpasswd = pkgs.writeText "radicale.users" ( concatStrings (flip mapAttrsToList mailAccounts (mail: user: "${mail}+:${user.hashedPassword}\n")) ); diff --git a/docs/radicale.rst b/docs/radicale.rst index 244a41e..f594b8e 100644 --- a/docs/radicale.rst +++ b/docs/radicale.rst @@ -10,7 +10,7 @@ Limitations Radicale since the 3.x release (introduced in NixOS 20.09) does not support traditional crypt() password hashes any longer. To establish access for -existing :option:`mailserver.loginAccounts`, the hashing method used +existing :option:`mailserver.accounts`, the hashing method used for ``hashedPassword`` needs to be compatible with one of the available `htpasswd_encryption`_ methods. Such hashes can for example be created using diff --git a/docs/release-notes.rst b/docs/release-notes.rst index 9ee6ccc..e0c2bd8 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -21,7 +21,7 @@ NixOS 26.05 is an alternative to hashed passwords that integrates well with workflows established by `agenix`_/`sops-nix`_ that instead rely on encryption. This option prevents files from leaking in to the Nix store. - See :option:`mailserver.loginAccounts..passwordFile`. + See :option:`mailserver.accounts..passwordFile`. - LDAP setups require a migration of Dovecot home directories to `UUID based home directories`_. The exact UUID attribute can be customized through :option:`mailserver.ldap.attributes.uuid`. diff --git a/docs/setup-example.nix b/docs/setup-example.nix index aba25ed..66a8242 100644 --- a/docs/setup-example.nix +++ b/docs/setup-example.nix @@ -38,7 +38,7 @@ # A list of all login accounts. To create the password hashes, use # nix-shell -p mkpasswd --run 'mkpasswd -s' - loginAccounts = { + accounts = { "user1@example.com" = { # Reads the password hash from a file on the server hashedPasswordFile = "/a/file/containing/a/hashed/password"; diff --git a/mail-server/common.nix b/mail-server/common.nix index 3ca5c57..95f317b 100644 --- a/mail-server/common.nix +++ b/mail-server/common.nix @@ -51,12 +51,12 @@ rec { builtins.toString (mkHashFile name value.hashedPassword) else value.passwordFile - ) cfg.loginAccounts; + ) cfg.accounts; # Collect accounts with plain text passwords that require hashing - accountsWithPlaintextPasswordFiles = lib.filter ( - name: cfg.loginAccounts.${name}.passwordFile != null - ) (builtins.attrNames cfg.loginAccounts); + accountsWithPlaintextPasswordFiles = lib.filter (name: cfg.accounts.${name}.passwordFile != null) ( + builtins.attrNames cfg.accounts + ); # Appends the LDAP bind password to files to avoid writing this # password into the Nix store. diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index 9c89c4e..83c4859 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -115,7 +115,7 @@ let umask 077 for f in ${ - builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts) + builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.accounts) }; do if [ ! -f "$f" ]; then echo "Expected password hash file $f does not exist!" @@ -131,7 +131,7 @@ let "${name}:${"$(sed -n '1{p;p;q}' ${passwordFiles."${name}"} | ${lib.getExe' pkgs.dovecot "doveadm"} pw)"}::::::" else "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::" - ) cfg.loginAccounts + ) cfg.accounts )} EOF @@ -141,7 +141,7 @@ let name: value: "${name}:::::::" + lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}" - ) cfg.loginAccounts + ) cfg.accounts )} EOF ''; diff --git a/mail-server/postfix.nix b/mail-server/postfix.nix index 70ae68e..52dc45a 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -51,7 +51,7 @@ let to = name; in map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name) - ) cfg.loginAccounts + ) cfg.accounts ) ); regex_valiases_postfix = mergeLookupTables ( @@ -62,7 +62,7 @@ let to = name; in map (from: { "${from}" = to; }) value.aliasesRegexp - ) cfg.loginAccounts + ) cfg.accounts ) ); @@ -75,7 +75,7 @@ let to = name; in map (from: { "@${from}" = to; }) value.catchAll - ) cfg.loginAccounts + ) cfg.accounts ) ); @@ -127,7 +127,7 @@ let # denied_recipients_postfix :: [ String ] denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") ( - lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts) + lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.accounts) ); denied_recipients_file = builtins.toFile "denied_recipients" ( lib.concatStringsSep "\n" denied_recipients_postfix diff --git a/mail-server/users.nix b/mail-server/users.nix index 9863066..4c0f620 100644 --- a/mail-server/users.nix +++ b/mail-server/users.nix @@ -86,7 +86,7 @@ let rm "${sieveDirectory}/${name}/default.svbin" fi '' - ) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues loginAccounts))} + ) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues accounts))} ''; in { @@ -102,14 +102,14 @@ in ] ) == 1; message = "Login account ${acct.name} must provide exactly one of password file, hashed password, or hashed password file"; - }) (lib.attrValues loginAccounts); + }) (lib.attrValues accounts); # warn for accounts that specify both password and file warnings = map (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used") ( lib.filter (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) ( - lib.attrValues loginAccounts + lib.attrValues accounts ) ); diff --git a/scripts/generate-options.py b/scripts/generate-options.py index c195504..f6ea83c 100644 --- a/scripts/generate-options.py +++ b/scripts/generate-options.py @@ -25,7 +25,7 @@ f = open(sys.argv[1]) options = json.load(f) groups = [ - "mailserver.loginAccounts", + "mailserver.accounts", "mailserver.x509", "mailserver.dkim", "mailserver.srs", diff --git a/tests/clamav.nix b/tests/clamav.nix index 900dd32..2600416 100644 --- a/tests/clamav.nix +++ b/tests/clamav.nix @@ -77,7 +77,7 @@ ]; virusScanning = true; - loginAccounts = { + accounts = { "user1@example.com" = { hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; aliases = [ "postmaster@example.com" ]; diff --git a/tests/external.nix b/tests/external.nix index a149144..e47013c 100644 --- a/tests/external.nix +++ b/tests/external.nix @@ -66,7 +66,7 @@ }; dmarcReporting.enable = true; - loginAccounts = { + accounts = { "user1@example.com" = { hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; aliases = [ "postmaster@example.com" ]; diff --git a/tests/internal.nix b/tests/internal.nix index a3cbfb8..15ada06 100644 --- a/tests/internal.nix +++ b/tests/internal.nix @@ -87,7 +87,7 @@ in ]; localDnsResolver = false; - loginAccounts = { + accounts = { "user1@example.com" = { hashedPasswordFile = hashedPasswordFile; }; diff --git a/tests/ldap.nix b/tests/ldap.nix index 2999bd8..fd9711f 100644 --- a/tests/ldap.nix +++ b/tests/ldap.nix @@ -133,7 +133,7 @@ in "frank@example.com" = "mallory@example.com"; }; - loginAccounts = { + accounts = { # Colliding local account takes precedence over LDAP account with # same address. "carol@example.com" = { diff --git a/tests/multiple.nix b/tests/multiple.nix index 3f9b5db..d627edd 100644 --- a/tests/multiple.nix +++ b/tests/multiple.nix @@ -35,7 +35,7 @@ let fqdn = "mail.${domain}"; domains = [ domain ]; localDnsResolver = false; - loginAccounts = { + accounts = { "user@${domain}" = { hashedPasswordFile = hashPassword "password"; }; From 6826d11c58133fe633fd027171a595df1bc75e94 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 20 Mar 2026 01:21:05 +0100 Subject: [PATCH 3/5] users: remove global with config.mailserver --- mail-server/users.nix | 52 +++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/mail-server/users.nix b/mail-server/users.nix index 4c0f620..b660da3 100644 --- a/mail-server/users.nix +++ b/mail-server/users.nix @@ -31,16 +31,16 @@ with (import ./common.nix { ; }); -with config.mailserver; - let + cfg = config.mailserver; + vmail_user = { - name = vmailUserName; + name = cfg.vmailUserName; isSystemUser = true; - uid = vmailUID; - home = mailDirectory; + uid = cfg.vmailUID; + home = cfg.mailDirectory; createHome = true; - group = vmailGroupName; + group = cfg.vmailGroupName; }; virtualMailUsersActivationScript = @@ -55,10 +55,10 @@ let umask 007 # Create directory to store user sieve scripts if it doesn't exist - if (! test -d "${sieveDirectory}"); then - mkdir "${sieveDirectory}" - chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}" - chmod 770 "${sieveDirectory}" + if (! test -d "${cfg.sieveDirectory}"); then + mkdir "${cfg.sieveDirectory}" + chown "${cfg.vmailUserName}:${cfg.vmailGroupName}" "${cfg.sieveDirectory}" + chmod 770 "${cfg.sieveDirectory}" fi # Copy user's sieve script to the correct location (if it exists). If it @@ -67,30 +67,30 @@ let { name, sieveScript }: if lib.isString sieveScript then '' - if (! test -d "${sieveDirectory}/${name}"); then - mkdir -p "${sieveDirectory}/${name}" - chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}" - chmod 770 "${sieveDirectory}/${name}" + if (! test -d "${cfg.sieveDirectory}/${name}"); then + mkdir -p "${cfg.sieveDirectory}/${name}" + chown "${cfg.vmailUserName}:${cfg.vmailGroupName}" "${cfg.sieveDirectory}/${name}" + chmod 770 "${cfg.sieveDirectory}/${name}" fi - cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve" + cat << 'EOF' > "${cfg.sieveDirectory}/${name}/default.sieve" ${sieveScript} EOF - chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve" + chown "${cfg.vmailUserName}:${cfg.vmailGroupName}" "${cfg.sieveDirectory}/${name}/default.sieve" '' else '' - if (test -f "${sieveDirectory}/${name}/default.sieve"); then - rm "${sieveDirectory}/${name}/default.sieve" + if (test -f "${cfg.sieveDirectory}/${name}/default.sieve"); then + rm "${cfg.sieveDirectory}/${name}/default.sieve" fi - if (test -f "${sieveDirectory}/${name}.svbin"); then - rm "${sieveDirectory}/${name}/default.svbin" + if (test -f "${cfg.sieveDirectory}/${name}.svbin"); then + rm "${cfg.sieveDirectory}/${name}/default.svbin" fi '' - ) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues accounts))} + ) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues cfg.accounts))} ''; in { - config = lib.mkIf enable { + config = lib.mkIf cfg.enable { # assert that all accounts provide a password assertions = map (acct: { assertion = @@ -102,21 +102,21 @@ in ] ) == 1; message = "Login account ${acct.name} must provide exactly one of password file, hashed password, or hashed password file"; - }) (lib.attrValues accounts); + }) (lib.attrValues cfg.accounts); # warn for accounts that specify both password and file warnings = map (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used") ( lib.filter (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) ( - lib.attrValues accounts + lib.attrValues cfg.accounts ) ); # set the vmail gid to a specific value users.groups = { - "${vmailGroupName}" = { - gid = vmailUID; + "${cfg.vmailGroupName}" = { + gid = cfg.vmailUID; }; }; From e13736db67c57a5ca02747ad711a079cfd3ebf32 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 20 Mar 2026 01:49:25 +0100 Subject: [PATCH 4/5] Group storage and vmail user options at mailserver.storage Create a nicer option structure that deals with the mail storage and its owner, uid, group and gid. Also includes the directory layout as a property of how mails are stored.. --- default.nix | 121 +++++++++++++++++++++++------------- docs/backup-guide.rst | 15 +++-- docs/migrations.rst | 6 +- mail-server/assertions.nix | 28 ++++----- mail-server/dovecot.nix | 20 +++--- mail-server/rsnapshot.nix | 2 +- mail-server/systemd.nix | 4 +- mail-server/users.nix | 35 +++++------ scripts/generate-options.py | 1 + tests/internal.nix | 9 ++- tests/ldap.nix | 8 +-- 11 files changed, 139 insertions(+), 110 deletions(-) diff --git a/default.nix b/default.nix index 575cc6c..554f07d 100644 --- a/default.nix +++ b/default.nix @@ -25,6 +25,7 @@ let inherit (lib) literalExpression literalMD + mkChangedOptionModule mkEnableOption mkOption mkOptionType @@ -783,53 +784,80 @@ in default = [ ]; }; - vmailUID = mkOption { - type = types.int; - default = 5000; - description = '' - The unix UID of the virtual mail user. Be mindful that if this is - changed, you will need to manually adjust the permissions of - `mailDirectory`. - ''; - }; + storage = { + path = mkOption { + type = types.path; + default = "/var/vmail"; + description = '' + Path on disk where mail home directories are stored. + ''; + }; - vmailUserName = mkOption { - type = types.str; - default = "virtualMail"; - description = '' - The user name and group name of the user that owns the directory where all - the mail is stored. - ''; - }; + directoryLayout = mkOption { + type = types.enum [ + "fs" + "maildir++" + ]; + default = "maildir++"; + description = '' + Sets whether dovecot should organize mail in subdirectories: - vmailGroupName = mkOption { - type = types.str; - default = "virtualMail"; - description = '' - The user name and group name of the user that owns the directory where all - the mail is stored. - ''; - }; + - /var/vmail/example.com/user/.folder.subfolder/ (Maildir++ layout) + - /var/vmail/example.com/user/folder/subfolder/ (FS layout) - mailDirectory = mkOption { - type = types.path; - default = "/var/vmail"; - description = '' - Where to store the mail. - ''; - }; + See + See https://doc.dovecot.org/main/core/config/mailbox_formats/maildir.html#maildir-mailbox-format for details. + ''; + }; - useFsLayout = mkOption { - type = types.bool; - default = false; - description = '' - Sets whether dovecot should organize mail in subdirectories: + uid = mkOption { + type = types.ints.positive; + default = 5000; + description = '' + The user id assigned to the vmail user. - - /var/vmail/example.com/user/.folder.subfolder/ (default layout) - - /var/vmail/example.com/user/folder/subfolder/ (FS layout) + This user owns the mail storage files and directories and is used by + services accessing the mail store. - See https://doc.dovecot.org/main/core/config/mailbox_formats/maildir.html#maildir-mailbox-format for details. - ''; + :::{warning} + If you change this value you also need to manually adjust the + permissions of your :option:`mailserver.storage.path`. + ::: + ''; + }; + + owner = mkOption { + type = types.str; + default = "virtualMail"; + description = '' + The name of the user that owns the :option:`mailserver.storage.path`. + ''; + }; + + gid = mkOption { + type = types.ints.positive; + default = 5000; + description = '' + The group id of the primary group of the vmail user. + + This group owns the mail storage directories. Access can be delegated + to other users via group membership. + + :::{warning} + If you change this value you also need to manually adjust the + permissions of your :option:`mailserver.storage.path`. + ::: + ''; + }; + + group = mkOption { + type = types.str; + default = "virtualMail"; + description = '' + The primary group name of the user that owns the + :option:`mailserver.storage.path`. + ''; + }; }; useUTF8FolderNames = mkOption { @@ -1513,8 +1541,8 @@ in locations = mkOption { type = types.listOf types.path; - default = [ cfg.mailDirectory ]; - defaultText = literalExpression "[ config.mailserver.mailDirectory ]"; + default = [ cfg.storage.path ]; + defaultText = literalExpression "[ config.mailserver.storage.path ]"; description = "The locations that are to be backed up by borg."; }; @@ -1715,5 +1743,12 @@ in ) (mkRenamedOptionModule [ "mailserver" "extraVirtualAliases" ] [ "mailserver" "aliases" ]) (mkRenamedOptionModule [ "mailserver" "loginAccounts" ] [ "mailserver" "accounts" ]) + (mkRenamedOptionModule [ "mailserver" "vmailUID" ] [ "mailserver" "storage" "uid" ]) + (mkRenamedOptionModule [ "mailserver" "vmailUserName" ] [ "mailserver" "storage" "owner" ]) + (mkRenamedOptionModule [ "mailserver" "vmailGroupName" ] [ "mailserver" "storage" "group" ]) + (mkRenamedOptionModule [ "mailserver" "mailDirectory" ] [ "mailserver" "storage" "path" ]) + (mkChangedOptionModule [ "mailserver" "useFSLayout" ] [ "mailserver" "storage" "directoryLayout" ] ( + config: if config.mailserver.useFSLayout then "fs" else "maildir++" + )) ]; } diff --git a/docs/backup-guide.rst b/docs/backup-guide.rst index caff6bf..3a7306a 100644 --- a/docs/backup-guide.rst +++ b/docs/backup-guide.rst @@ -5,14 +5,13 @@ First off you should have a backup of your ``configuration.nix`` file where you have the server config (but that is already in a git repository right?) -Next you need to backup ``/var/vmail`` or whatever you have specified -for the option ``mailDirectory``. This is where all the mails reside. -Good options are a cron job with ``rsync`` or ``scp``. But really -anything works, as it is simply a folder with plenty of files in it. If -your backup solution does not preserve the owner of the files don’t -forget to ``chown`` them to ``virtualMail:virtualMail`` if you copy them -back (or whatever you specified as ``vmailUserName``, and -``vmailGroupName``). +Next you need to backup ``/var/vmail`` or whatever you have specified for the +option :option:`mailserver.storage.path`. This is where all the mails reside. +Good options are a cron job with ``rsync`` or ``scp``. But really anything +works, as it is simply a folder with plenty of files in it. If your backup +solution does not preserve the owner of the files don’t forget to ``chown`` them +to ``virtualMail:virtualMail`` if you copy them back (or whatever you specified +as :option:`mailserver.storage.owner`, and :option:`mailserver.storage.group`). If you enabled ``enableManageSieve`` then you also may want to backup ``/var/sieve`` or whatever you have specified as ``sieveDirectory``. diff --git a/docs/migrations.rst b/docs/migrations.rst index 7807f95..1b03e9e 100644 --- a/docs/migrations.rst +++ b/docs/migrations.rst @@ -40,18 +40,18 @@ best practices to mailserver management. systemctl stop dovecot.service -3. Create a backup or snapshot of your :option:`mailserver.mailDirectory`, so +3. Create a backup or snapshot of your :option:`mailserver.storage.path`, so you can restore should anything go wrong. 4. Run the migration script and pass the required arguments to enable LDAP lookups: - The script should be run under the user who owns the :option:`mailserver.mailDirectory`. + The script should be run under the user who owns the :option:`mailserver.storage.path`. If run as root it will automatically switch into the appropriate user by itself. The script will not modify your data unless called with ``--execute``. The migration script finds all Dovecot home directories in - ``/var/vmail/ldap/`` (or any other :option:`mailserver.mailDirectory`), + ``/var/vmail/ldap/`` (or any other :option:`mailserver.storage.path`), for example that of bob at ``/var/vmail/ldap/bob@example.com``. It then takes ``bob@example.com`` and queries the LDAP server for ``mail=bob@example.com`` to retrieve the UUID attribute. Finally diff --git a/mail-server/assertions.nix b/mail-server/assertions.nix index a3c2428..d3f002b 100644 --- a/mail-server/assertions.nix +++ b/mail-server/assertions.nix @@ -98,22 +98,20 @@ in ) config.mailserver.dkim.domains ) ) - ++ - lib.optionals (config.mailserver.ldap.enable && config.mailserver.mailDirectory != "/var/vmail") - [ - { - assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 2; - message = '' - Issue: The dovecot homedir for LDAP users was previously not respecting `mailserver.mailDirectory`. - Remediation: - - Stop the `dovecot.service` - - Move `/var/vmail/ldap` below your `mailserver.mailDirectory` - - Increase the `stateVersion` to 2. + ++ lib.optionals (config.mailserver.ldap.enable && config.mailserver.storage.path != "/var/vmail") [ + { + assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 2; + message = '' + Issue: The dovecot homedir for LDAP users was previously not respecting `mailserver.storage.path`. + Remediation: + - Stop the `dovecot.service` + - Move `/var/vmail/ldap` below your `mailserver.storage.path` + - Increase the `stateVersion` to 2. - Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-home-directory-migration for more information. - ''; - } - ] + Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-home-directory-migration for more information. + ''; + } + ] ++ [ { assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 3; diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index 83c4859..ab08b6e 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -50,7 +50,7 @@ let }) attrs ); - maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs"; + maildirLayoutAppendix = lib.optionalString (cfg.storage.directoryLayout == "fs") ":LAYOUT=fs"; maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8"; # https://doc.dovecot.org/2.3/configuration_manual/home_directories_for_virtual_users/#ways-to-set-up-home-directory @@ -79,7 +79,7 @@ let scope = ${mkLdapSearchScope cfg.ldap.scope} user_attrs = \ ${ldapUuidAttribute}=${ldapUuidAttribute}, \ - =home=${cfg.mailDirectory}/ldap/%{ldap:${ldapUuidAttribute}}, \ + =home=${cfg.storage.path}/ldap/%{ldap:${ldapUuidAttribute}}, \ =mail=maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}${ lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/ldap/%{ldap:${ldapUuidAttribute}}" } @@ -228,8 +228,8 @@ in enablePop3 = cfg.enablePop3 || cfg.enablePop3Ssl; enablePAM = false; enableQuota = true; - mailGroup = cfg.vmailGroupName; - mailUser = cfg.vmailUserName; + mailGroup = cfg.storage.group; + mailUser = cfg.storage.owner; mailLocation = dovecotMaildir; sslServerCert = x509CertificateFile; sslServerKey = x509PrivateKeyFile; @@ -371,7 +371,7 @@ in mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} } - mail_access_groups = ${cfg.vmailGroupName} + mail_access_groups = ${cfg.storage.group} # https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7 ssl = required @@ -431,9 +431,9 @@ in driver = passwd-file args = ${userdbFile} default_fields = \ - home=${cfg.mailDirectory}/%{domain}/%{username} \ - uid=${builtins.toString cfg.vmailUID} \ - gid=${builtins.toString cfg.vmailUID} + home=${cfg.storage.path}/%{domain}/%{username} \ + uid=${builtins.toString cfg.storage.uid} \ + gid=${builtins.toString cfg.storage.uid} } ${lib.optionalString cfg.ldap.enable '' @@ -446,8 +446,8 @@ in driver = ldap args = ${ldapConfFile} override_fields = \ - uid=${toString cfg.vmailUID} \ - gid=${toString cfg.vmailUID} + uid=${toString cfg.storage.uid} \ + gid=${toString cfg.storage.uid} } ''} diff --git a/mail-server/rsnapshot.nix b/mail-server/rsnapshot.nix index f01ff8d..e898802 100644 --- a/mail-server/rsnapshot.nix +++ b/mail-server/rsnapshot.nix @@ -61,7 +61,7 @@ in retain hourly ${toString cfg.backup.retain.hourly} retain daily ${toString cfg.backup.retain.daily} retain weekly ${toString cfg.backup.retain.weekly} - backup ${cfg.mailDirectory}/ localhost/ + backup ${cfg.storage.path}/ localhost/ ''; }; }; diff --git a/mail-server/systemd.nix b/mail-server/systemd.nix index b401d6b..21b0c94 100644 --- a/mail-server/systemd.nix +++ b/mail-server/systemd.nix @@ -46,7 +46,7 @@ in preStart = let directories = lib.strings.escapeShellArgs ( - [ cfg.mailDirectory ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir + [ cfg.storage.path ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir ); in '' @@ -55,7 +55,7 @@ in # Prevent world-readable paths, even temporarily. umask 007 mkdir -p ${directories} - chgrp "${cfg.vmailGroupName}" ${directories} + chgrp "${cfg.storage.group}" ${directories} chmod 02770 ${directories} ''; }; diff --git a/mail-server/users.nix b/mail-server/users.nix index b660da3..39fa055 100644 --- a/mail-server/users.nix +++ b/mail-server/users.nix @@ -34,15 +34,6 @@ with (import ./common.nix { let cfg = config.mailserver; - vmail_user = { - name = cfg.vmailUserName; - isSystemUser = true; - uid = cfg.vmailUID; - home = cfg.mailDirectory; - createHome = true; - group = cfg.vmailGroupName; - }; - virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" # bash @@ -57,7 +48,7 @@ let # Create directory to store user sieve scripts if it doesn't exist if (! test -d "${cfg.sieveDirectory}"); then mkdir "${cfg.sieveDirectory}" - chown "${cfg.vmailUserName}:${cfg.vmailGroupName}" "${cfg.sieveDirectory}" + chown "${cfg.storage.owner}:${cfg.storage.group}" "${cfg.sieveDirectory}" chmod 770 "${cfg.sieveDirectory}" fi @@ -69,13 +60,13 @@ let '' if (! test -d "${cfg.sieveDirectory}/${name}"); then mkdir -p "${cfg.sieveDirectory}/${name}" - chown "${cfg.vmailUserName}:${cfg.vmailGroupName}" "${cfg.sieveDirectory}/${name}" + chown "${cfg.storage.owner}:${cfg.storage.group}" "${cfg.sieveDirectory}/${name}" chmod 770 "${cfg.sieveDirectory}/${name}" fi cat << 'EOF' > "${cfg.sieveDirectory}/${name}/default.sieve" ${sieveScript} EOF - chown "${cfg.vmailUserName}:${cfg.vmailGroupName}" "${cfg.sieveDirectory}/${name}/default.sieve" + chown "${cfg.storage.owner}:${cfg.storage.group}" "${cfg.sieveDirectory}/${name}/default.sieve" '' else '' @@ -113,16 +104,18 @@ in ) ); - # set the vmail gid to a specific value - users.groups = { - "${cfg.vmailGroupName}" = { - gid = cfg.vmailUID; - }; + users.groups.${cfg.storage.group} = { + inherit (cfg.storage) gid; }; - - # define all users - users.users = { - "${vmail_user.name}" = lib.mkForce vmail_user; + users.users.${cfg.storage.owner} = lib.mkForce { + inherit (cfg.storage) + group + uid + ; + name = cfg.storage.owner; + isSystemUser = true; + home = cfg.storage.path; + createHome = true; }; systemd.services.activate-virtual-mail-users = { diff --git a/scripts/generate-options.py b/scripts/generate-options.py index f6ea83c..dfba41d 100644 --- a/scripts/generate-options.py +++ b/scripts/generate-options.py @@ -27,6 +27,7 @@ options = json.load(f) groups = [ "mailserver.accounts", "mailserver.x509", + "mailserver.storage", "mailserver.dkim", "mailserver.srs", "mailserver.dmarcReporting", diff --git a/tests/internal.nix b/tests/internal.nix index 15ada06..e6c1b17 100644 --- a/tests/internal.nix +++ b/tests/internal.nix @@ -109,8 +109,11 @@ in "user2@example.com" = "user1@example.com"; }; - vmailGroupName = "vmail"; - vmailUID = 5000; + storage = { + gid = 5000; + group = "vmail"; + }; + indexDir = "/var/lib/dovecot/indices"; enableImap = false; @@ -218,7 +221,7 @@ in with subtest("Check dovecot maildir and index locations"): # If these paths change we need a migration - machine.succeed("doveadm user -f home user1@example.com | grep ${nodes.machine.mailserver.mailDirectory}/example.com/user1") + machine.succeed("doveadm user -f home user1@example.com | grep ${nodes.machine.mailserver.storage.path}/example.com/user1") machine.succeed("doveadm user -f mail user1@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/example.com/user1'") with subtest("mail to send only accounts is rejected"): diff --git a/tests/ldap.nix b/tests/ldap.nix index fd9711f..57e90aa 100644 --- a/tests/ldap.nix +++ b/tests/ldap.nix @@ -125,7 +125,7 @@ in fqdn = "mail.example.com"; domains = [ "example.com" ]; localDnsResolver = false; - mailDirectory = "/var/lib/dovecot/vmail"; + storage.path = "/var/lib/dovecot/vmail"; indexDir = "/var/lib/dovecot/indices"; aliases = { @@ -214,10 +214,10 @@ in machine.succeed("doveadm user -u alice") machine.log(machine.succeed("doveadm user -u bob")) - machine.succeed("doveadm user -f uid bob@example.com | grep ${toString nodes.machine.mailserver.vmailUID}") - machine.succeed("doveadm user -f gid bob@example.com | grep ${toString nodes.machine.mailserver.vmailUID}") + machine.succeed("doveadm user -f uid bob@example.com | grep ${toString nodes.machine.mailserver.storage.uid}") + machine.succeed("doveadm user -f gid bob@example.com | grep ${toString nodes.machine.mailserver.storage.uid}") - machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.mailDirectory}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092") + machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.storage.path}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092") machine.succeed("doveadm user -f mail bob@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092'") with subtest("Files containing secrets are only readable by root"): From 20f0e767cbcbf4a6a5bdf46a1bc210878a26f061 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 20 Mar 2026 01:52:49 +0100 Subject: [PATCH 5/5] users: remove unused common import --- mail-server/users.nix | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/mail-server/users.nix b/mail-server/users.nix index 39fa055..163943e 100644 --- a/mail-server/users.nix +++ b/mail-server/users.nix @@ -16,21 +16,11 @@ { config, - options, pkgs, lib, ... }: -with (import ./common.nix { - inherit - config - options - lib - pkgs - ; -}); - let cfg = config.mailserver;