dovecot: migrate to dovecot 2.4

This commit is contained in:
Martin Weinelt
2026-04-18 16:15:29 +02:00
parent 7dce7fbd5a
commit f9d1435378
6 changed files with 391 additions and 359 deletions
+318 -309
View File
@@ -39,6 +39,7 @@ let
mapAttrs'
mkForce
mkIf
mkMerge
nameValuePair
;
@@ -47,56 +48,6 @@ let
passwdDir = "/run/dovecot2";
passwdFile = "${passwdDir}/passwd";
userdbFile = "${passwdDir}/userdb";
# This file contains the ldap bind password
ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext";
listToMultiAttrs =
keyPrefix: attrs:
lib.listToAttrs (
lib.imap1 (n: x: {
name = "${keyPrefix}${if n == 1 then "" else toString n}";
value = x;
}) attrs
);
maildirLayoutAppendix = lib.optionalString (cfg.storage.directoryLayout == "fs") ":LAYOUT=fs";
maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8";
ldapConfig = pkgs.writeTextFile {
name = "dovecot-ldap.conf.ext.template";
text = ''
ldap_version = 3
uris = ${lib.concatStringsSep " " cfg.ldap.uris}
${lib.optionalString cfg.ldap.startTls ''
tls = yes
''}
tls_require_cert = hard
tls_ca_cert_file = ${cfg.ldap.caFile}
dn = ${cfg.ldap.bind.dn}
sasl_bind = no
auth_bind = yes
base = ${cfg.ldap.base}
scope = ${mkLdapSearchScope cfg.ldap.scope}
user_attrs = \
=home=${cfg.storage.path}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}, \
=mail=maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}${
lib.optionalString (
cfg.indexDir != null
) ":INDEX=${cfg.indexDir}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}"
}
user_filter = ${cfg.ldap.dovecot.userFilter}
pass_attrs = ${cfg.ldap.attributes.password}=password
pass_filter = ${cfg.ldap.dovecot.passFilter}
'';
};
setPwdInLdapConfFile = appendLdapBindPwd {
name = "ldap-conf-file";
file = ldapConfig;
prefix = ''dnpass = "'';
suffix = ''"'';
passwordFile = cfg.ldap.bind.passwordFile;
destination = ldapConfFile;
};
genPasswdScript =
pkgs.writeScript "generate-password-file"
@@ -130,7 +81,7 @@ let
if lib.elem name accountsWithPlaintextPasswordFiles then
"${name}:${"$(sed -n '1{p;p;q}' ${passwordFiles."${name}"} | ${lib.getExe' config.services.dovecot2.package "doveadm"} pw)"}::::::"
else
"${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
"${name}:{CRYPT}${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
) cfg.accounts
)}
EOF
@@ -140,8 +91,11 @@ let
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: value:
# https://doc.dovecot.org/2.4.3/core/config/auth/databases/passwd_file.html
# https://doc.dovecot.org/2.4.3/core/plugins/quota.html#per-user-quota
# https://dovecot.org/mailman3/archives/list/dovecot@dovecot.org/thread/67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO/#67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO
"${name}:::::::"
+ lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}"
+ lib.optionalString (value.quota != null) "userdb_quota/user/storage_size=${value.quota}"
) cfg.accounts
)}
EOF
@@ -213,310 +167,365 @@ in
};
};
# for sieve-test. Shelling it in on demand usually doesn't work, as it reads
# the global config and tries to open shared libraries configured in there,
# which are usually not compatible.
# Dovecot modules
environment.systemPackages = [
pkgs.dovecot_pigeonhole_0_5
]
++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve;
pkgs.dovecot_pigeonhole
];
# For compatibility with python imaplib
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
services.dovecot2 = {
package = pkgs.dovecot_2_3;
enable = true;
package = pkgs.dovecot; # pin over stateVersion logic in nixox 26.05
enablePAM = mkForce false;
sieve = {
extensions = [
"fileinto"
];
scripts.after = builtins.toFile "spam.sieve" ''
require "fileinto";
if header :is "X-Spam" "Yes" {
fileinto "${junkMailboxName}";
stop;
}
'';
pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_ham")
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_spam")
];
};
imapsieve.mailbox = [
{
name = junkMailboxName;
causes = [
"COPY"
"APPEND"
];
before = ./dovecot/imap_sieve/report-spam.sieve;
}
{
name = "*";
from = junkMailboxName;
causes = [ "COPY" ];
before = ./dovecot/imap_sieve/report-ham.sieve;
}
sieve.pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_ham")
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_spam")
];
settings = {
# vmail user
mail_uid = cfg.storage.owner;
mail_gid = cfg.storage.group;
mail_access_groups = cfg.storage.group;
# https://doc.dovecot.org/2.4.3/core/settings/syntax.html
# https://doc.dovecot.org/2.4.3/core/settings/types.html#boolean-list
settings = mkMerge [
({
# https://doc.dovecot.org/main/core/summaries/settings.html#dovecot_config_version
dovecot_config_version = "2.4.3";
# https://doc.dovecot.org/main/core/summaries/settings.html#dovecot_storage_version
dovecot_storage_version = "2.3.21.1";
# authentication
auth_mechanisms = [
"plain"
"login"
];
disable_plaintext_auth = true;
# server identity
hostname = cfg.fqdn;
# hostname
hostname = cfg.fqdn;
# vmail user
mail_uid = cfg.storage.owner;
mail_gid = cfg.storage.group;
mail_access_groups = cfg.storage.group;
# 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;
};
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"
# authentication
auth_mechanisms = [
"plain"
"login"
];
"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
port = if cfg.enableImap then 143 else 0;
# backend services
"service anvil" = {
"unix_listener anvil" = {
mode = "0660";
group = cfg.storage.group;
};
};
"inet_listener imaps" = {
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = if cfg.enableImapSsl then 993 else 0;
ssl = true;
"service auth" = {
"unix_listener auth" = {
user = config.services.postfix.user;
group = config.services.postfix.group;
mode = "0660";
};
};
};
"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;
"service lmtp" = {
"unix_listener dovecot-lmtp" = {
user = config.services.postfix.user;
group = config.services.postfix.group;
mode = "0600";
};
user = cfg.storage.owner;
vsz_limit = "${toString cfg.lmtpMemoryLimit} MB";
};
"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";
};
# protocols
protocols = [
"lmtp"
]
++ lib.optionals (cfg.enableImap || cfg.enableImapSsl) [ "imap" ]
++ lib.optionals (cfg.enablePop3 || cfg.enablePop3Ssl) [ "pop3" ]
++ lib.optionals cfg.enableManageSieve [ "sieve" ];
# frontend services
"service imap-login" = mkIf (cfg.enableImap || cfg.enableImapSsl) {
"inet_listener imap" = {
# https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = if cfg.enableImap then 143 else 0;
};
"inet_listener imaps" = mkIf cfg.enableImapSsl {
port = 993;
ssl = true;
};
};
"service pop3-login" = mkIf (cfg.enablePop3 || cfg.enablePop3Ssl) {
"inet_listener pop3" = {
# https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = if cfg.enablePop3 then 110 else 0;
};
"inet_listener pop3s" = mkIf cfg.enablePop3Ssl {
port = 995;
ssl = true;
};
};
"service imap" = {
vsz_limit = "${toString cfg.imapMemoryLimit} MB";
};
"protocol lmtp" = {
mail_plugins = [
"$mail_plugins"
"sieve"
# protocols
protocols = {
lmtp = true;
imap = cfg.enableImap || cfg.enableImapSsl;
pop3 = cfg.enablePop3 || cfg.enablePop3Ssl;
sieve = cfg.enableManageSieve;
};
"protocol lmtp" = {
mail_plugins = {
sieve = true;
};
};
"protocol imap" = {
mail_max_userip_connections = cfg.maxConnectionsPerUser;
mail_plugins = {
imap_sieve = true;
};
};
"protocol pop3" = {
mail_max_userip_connections = cfg.maxConnectionsPerUser;
};
# tls settings
ssl_server_cert_file = x509CertificateFile;
ssl_server_key_file = 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_server_prefer_ciphers = "client";
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"
];
};
"protocol imap" = {
mail_max_userip_connections = cfg.maxConnectionsPerUser;
mail_plugins = [
"$mail_plugins"
"imap_sieve"
]
++ lib.optionals cfg.quota.enable [
"imap_quota"
ssl_curve_list = lib.concatStringsSep ":" [
"X25519MLKEM768"
"X25519"
"prime256v1"
"secp384r1"
];
};
"protocol pop3" = {
mail_max_userip_connections = cfg.maxConnectionsPerUser;
};
# globally enabled plugins
mail_plugins = [
"$mail_plugins"
]
++ lib.optionals cfg.quota.enable [
"quota"
]
++ lib.optionals cfg.fullTextSearch.enable [
"fts"
"fts_flatcurve"
];
# default user mailboxes
"namespace inbox" = {
inbox = true;
separator = cfg.hierarchySeparator;
}
// mapAttrs' (name: value: nameValuePair ''mailbox "${name}"'' value) cfg.mailboxes;
lda_mailbox_autosubscribe = true;
lda_mailbox_autocreate = true;
# 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 = 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"
];
# subaddressing
recipient_delimiter = cfg.recipientDelimiter;
lmtp_save_to_detail_mailbox = cfg.lmtpSaveToDetailMailbox;
# 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}");
# sieve filtering
"sieve_script spamfilter" = {
# junk filter
path = pkgs.writeText "after.sieve" ''
require "fileinto";
passdb = [
{
if header :is "X-Spam" "Yes" {
fileinto "${junkMailboxName}";
stop;
}
'';
type = "after";
};
"sieve_script default" = {
# declarative
type = "default";
path = "${cfg.sieveDirectory}/%{user}/default.sieve";
};
"sieve_script personal" = {
# managesieve
type = "personal";
active_path = "${cfg.sieveDirectory}/%{user}/active.sieve";
path = "${cfg.sieveDirectory}/%{user}/scripts";
};
sieve_extensions = {
fileinto = true;
};
sieve_global_extensions = {
"vnd.dovecot.pipe" = true;
};
sieve_plugins = {
sieve_imapsieve = true;
sieve_extprograms = true;
};
# imapsieve (spam/ham learning)
"mailbox ${junkMailboxName}" = {
"sieve_script spam" = {
cause = [
"APPEND"
"COPY"
];
path = ./dovecot/imap_sieve/report-spam.sieve;
type = "before";
};
};
"imapsieve_from ${junkMailboxName}" = {
"sieve_script ham" = {
cause = "copy";
path = ./dovecot/imap_sieve/report-ham.sieve;
type = "before";
};
};
mailbox_list_layout = cfg.storage.directoryLayout;
mailbox_list_utf8 = cfg.useUTF8FolderNames;
mail_driver = "maildir";
mail_path = "~/mail";
# declarative users
"userdb declarative" = {
driver = "passwd-file";
args = "${passwdFile}";
}
]
++ lib.optionals cfg.ldap.enable [
{
driver = "ldap";
args = "${ldapConfFile}";
}
];
userdb = [
{
passwd_file_path = userdbFile;
fields = {
home = "${cfg.storage.path}/%{user | domain}/%{user | username}";
inherit (cfg.storage) uid gid;
mail_index_path = "${
if cfg.indexDir != null then cfg.indexDir else cfg.storage.path
}/%{user | domain }/%{user | username}";
};
};
"passdb declarative" = {
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 [
{
passwd_file_path = passwdFile;
};
})
(mkIf cfg.ldap.enable {
# ldap users
ssl_client_ca_file = cfg.ldap.caFile;
ssl_client_require_valid_cert = true;
ldap_version = 3;
ldap_uris = cfg.ldap.uris;
ldap_starttls = cfg.ldap.startTls;
ldap_auth_dn = cfg.ldap.bind.dn;
ldap_auth_dn_password = "</run/credentials/dovecot.service/ldap-bind-pw";
ldap_base = cfg.ldap.base;
ldap_scope = mkLdapSearchScope cfg.ldap.scope;
"userdb ldap" = {
driver = "ldap";
args = "${ldapConfFile}";
override_fields = [
"uid=${toString cfg.storage.uid}"
"gid=${toString cfg.storage.gid}"
filter = cfg.ldap.dovecot.userFilter;
fields = {
home = "${cfg.storage.path}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
inherit (cfg.storage) uid gid;
mail_index_path = "${
if cfg.indexDir != null then cfg.indexDir else cfg.storage.path
}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
};
ldap_connection_group = "ldap-userdb-conn";
};
"passdb ldap" = {
driver = "ldap";
filter = cfg.ldap.dovecot.passFilter;
fields = {
password = "%{ldap:userPassword}";
};
ldap_connection_group = "ldap-passdb-conn";
};
})
(mkIf cfg.quota.enable {
mail_plugins.quota = true;
"protocol imap".mail_plugins.imap_quota = true;
"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";
};
# default user mailboxes
"namespace inbox" = {
inbox = true;
separator = cfg.hierarchySeparator;
}
// 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 = {
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;
}
// (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;
auth_debug = true;
verbose_ssl = true;
};
# quota_storage_grace = "10M";
"quota user" = {
driver = "count";
storage_size = mkIf (cfg.quota.defaults.perUser != null) cfg.quota.defaults.perUser;
};
})
(mkIf cfg.fullTextSearch.enable (
{
mail_plugins = {
fts = true;
fts_flatcurve = true;
};
"service indexer-worker" = mkIf (cfg.fullTextSearch.memoryLimit != null) {
vsz_limit = "${toString cfg.fullTextSearch.memoryLimit} MB";
};
fts_autoindex = cfg.fullTextSearch.autoIndex;
fts_driver = "flatcurve";
fts_search_add_missing = "yes";
fts_search_read_fallback = cfg.fullTextSearch.fallback;
fts_header_excludes = lib.genAttrs cfg.fullTextSearch.headerExcludes (_: true);
"fts flatcurve" = {
flatcurve_substring_search = cfg.fullTextSearch.substringSearch;
};
# languages
language_filters = lib.genAttrs cfg.fullTextSearch.filters (_: true);
language_tokenizer_address_token_maxlen = 100; # default 250 too large for Xapian
}
# build languages from list, the first one becomes the default language
// lib.listToAttrs (
lib.imap0 (i: lang: {
name = "language ${lang}";
value = (if i == 0 then { default = true; } else { }) // {
language_tokenizers = [
"generic"
"email-address"
];
};
}) cfg.fullTextSearch.languages
)
))
(mkIf cfg.debug.dovecot {
mail_debug = true;
# https://doc.dovecot.org/2.4.3/core/config/events/filter.html#common-unified-filter-language
log_debug = "category=ssl OR category=auth";
})
];
};
systemd.services.dovecot = {
preStart = ''
${genPasswdScript}
''
+ (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
'';
reloadTriggers = lib.mkIf (!withACME) [
x509CertificateFile
x509PrivateKeyFile
];
serviceConfig = lib.optionalAttrs cfg.ldap.enable {
LoadCredential = [
"ldap-bind-pw:${cfg.ldap.bind.passwordFile}"
];
};
};
systemd.services.postfix.restartTriggers = [
genPasswdScript
]
++ (lib.optional cfg.ldap.enable [ setPwdInLdapConfFile ]);
];
};
}