0da8e2b197
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`. ````
518 lines
16 KiB
Nix
518 lines
16 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
|
|
nameValuePair
|
|
;
|
|
|
|
cfg = config.mailserver;
|
|
|
|
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"
|
|
# 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
|
|
|
|
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}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
|
|
) cfg.accounts
|
|
)}
|
|
EOF
|
|
chown dovecot2:dovecot2 ${passwdFile}
|
|
|
|
cat <<EOF > ${userdbFile}
|
|
${lib.concatStringsSep "\n" (
|
|
lib.mapAttrsToList (
|
|
name: value:
|
|
"${name}:::::::"
|
|
+ lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${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)";
|
|
}
|
|
{
|
|
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 =
|
|
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" ];
|
|
};
|
|
};
|
|
|
|
# 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.
|
|
environment.systemPackages = [
|
|
pkgs.dovecot_pigeonhole_0_5
|
|
]
|
|
++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve;
|
|
|
|
# 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;
|
|
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;
|
|
}
|
|
];
|
|
|
|
settings = {
|
|
# vmail user
|
|
mail_uid = cfg.storage.owner;
|
|
mail_gid = cfg.storage.group;
|
|
mail_access_groups = cfg.storage.group;
|
|
|
|
# authentication
|
|
auth_mechanisms = [
|
|
"plain"
|
|
"login"
|
|
];
|
|
disable_plaintext_auth = true;
|
|
|
|
# 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" = 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
|
|
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";
|
|
};
|
|
|
|
# protocols
|
|
protocols = [
|
|
"lmtp"
|
|
]
|
|
++ lib.optionals (cfg.enableImap || cfg.enableImapSsl) [ "imap" ]
|
|
++ lib.optionals (cfg.enablePop3 || cfg.enablePop3Ssl) [ "pop3" ]
|
|
++ lib.optionals cfg.enableManageSieve [ "sieve" ];
|
|
|
|
"protocol lmtp" = {
|
|
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;
|
|
};
|
|
|
|
# globally enabled plugins
|
|
mail_plugins = [
|
|
"$mail_plugins"
|
|
]
|
|
++ 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
|
|
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"
|
|
];
|
|
|
|
# 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}";
|
|
}
|
|
];
|
|
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}"
|
|
];
|
|
}
|
|
];
|
|
|
|
# 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 = {
|
|
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;
|
|
};
|
|
};
|
|
|
|
systemd.services.dovecot = {
|
|
preStart = ''
|
|
${genPasswdScript}
|
|
''
|
|
+ (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
|
|
reloadTriggers = lib.mkIf (!withACME) [
|
|
x509CertificateFile
|
|
x509PrivateKeyFile
|
|
];
|
|
};
|
|
|
|
systemd.services.postfix.restartTriggers = [
|
|
genPasswdScript
|
|
]
|
|
++ (lib.optional cfg.ldap.enable [ setPwdInLdapConfFile ]);
|
|
};
|
|
}
|