Merge branch 'dovecot-rfc42' into 'main'

dovecot: migrate to settings option

See merge request simple-nixos-mailserver/nixos-mailserver!498
This commit is contained in:
Martin Weinelt
2026-04-12 23:25:29 +00:00
6 changed files with 307 additions and 247 deletions
+47 -12
View File
@@ -137,6 +137,35 @@ in
description = "Message size limit enforced by Postfix."; 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 { accounts = mkOption {
type = types.attrsOf ( type = types.attrsOf (
types.submodule ( types.submodule (
@@ -260,8 +289,11 @@ in
default = null; default = null;
example = "2G"; example = "2G";
description = '' description = ''
Per user quota rules. Accepted sizes are `xx k/M/G/T` with the The quota limit for this user.
obvious meaning. Leave blank for the standard quota `100G`.
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.
''; '';
}; };
@@ -654,11 +686,8 @@ in
}; };
lmtpSaveToDetailMailbox = mkOption { lmtpSaveToDetailMailbox = mkOption {
type = types.enum [ type = types.bool;
"yes" default = true;
"no"
];
default = "yes";
description = '' description = ''
If an email address is delimited by a "+", should it be filed into a If an email address is delimited by a "+", should it be filed into a
mailbox matching the string after the "+"? For example, mailbox matching the string after the "+"? For example,
@@ -882,25 +911,31 @@ in
mailboxes = mkOption { mailboxes = mkOption {
description = '' 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. 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 = { default = {
Trash = { Trash = {
auto = "no"; auto = "no";
specialUse = "Trash"; special_use = "\\Trash";
}; };
Junk = { Junk = {
auto = "subscribe"; auto = "subscribe";
specialUse = "Junk"; special_use = "\\Junk";
}; };
Drafts = { Drafts = {
auto = "subscribe"; auto = "subscribe";
specialUse = "Drafts"; special_use = "\\Drafts";
}; };
Sent = { Sent = {
auto = "subscribe"; auto = "subscribe";
specialUse = "Sent"; special_use = "\\Sent";
}; };
}; };
}; };
Generated
+3 -3
View File
@@ -79,11 +79,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1774935083, "lastModified": 1776030597,
"narHash": "sha256-Mh6bLcYAcENBAZk3RoMPMFCGGMZmfaGMERE4siZOgP4=", "narHash": "sha256-H2CYM/RmVqCo1iud5BhPp8Pim2d1ESGt2FDHjbmju8A=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "2f4fd5e1abf9bac8c1d22750c701a7a5e6b524c6", "rev": "c88e63f4caf12c731f61ce71f300680ce73c180e",
"type": "github" "type": "github"
}, },
"original": { "original": {
+233 -211
View File
@@ -32,6 +32,16 @@ with (import ./common.nix {
}); });
let let
inherit (lib)
attrNames
concatMapStringsSep
filterAttrs
mapAttrs'
mkForce
mkIf
nameValuePair
;
cfg = config.mailserver; cfg = config.mailserver;
passwdDir = "/run/dovecot2"; passwdDir = "/run/dovecot2";
@@ -39,8 +49,6 @@ let
userdbFile = "${passwdDir}/userdb"; userdbFile = "${passwdDir}/userdb";
# This file contains the ldap bind password # This file contains the ldap bind password
ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext"; ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext";
boolToYesNo = x: if x then "yes" else "no";
listToLine = lib.concatStringsSep " ";
listToMultiAttrs = listToMultiAttrs =
keyPrefix: attrs: keyPrefix: attrs:
lib.listToAttrs ( lib.listToAttrs (
@@ -53,14 +61,6 @@ let
maildirLayoutAppendix = lib.optionalString (cfg.storage.directoryLayout == "fs") ":LAYOUT=fs"; maildirLayoutAppendix = lib.optionalString (cfg.storage.directoryLayout == "fs") ":LAYOUT=fs";
maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8"; 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 { ldapConfig = pkgs.writeTextFile {
name = "dovecot-ldap.conf.ext.template"; name = "dovecot-ldap.conf.ext.template";
text = '' text = ''
@@ -128,12 +128,13 @@ let
lib.mapAttrsToList ( lib.mapAttrsToList (
name: _: name: _:
if lib.elem name accountsWithPlaintextPasswordFiles then 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 else
"${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::" "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
) cfg.accounts ) cfg.accounts
)} )}
EOF EOF
chown dovecot2:dovecot2 ${passwdFile}
cat <<EOF > ${userdbFile} cat <<EOF > ${userdbFile}
${lib.concatStringsSep "\n" ( ${lib.concatStringsSep "\n" (
@@ -144,10 +145,11 @@ let
) cfg.accounts ) cfg.accounts
)} )}
EOF EOF
chown dovecot2:dovecot2 ${userdbFile}
''; '';
junkMailboxes = builtins.attrNames ( 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; junkMailboxNumber = builtins.length junkMailboxes;
# The assertion guarantees there is exactly one Junk mailbox. # The assertion guarantees there is exactly one Junk mailbox.
@@ -163,30 +165,29 @@ let
else else
scope 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 in
{ {
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
assertions = [ assertions = [
{ {
assertion = junkMailboxNumber == 1; 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)";
}
{
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`.
'';
} }
]; ];
@@ -215,7 +216,7 @@ in
# the global config and tries to open shared libraries configured in there, # the global config and tries to open shared libraries configured in there,
# which are usually not compatible. # which are usually not compatible.
environment.systemPackages = [ environment.systemPackages = [
pkgs.dovecot_pigeonhole pkgs.dovecot_pigeonhole_0_5
] ]
++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve; ++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve;
@@ -223,30 +224,9 @@ in
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules"; environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
services.dovecot2 = { services.dovecot2 = {
package = pkgs.dovecot_2_3;
enable = true; enable = true;
enableImap = cfg.enableImap || cfg.enableImapSsl; enablePAM = mkForce false;
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);
sieve = { sieve = {
extensions = [ extensions = [
@@ -285,100 +265,127 @@ in
} }
]; ];
mailboxes = cfg.mailboxes; settings = {
# vmail user
mail_uid = cfg.storage.owner;
mail_gid = cfg.storage.group;
mail_access_groups = cfg.storage.group;
extraConfig = '' # authentication
#Extra Config auth_mechanisms = [
${lib.optionalString cfg.debug.dovecot '' "plain"
mail_debug = yes "login"
auth_debug = yes ];
verbose_ssl = yes disable_plaintext_auth = true;
''}
${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) '' # backend services
service imap-login { "service auth" = {
inet_listener imap { "unix_listener auth" = {
${ mode = "0660";
if cfg.enableImap then user = config.services.postfix.user;
'' group = config.services.postfix.group;
port = 143 };
'' };
else "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;
};
user = cfg.storage.owner;
vsz_limit = "${toString cfg.lmtpMemoryLimit} MB";
};
"service quota-status" = mkIf cfg.quota.enable {
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";
};
# frontend services
"service imap-login" = mkIf (cfg.enableImap || cfg.enableImapSsl) {
"inet_listener imap" = {
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0 port = if cfg.enableImap then 143 else 0;
'' };
} "inet_listener imaps" = {
}
inet_listener imaps {
${
if cfg.enableImapSsl then
''
port = 993
ssl = yes
''
else
''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0 port = if cfg.enableImapSsl then 993 else 0;
'' ssl = true;
} };
} };
} "service pop3-login" = mkIf (cfg.enablePop3 || cfg.enablePop3Ssl) {
''} "inet_listener pop3" = {
${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 # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0 port = if cfg.enablePop3 then 110 else 0;
'' };
} "inet_listener pop3s" = {
}
inet_listener pop3s {
${
if cfg.enablePop3Ssl then
''
port = 995
ssl = yes
''
else
''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0 port = if cfg.enablePop3Ssl then 995 else 0;
'' ssl = true;
} };
} };
} "service imap" = {
''} vsz_limit = "${toString cfg.imapMemoryLimit} MB";
};
protocol imap { # protocols
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} protocols = [
mail_plugins = $mail_plugins imap_sieve "lmtp"
} ]
++ lib.optionals (cfg.enableImap || cfg.enableImapSsl) [ "imap" ]
++ lib.optionals (cfg.enablePop3 || cfg.enablePop3Ssl) [ "pop3" ]
++ lib.optionals cfg.enableManageSieve [ "sieve" ];
service imap { "protocol lmtp" = {
vsz_limit = ${builtins.toString cfg.imapMemoryLimit} MB mail_plugins = [
} "$mail_plugins"
"sieve"
];
};
"protocol imap" = {
mail_max_userip_connections = cfg.maxConnectionsPerUser;
mail_plugins = [
"$mail_plugins"
"imap_sieve"
]
++ lib.optionals cfg.quota.enable [
"imap_quota"
];
};
"protocol pop3" = {
mail_max_userip_connections = cfg.maxConnectionsPerUser;
};
protocol pop3 { # globally enabled plugins
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} mail_plugins = [
} "$mail_plugins"
]
mail_access_groups = ${cfg.storage.group} ++ lib.optionals cfg.quota.enable [
"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 # https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7
ssl = required ssl = "required";
ssl_min_protocol = TLSv1.2 ssl_min_protocol = "TLSv1.2";
ssl_prefer_server_ciphers = no ssl_prefer_server_ciphers = false;
ssl_cipher_list = ${ ssl_cipher_list = lib.concatStringsSep ":" [
lib.concatStringsSep ":" [
# TLS1.3 # TLS1.3
"TLS_AES_128_GCM_SHA256" "TLS_AES_128_GCM_SHA256"
"TLS_CHACHA20_POLY1305_SHA256" "TLS_CHACHA20_POLY1305_SHA256"
@@ -392,89 +399,104 @@ in
"ECDHE-RSA-AES128-GCM-SHA256" "ECDHE-RSA-AES128-GCM-SHA256"
"ECDHE-RSA-CHACHA20-POLY1305" "ECDHE-RSA-CHACHA20-POLY1305"
"ECDHE-RSA-AES256-GCM-SHA384" "ECDHE-RSA-AES256-GCM-SHA384"
];
ssl_curve_list = lib.concatStringsSep ":" [
"X25519MLKEM768"
"X25519"
"prime256v1"
"secp384r1"
];
# 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}";
}
] ]
++ lib.optionals cfg.ldap.enable [
{
driver = "ldap";
args = "${ldapConfFile}";
} }
ssl_curve_list = X25519MLKEM768:X25519:prime256v1:secp384r1 ];
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}"
];
}
]
++ lib.optionals cfg.ldap.enable [
{
driver = "ldap";
args = "${ldapConfFile}";
override_fields = [
"uid=${toString cfg.storage.uid}"
"gid=${toString cfg.storage.gid}"
];
}
];
service lmtp { # default user mailboxes
unix_listener dovecot-lmtp { "namespace inbox" = {
group = ${postfixCfg.group} inbox = true;
mode = 0600 separator = ".";
user = ${postfixCfg.user}
}
vsz_limit = ${builtins.toString cfg.lmtpMemoryLimit} MB
} }
// mapAttrs' (name: value: nameValuePair ''mailbox "${name}"'' value) cfg.mailboxes;
lda_mailbox_autosubscribe = true;
lda_mailbox_autocreate = true;
service quota-status { # subaddressing
inet_listener { recipient_delimiter = cfg.recipientDelimiter;
port = 0 lmtp_save_to_detail_mailbox = cfg.lmtpSaveToDetailMailbox;
}
unix_listener quota-status {
user = postfix
}
vsz_limit = ${builtins.toString cfg.quotaStatusMemoryLimit} MB
}
recipient_delimiter = ${cfg.recipientDelimiter} plugin = {
lmtp_save_to_detail_mailbox = ${cfg.lmtpSaveToDetailMailbox} sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve";
sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve";
protocol lmtp { sieve_default_name = "default";
mail_plugins = $mail_plugins sieve
} }
// lib.optionalAttrs cfg.fullTextSearch.enable (
passdb { {
driver = passwd-file fts = "flatcurve";
args = ${passwdFile} 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;
} }
// (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude)
userdb { )
driver = passwd-file // lib.optionalAttrs cfg.quota.enable {
args = ${userdbFile} quota_rule = mkIf (cfg.quota.defaults.perUser != null) "*:storage=${cfg.quota.defaults.perUser}";
default_fields = \ quota = "count:User quota"; # per virtual mail user quota
home=${cfg.storage.path}/%{domain}/%{username} \ quota_status_success = "DUNNO";
uid=${builtins.toString cfg.storage.uid} \ quota_status_nouser = "DUNNO";
gid=${builtins.toString cfg.storage.uid} quota_status_overquota = "552 5.2.2 Mailbox is full";
quota_grace = "10%%";
quota_vsizes = true;
};
} }
// lib.optionalAttrs cfg.debug.dovecot {
${lib.optionalString cfg.ldap.enable '' mail_debug = true;
passdb { auth_debug = true;
driver = ldap verbose_ssl = true;
args = ${ldapConfFile} };
}
userdb {
driver = ldap
args = ${ldapConfFile}
override_fields = \
uid=${toString cfg.storage.uid} \
gid=${toString cfg.storage.uid}
}
''}
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
'';
}; };
systemd.services.dovecot = { systemd.services.dovecot = {
+1 -1
View File
@@ -27,7 +27,7 @@ in
{ {
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
dovecot config.services.dovecot2.package
openssh openssh
postfix postfix
rspamd rspamd
+2
View File
@@ -373,6 +373,8 @@ in
# reject selected recipients # reject selected recipients
"check_recipient_access ${mappedFile "denied_recipients"}" "check_recipient_access ${mappedFile "denied_recipients"}"
"check_recipient_access ${mappedFile "reject_recipients"}" "check_recipient_access ${mappedFile "reject_recipients"}"
]
++ lib.optionals cfg.quota.enable [
# quota checking # quota checking
"check_policy_service unix:/run/dovecot2/quota-status" "check_policy_service unix:/run/dovecot2/quota-status"
]; ];
+1
View File
@@ -33,6 +33,7 @@ groups = [
"mailserver.dmarcReporting", "mailserver.dmarcReporting",
"mailserver.tlsrpt", "mailserver.tlsrpt",
"mailserver.fullTextSearch", "mailserver.fullTextSearch",
"mailserver.quota",
"mailserver.redis", "mailserver.redis",
"mailserver.ldap", "mailserver.ldap",
"mailserver.monitoring", "mailserver.monitoring",