From ffb64609a5812e227907e8af34edbce857dd5b1b Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Mon, 13 Apr 2026 01:16:11 +0200 Subject: [PATCH 1/4] flake.lock: Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flake lock file updates: • Updated input 'nixpkgs': 'github:NixOS/nixpkgs/2f4fd5e1abf9bac8c1d22750c701a7a5e6b524c6' (2026-03-31) → 'github:NixOS/nixpkgs/c88e63f4caf12c731f61ce71f300680ce73c180e' (2026-04-12) --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 32516f9..6fb9513 100644 --- a/flake.lock +++ b/flake.lock @@ -79,11 +79,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1774935083, - "narHash": "sha256-Mh6bLcYAcENBAZk3RoMPMFCGGMZmfaGMERE4siZOgP4=", + "lastModified": 1776030597, + "narHash": "sha256-H2CYM/RmVqCo1iud5BhPp8Pim2d1ESGt2FDHjbmju8A=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "2f4fd5e1abf9bac8c1d22750c701a7a5e6b524c6", + "rev": "c88e63f4caf12c731f61ce71f300680ce73c180e", "type": "github" }, "original": { From 44149c527e4dddb52ba4df1808003fb841f72c0a Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Tue, 17 Mar 2026 01:53:34 +0100 Subject: [PATCH 2/4] dovecot: migrate to settings option --- default.nix | 23 +- mail-server/dovecot.nix | 455 ++++++++++++++++++------------------ mail-server/environment.nix | 2 +- 3 files changed, 240 insertions(+), 240 deletions(-) diff --git a/default.nix b/default.nix index 3e70624..cb1f4d5 100644 --- a/default.nix +++ b/default.nix @@ -654,11 +654,8 @@ in }; lmtpSaveToDetailMailbox = mkOption { - type = types.enum [ - "yes" - "no" - ]; - default = "yes"; + type = types.bool; + default = true; description = '' If an email address is delimited by a "+", should it be filed into a mailbox matching the string after the "+"? For example, @@ -882,25 +879,31 @@ in mailboxes = mkOption { description = '' - The mailboxes for dovecot. + The default mailboxes for Dovecot maildirs. + + The [`special_use`] option must refer to an [RFC6154 2] attribute and lead with an escaped backslash. + Depending on the mail client used it might be necessary to change some mailbox's name. + + [special_use]: https://doc.dovecot.org/2.3/configuration_manual/namespace/#core_setting-namespace/mailbox/special_use + [RFC6154 2]: https://datatracker.ietf.org/doc/html/rfc6154.html#section-2 ''; default = { Trash = { auto = "no"; - specialUse = "Trash"; + special_use = "\\Trash"; }; Junk = { auto = "subscribe"; - specialUse = "Junk"; + special_use = "\\Junk"; }; Drafts = { auto = "subscribe"; - specialUse = "Drafts"; + special_use = "\\Drafts"; }; Sent = { auto = "subscribe"; - specialUse = "Sent"; + special_use = "\\Sent"; }; }; }; diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index b022996..cc4f564 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -32,6 +32,13 @@ with (import ./common.nix { }); let + inherit (lib) + mapAttrs' + mkForce + mkIf + nameValuePair + ; + cfg = config.mailserver; passwdDir = "/run/dovecot2"; @@ -39,8 +46,6 @@ let userdbFile = "${passwdDir}/userdb"; # This file contains the ldap bind password ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext"; - boolToYesNo = x: if x then "yes" else "no"; - listToLine = lib.concatStringsSep " "; listToMultiAttrs = keyPrefix: attrs: lib.listToAttrs ( @@ -53,14 +58,6 @@ let 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 - # Mail directory below the home directory - dovecotMaildir = - "maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}" - + (lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/%{domain}/%{username}"); - - postfixCfg = config.services.postfix; - ldapConfig = pkgs.writeTextFile { name = "dovecot-ldap.conf.ext.template"; text = '' @@ -128,12 +125,13 @@ let lib.mapAttrsToList ( name: _: if lib.elem name accountsWithPlaintextPasswordFiles then - "${name}:${"$(sed -n '1{p;p;q}' ${passwordFiles."${name}"} | ${lib.getExe' pkgs.dovecot "doveadm"} pw)"}::::::" + "${name}:${"$(sed -n '1{p;p;q}' ${passwordFiles."${name}"} | ${lib.getExe' config.services.dovecot2.package "doveadm"} pw)"}::::::" else "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::" ) cfg.accounts )} EOF + chown dovecot2:dovecot2 ${passwdFile} cat < ${userdbFile} ${lib.concatStringsSep "\n" ( @@ -144,10 +142,11 @@ let ) cfg.accounts )} EOF + chown dovecot2:dovecot2 ${userdbFile} ''; junkMailboxes = builtins.attrNames ( - lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes + lib.filterAttrs (_: v: v ? "special_use" && v.special_use == "\\Junk") cfg.mailboxes ); junkMailboxNumber = builtins.length junkMailboxes; # The assertion guarantees there is exactly one Junk mailbox. @@ -163,30 +162,13 @@ let else scope ); - - ftsPluginSettings = { - fts = "flatcurve"; - fts_languages = listToLine cfg.fullTextSearch.languages; - fts_tokenizers = listToLine [ - "generic" - "email-address" - ]; - fts_tokenizer_email_address = "maxlen=100"; # default 254 too large for Xapian - fts_flatcurve_substring_search = boolToYesNo cfg.fullTextSearch.substringSearch; - fts_filters = listToLine cfg.fullTextSearch.filters; - fts_header_excludes = listToLine cfg.fullTextSearch.headerExcludes; - fts_autoindex = boolToYesNo cfg.fullTextSearch.autoIndex; - fts_enforced = cfg.fullTextSearch.enforced; - } - // (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude); - in { config = lib.mkIf cfg.enable { assertions = [ { assertion = junkMailboxNumber == 1; - message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special use' flag set to 'Junk' (${builtins.toString junkMailboxNumber} have been found)"; + message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special_use' flag set to '\\Junk' (${builtins.toString junkMailboxNumber} have been found)"; } ]; @@ -215,7 +197,7 @@ in # the global config and tries to open shared libraries configured in there, # which are usually not compatible. environment.systemPackages = [ - pkgs.dovecot_pigeonhole + pkgs.dovecot_pigeonhole_0_5 ] ++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve; @@ -223,30 +205,9 @@ in environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules"; services.dovecot2 = { + package = pkgs.dovecot_2_3; enable = true; - enableImap = cfg.enableImap || cfg.enableImapSsl; - enablePop3 = cfg.enablePop3 || cfg.enablePop3Ssl; - enablePAM = false; - enableQuota = true; - mailGroup = cfg.storage.group; - mailUser = cfg.storage.owner; - mailLocation = dovecotMaildir; - sslServerCert = x509CertificateFile; - sslServerKey = x509PrivateKeyFile; - enableDHE = lib.mkDefault false; - enableLmtp = true; - mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [ - "fts" - "fts_flatcurve" - ]; - protocols = lib.optional cfg.enableManageSieve "sieve"; - - pluginSettings = { - sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve"; - sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve"; - sieve_default_name = "default"; - } - // (lib.optionalAttrs cfg.fullTextSearch.enable ftsPluginSettings); + enablePAM = mkForce false; sieve = { extensions = [ @@ -285,196 +246,232 @@ in } ]; - mailboxes = cfg.mailboxes; + settings = { + # vmail user + mail_uid = cfg.storage.owner; + mail_gid = cfg.storage.group; + mail_access_groups = cfg.storage.group; - extraConfig = '' - #Extra Config - ${lib.optionalString cfg.debug.dovecot '' - mail_debug = yes - auth_debug = yes - verbose_ssl = yes - ''} + # authentication + auth_mechanisms = [ + "plain" + "login" + ]; + disable_plaintext_auth = true; - ${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) '' - service imap-login { - inet_listener imap { - ${ - if cfg.enableImap then - '' - port = 143 - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } - } - inet_listener imaps { - ${ - if cfg.enableImapSsl then - '' - port = 993 - ssl = yes - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } - } - } - ''} - ${lib.optionalString (cfg.enablePop3 || cfg.enablePop3Ssl) '' - service pop3-login { - inet_listener pop3 { - ${ - if cfg.enablePop3 then - '' - port = 110 - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } - } - inet_listener pop3s { - ${ - if cfg.enablePop3Ssl then - '' - port = 995 - ssl = yes - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } - } - } - ''} + # backend services + "service auth" = { + "unix_listener auth" = { + mode = "0660"; + user = config.services.postfix.user; + group = config.services.postfix.group; + }; + }; + "service indexer-worker" = mkIf (cfg.fullTextSearch.memoryLimit != null) { + vsz_limit = "${toString cfg.fullTextSearch.memoryLimit} MB"; + }; + "service lmtp" = { + "unix_listener dovecot-lmtp" = { + group = config.services.postfix.group; + mode = "0600"; + user = config.services.postfix.user; + }; + vsz_limit = "${toString cfg.lmtpMemoryLimit} MB"; + }; + "service quota-status" = { + executable = toString [ + "${config.services.dovecot2.package}/libexec/dovecot/quota-status" + "-p" + "postfix" + ]; + "unix_listener quota-status" = { + user = "postfix"; + }; + client_limit = 1; + vsz_limit = "${toString cfg.quotaStatusMemoryLimit} MB"; + }; - protocol imap { - mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} - mail_plugins = $mail_plugins imap_sieve - } + # frontend services + "service imap-login" = mkIf (cfg.enableImap || cfg.enableImapSsl) { + "inet_listener imap" = { + # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html + port = if cfg.enableImap then 143 else 0; + }; + "inet_listener imaps" = { + # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html + port = if cfg.enableImapSsl then 993 else 0; + ssl = true; + }; + }; + "service pop3-login" = mkIf (cfg.enablePop3 || cfg.enablePop3Ssl) { + "inet_listener pop3" = { + # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html + port = if cfg.enablePop3 then 110 else 0; + }; + "inet_listener pop3s" = { + # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html + port = if cfg.enablePop3Ssl then 995 else 0; + ssl = true; + }; + }; + "service imap" = { + vsz_limit = "${toString cfg.imapMemoryLimit} MB"; + }; - service imap { - vsz_limit = ${builtins.toString cfg.imapMemoryLimit} MB - } + # protocols + protocols = [ + "lmtp" + ] + ++ lib.optionals (cfg.enableImap || cfg.enableImapSsl) [ "imap" ] + ++ lib.optionals (cfg.enablePop3 || cfg.enablePop3Ssl) [ "pop3" ] + ++ lib.optionals cfg.enableManageSieve [ "sieve" ]; - protocol pop3 { - mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} - } + "protocol lmtp" = { + mail_plugins = [ + "$mail_plugins" + "sieve" + ]; + }; + "protocol imap" = { + mail_max_userip_connections = cfg.maxConnectionsPerUser; + mail_plugins = [ + "$mail_plugins" + "imap_quota" + "imap_sieve" + ]; + }; + "protocol pop3" = { + mail_max_userip_connections = cfg.maxConnectionsPerUser; + }; - mail_access_groups = ${cfg.storage.group} + # globally enabled plugins + mail_plugins = [ + "$mail_plugins" + "quota" + ] + ++ lib.optionals cfg.fullTextSearch.enable [ + "fts" + "fts_flatcurve" + ]; + # tls settings + ssl_cert = "<${x509CertificateFile}"; + ssl_key = "<${x509PrivateKeyFile}"; # https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7 - ssl = required - ssl_min_protocol = TLSv1.2 - ssl_prefer_server_ciphers = no - ssl_cipher_list = ${ - lib.concatStringsSep ":" [ - # TLS1.3 - "TLS_AES_128_GCM_SHA256" - "TLS_CHACHA20_POLY1305_SHA256" - "TLS_AES_256_GCM_SHA384" - # TLS1.2 - # EC key material - "ECDHE-ECDSA-AES128-GCM-SHA256" - "ECDHE-ECDSA-CHACHA20-POLY1305" - "ECDHE-ECDSA-AES256-GCM-SHA384" - # RSA key material - "ECDHE-RSA-AES128-GCM-SHA256" - "ECDHE-RSA-CHACHA20-POLY1305" - "ECDHE-RSA-AES256-GCM-SHA384" - ] - } - ssl_curve_list = X25519MLKEM768:X25519:prime256v1:secp384r1 + ssl = "required"; + ssl_min_protocol = "TLSv1.2"; + ssl_prefer_server_ciphers = false; + ssl_cipher_list = lib.concatStringsSep ":" [ + # TLS1.3 + "TLS_AES_128_GCM_SHA256" + "TLS_CHACHA20_POLY1305_SHA256" + "TLS_AES_256_GCM_SHA384" + # TLS1.2 + # EC key material + "ECDHE-ECDSA-AES128-GCM-SHA256" + "ECDHE-ECDSA-CHACHA20-POLY1305" + "ECDHE-ECDSA-AES256-GCM-SHA384" + # RSA key material + "ECDHE-RSA-AES128-GCM-SHA256" + "ECDHE-RSA-CHACHA20-POLY1305" + "ECDHE-RSA-AES256-GCM-SHA384" + ]; + ssl_curve_list = lib.concatStringsSep ":" [ + "X25519MLKEM768" + "X25519" + "prime256v1" + "secp384r1" + ]; - service lmtp { - unix_listener dovecot-lmtp { - group = ${postfixCfg.group} - mode = 0600 - user = ${postfixCfg.user} + # default mail storage + # https://doc.dovecot.org/2.3/configuration_manual/home_directories_for_virtual_users/#ways-to-set-up-home-directory + mail_location = + "maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}" + + (lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/%{domain}/%{username}"); + + passdb = [ + { + driver = "passwd-file"; + args = "${passwdFile}"; } - vsz_limit = ${builtins.toString cfg.lmtpMemoryLimit} MB - } - - service quota-status { - inet_listener { - port = 0 + ] + ++ lib.optionals cfg.ldap.enable [ + { + driver = "ldap"; + args = "${ldapConfFile}"; } - unix_listener quota-status { - user = postfix + ]; + userdb = [ + { + driver = "passwd-file"; + args = "${userdbFile}"; + default_fields = [ + "home=${cfg.storage.path}/%{domain}/%{username}" + "uid=${builtins.toString cfg.storage.uid}" + "gid=${builtins.toString cfg.storage.gid}" + ]; } - vsz_limit = ${builtins.toString cfg.quotaStatusMemoryLimit} MB - } - - recipient_delimiter = ${cfg.recipientDelimiter} - lmtp_save_to_detail_mailbox = ${cfg.lmtpSaveToDetailMailbox} - - protocol lmtp { - mail_plugins = $mail_plugins sieve - } - - passdb { - driver = passwd-file - args = ${passwdFile} - } - - userdb { - driver = passwd-file - args = ${userdbFile} - default_fields = \ - home=${cfg.storage.path}/%{domain}/%{username} \ - uid=${builtins.toString cfg.storage.uid} \ - gid=${builtins.toString cfg.storage.uid} - } - - ${lib.optionalString cfg.ldap.enable '' - passdb { - driver = ldap - args = ${ldapConfFile} + ] + ++ lib.optionals cfg.ldap.enable [ + { + driver = "ldap"; + args = "${ldapConfFile}"; + override_fields = [ + "uid=${toString cfg.storage.uid}" + "gid=${toString cfg.storage.gid}" + ]; } + ]; - userdb { - driver = ldap - args = ${ldapConfFile} - override_fields = \ - uid=${toString cfg.storage.uid} \ - gid=${toString cfg.storage.uid} + # default user mailboxes + "namespace inbox" = { + inbox = true; + separator = "."; + } + // mapAttrs' (name: value: nameValuePair ''mailbox "${name}"'' value) cfg.mailboxes; + lda_mailbox_autosubscribe = true; + lda_mailbox_autocreate = true; + + # subaddressing + recipient_delimiter = cfg.recipientDelimiter; + lmtp_save_to_detail_mailbox = cfg.lmtpSaveToDetailMailbox; + + plugin = { + quota_rule = "*:storage=100G"; # TODO: quota option + quota = "count:User quota"; # per virtual mail user quota + quota_status_success = "DUNNO"; + quota_status_nouser = "DUNNO"; + quota_status_overquota = "552 5.2.2 Mailbox is full"; + quota_grace = "10%%"; + quota_vsizes = true; + + sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve"; + sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve"; + sieve_default_name = "default"; + } + // lib.optionalAttrs cfg.fullTextSearch.enable ( + { + fts = "flatcurve"; + fts_languages = cfg.fullTextSearch.languages; + fts_tokenizers = [ + "generic" + "email-address" + ]; + fts_tokenizer_email_address = "maxlen=100"; # default 254 too large for Xapian + fts_flatcurve_substring_search = cfg.fullTextSearch.substringSearch; + fts_filters = cfg.fullTextSearch.filters; + fts_header_excludes = cfg.fullTextSearch.headerExcludes; + fts_autoindex = cfg.fullTextSearch.autoIndex; + fts_enforced = cfg.fullTextSearch.enforced; } - ''} - - service auth { - unix_listener auth { - mode = 0660 - user = ${postfixCfg.user} - group = ${postfixCfg.group} - } - } - - auth_mechanisms = plain login - - namespace inbox { - separator = ${cfg.hierarchySeparator} - inbox = yes - } - - service indexer-worker { - ${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) '' - vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit * 1024 * 1024)} - ''} - } - - lda_mailbox_autosubscribe = yes - lda_mailbox_autocreate = yes - ''; + // (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude) + ); + } + // lib.optionalAttrs cfg.debug.dovecot { + mail_debug = true; + auth_debug = true; + verbose_ssl = true; + }; }; systemd.services.dovecot = { diff --git a/mail-server/environment.nix b/mail-server/environment.nix index 86a81df..020930a 100644 --- a/mail-server/environment.nix +++ b/mail-server/environment.nix @@ -27,7 +27,7 @@ in { config = lib.mkIf cfg.enable { environment.systemPackages = with pkgs; [ - dovecot + config.services.dovecot2.package openssh postfix rspamd From 0da8e2b1971732516c20619e32d76e1a8cf5b5b5 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Tue, 17 Mar 2026 02:21:10 +0100 Subject: [PATCH 3/4] quota: expose global quota settings With the options in the upstream dovecot module gone the quota support and its option now live in our downstream module. The only behavior change this introduces is not setting a global per user default instead of the previous 100G per user. Diabling quota support and setting per user quotas now raises an assertion: ```` Failed assertions: - Without quota support enabled, per-user quotas cannot be applied to the following accounts: - lowquota@example.com Either remove per user quota settings or re-enable `mailserver.quota.enable`. ```` --- default.nix | 36 +++++++++++++++++++++++++++-- mail-server/dovecot.nix | 46 ++++++++++++++++++++++++++++--------- mail-server/postfix.nix | 2 ++ scripts/generate-options.py | 1 + 4 files changed, 72 insertions(+), 13 deletions(-) diff --git a/default.nix b/default.nix index cb1f4d5..72317e4 100644 --- a/default.nix +++ b/default.nix @@ -137,6 +137,35 @@ in description = "Message size limit enforced by Postfix."; }; + quota = { + enable = mkOption { + type = types.bool; + default = true; + description = '' + Whether to enable quota support. + + When enabled, incoming mail can be rejected if a mailbox exceeds its + quota. + ''; + }; + + defaults = { + perUser = mkOption { + type = with types; nullOr str; + default = null; + example = "10G"; + description = '' + Default quota applied to all users. + + The value must use a size format like `500M`, `2G`, `10G`. + + If set to `null`, no default per user quota is applied and only + explicit per user quotas apply, if set. + ''; + }; + }; + }; + accounts = mkOption { type = types.attrsOf ( types.submodule ( @@ -260,8 +289,11 @@ in default = null; example = "2G"; description = '' - Per user quota rules. Accepted sizes are `xx k/M/G/T` with the - obvious meaning. Leave blank for the standard quota `100G`. + The quota limit for this user. + + The value is must use a size format like `500M`, `2G`, `10G`. + + If unset, will fall back to {option}`mailserver.quota.defaults.perUser` if set. ''; }; diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index cc4f564..59d9664 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -33,6 +33,9 @@ with (import ./common.nix { let inherit (lib) + attrNames + concatMapStringsSep + filterAttrs mapAttrs' mkForce mkIf @@ -170,6 +173,22 @@ in assertion = junkMailboxNumber == 1; message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special_use' flag set to '\\Junk' (${builtins.toString junkMailboxNumber} have been found)"; } + { + assertion = + let + usersWithQuota = attrNames ( + filterAttrs (_: account: account.quota != null) config.mailserver.loginAccounts + ); + in + !cfg.quota.enable -> usersWithQuota == { }; + message = '' + Without quota support enabled, per-user quotas cannot be applied to the following accounts: + + ${concatMapStringsSep "\n" (account: "- ${account}") quotaUsers} + + Either remove per user quota settings or re-enable `mailserver.quota.enable`. + ''; + } ]; warnings = @@ -278,7 +297,7 @@ in }; vsz_limit = "${toString cfg.lmtpMemoryLimit} MB"; }; - "service quota-status" = { + "service quota-status" = mkIf cfg.quota.enable { executable = toString [ "${config.services.dovecot2.package}/libexec/dovecot/quota-status" "-p" @@ -336,8 +355,10 @@ in mail_max_userip_connections = cfg.maxConnectionsPerUser; mail_plugins = [ "$mail_plugins" - "imap_quota" "imap_sieve" + ] + ++ lib.optionals cfg.quota.enable [ + "imap_quota" ]; }; "protocol pop3" = { @@ -347,6 +368,8 @@ in # globally enabled plugins mail_plugins = [ "$mail_plugins" + ] + ++ lib.optionals cfg.quota.enable [ "quota" ] ++ lib.optionals cfg.fullTextSearch.enable [ @@ -437,14 +460,6 @@ in lmtp_save_to_detail_mailbox = cfg.lmtpSaveToDetailMailbox; plugin = { - quota_rule = "*:storage=100G"; # TODO: quota option - quota = "count:User quota"; # per virtual mail user quota - quota_status_success = "DUNNO"; - quota_status_nouser = "DUNNO"; - quota_status_overquota = "552 5.2.2 Mailbox is full"; - quota_grace = "10%%"; - quota_vsizes = true; - sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve"; sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve"; sieve_default_name = "default"; @@ -465,7 +480,16 @@ in fts_enforced = cfg.fullTextSearch.enforced; } // (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude) - ); + ) + // lib.optionalAttrs cfg.quota.enable { + quota_rule = mkIf (cfg.quota.defaults.perUser != null) "*:storage=${cfg.quota.defaults.perUser}"; + quota = "count:User quota"; # per virtual mail user quota + quota_status_success = "DUNNO"; + quota_status_nouser = "DUNNO"; + quota_status_overquota = "552 5.2.2 Mailbox is full"; + quota_grace = "10%%"; + quota_vsizes = true; + }; } // lib.optionalAttrs cfg.debug.dovecot { mail_debug = true; diff --git a/mail-server/postfix.nix b/mail-server/postfix.nix index 52dc45a..0408138 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -373,6 +373,8 @@ in # reject selected recipients "check_recipient_access ${mappedFile "denied_recipients"}" "check_recipient_access ${mappedFile "reject_recipients"}" + ] + ++ lib.optionals cfg.quota.enable [ # quota checking "check_policy_service unix:/run/dovecot2/quota-status" ]; diff --git a/scripts/generate-options.py b/scripts/generate-options.py index dfba41d..eb13425 100644 --- a/scripts/generate-options.py +++ b/scripts/generate-options.py @@ -33,6 +33,7 @@ groups = [ "mailserver.dmarcReporting", "mailserver.tlsrpt", "mailserver.fullTextSearch", + "mailserver.quota", "mailserver.redis", "mailserver.ldap", "mailserver.monitoring", From f1e4af7184f6e0d25fbe4aeac6a079b560f6b73e Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Thu, 26 Mar 2026 12:50:00 +0100 Subject: [PATCH 4/4] dovecot: run lmtp service under storage owner user Previously it ran as root, which is not required since we use a single uid/gid for all mail storage. --- mail-server/dovecot.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index 59d9664..94682fa 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -295,6 +295,7 @@ in mode = "0600"; user = config.services.postfix.user; }; + user = cfg.storage.owner; vsz_limit = "${toString cfg.lmtpMemoryLimit} MB"; }; "service quota-status" = mkIf cfg.quota.enable {