260f38128e
This simplifies the remaining structure of `cfg.sieveDirectory` a lot and gets us one step closer to removing `activate-virtual-mail-users.service`.
552 lines
18 KiB
Nix
552 lines
18 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";
|
|
name = "default";
|
|
# TODO: Pre-compile Sieve scripts with 'sievec' (requires a Dovecot config in build sandbox)
|
|
path = "${
|
|
pkgs.runCommand "declarative-sieve-scripts" { } (
|
|
''
|
|
mkdir "$out"
|
|
''
|
|
+ lib.concatMapAttrsStringSep "\n" (_: value: ''
|
|
mkdir "$out/${value.name}"
|
|
cp -v "${builtins.toFile "default.sieve" value.sieveScript}" "$out/${value.name}/default.sieve"
|
|
'') (lib.filterAttrs (_: value: value.sieveScript != null) cfg.accounts)
|
|
)
|
|
}/%{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;
|
|
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";
|
|
};
|
|
|
|
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
|
|
];
|
|
};
|
|
}
|