Files
simple-nixos-mailserver/mail-server/dovecot.nix
T
emilylange eea473ea12 dovecot: reintroduce LDAP bind auth for passdb
LDAP bind auth used to be enabled by default (and not configurable)
before the dovecot 2.4 migration.

I changed the default option value to match the old Dovecot 2.3
behavior.

The use of authentication bind is required for LDAP servers that simply
do not have such LDAP attribute like Kanidm, or in cases where the
password scheme used is not supported by Dovecot.
2026-05-24 02:01:55 +02:00

541 lines
17 KiB
Nix

# nixos-mailserver: a simple mail server
# Copyright (C) 2016-2018 Robin Raymond
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
pkgs
lib
;
});
let
inherit (lib)
attrNames
concatMapStringsSep
filterAttrs
mapAttrs'
mkForce
mkIf
mkMerge
nameValuePair
;
cfg = config.mailserver;
passwdDir = "/run/dovecot2";
passwdFile = "${passwdDir}/passwd";
userdbFile = "${passwdDir}/userdb";
genPasswdScript =
pkgs.writeScript "generate-password-file"
# bash
''
#!${pkgs.stdenv.shell}
set -euo pipefail
if (! test -d "${passwdDir}"); then
mkdir "${passwdDir}"
chmod 755 "${passwdDir}"
fi
# Prevent world-readable password files, even temporarily.
umask 077
prepend_scheme() {
case "$1" in
{*}*) printf '%s' "$1" ;;
*) printf '{CRYPT}%s' "$1" ;;
esac
}
for f in ${
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.accounts)
}; do
if [ ! -f "$f" ]; then
echo "Expected password hash file $f does not exist!"
exit 1
fi
done
cat <<EOF > ${passwdFile}
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: _:
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}:${"$(prepend_scheme \"$(head -n 1 ${passwordFiles."${name}"})\")"}::::::"
) cfg.accounts
)}
EOF
chown dovecot2:dovecot2 ${passwdFile}
cat <<EOF > ${userdbFile}
${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/user/storage_size=${value.quota}"
) cfg.accounts
)}
EOF
chown dovecot2:dovecot2 ${userdbFile}
'';
junkMailboxes = builtins.attrNames (
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.
junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else "";
mkLdapSearchScope =
scope:
(
if scope == "sub" then
"subtree"
else if scope == "one" then
"onelevel"
else
scope
);
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)";
}
(
let
usersWithQuota = attrNames (
filterAttrs (_: account: account.quota != null) config.mailserver.accounts
);
in
{
assertion = !cfg.quota.enable -> usersWithQuota == [ ];
message = ''
Without quota support enabled, per-user quotas cannot be applied to the following accounts:
${concatMapStringsSep "\n" (account: "- ${account}") usersWithQuota}
Either remove per user quota settings or re-enable `mailserver.quota.enable`.
'';
}
)
];
warnings =
lib.optional
(
(builtins.length cfg.fullTextSearch.languages > 1)
&& (builtins.elem "stopwords" cfg.fullTextSearch.filters)
)
''
Using stopwords in `mailserver.fullTextSearch.filters` with multiple
languages in `mailserver.fullTextSearch.languages` configured WILL
cause some searches to fail.
The recommended solution is to NOT use the stopword filter when
multiple languages are present in the configuration.
'';
security.acme.certs = lib.mkIf withACME {
${cfg.x509.useACMEHost} = {
reloadServices = [ "dovecot.service" ];
};
};
# Dovecot modules
environment.systemPackages = [
pkgs.dovecot_pigeonhole
];
# For compatibility with python imaplib
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
services.dovecot2 = {
enable = true;
package = pkgs.dovecot; # pin over stateVersion logic in nixox 26.05
enablePAM = mkForce false;
sieve.pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${lib.getExe' config.services.rspamd.package "rspamc"} -h /run/rspamd/worker-controller.sock learn_ham")
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${lib.getExe' config.services.rspamd.package "rspamc"} -h /run/rspamd/worker-controller.sock learn_spam")
];
# 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";
# server identity
hostname = cfg.fqdn;
# vmail user
mail_uid = cfg.storage.owner;
mail_gid = cfg.storage.group;
mail_access_groups = cfg.storage.group;
# authentication
auth_mechanisms = [
"plain"
"login"
];
# backend services
"service anvil" = {
"unix_listener anvil" = {
mode = "0660";
group = cfg.storage.group;
};
};
"service auth" = {
"unix_listener auth" = {
user = config.services.postfix.user;
group = config.services.postfix.group;
mode = "0660";
};
};
"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";
};
# 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";
};
# 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"
];
ssl_curve_list = lib.concatStringsSep ":" [
"X25519MLKEM768"
"X25519"
"SecP256r1MLKEM768"
"prime256v1"
"secp384r1"
];
# 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;
# sieve filtering
"sieve_script spamfilter" = {
# junk filter
path = pkgs.writeText "after.sieve" ''
require "fileinto";
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";
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";
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";
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;
bind = cfg.ldap.attributes.password == null;
fields = {
password = mkIf (cfg.ldap.attributes.password != null) "%{ldap:${cfg.ldap.attributes.password}}";
};
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";
};
quota_status_success = "DUNNO";
quota_status_nouser = "DUNNO";
quota_status_overquota = "552 5.2.2 Mailbox is full";
# 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}
'';
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
];
};
}