520 lines
16 KiB
Nix
520 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)";
|
|
}
|
|
(
|
|
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" ];
|
|
};
|
|
};
|
|
|
|
# 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;
|
|
};
|
|
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
|
|
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 = 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;
|
|
};
|
|
};
|
|
|
|
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 ]);
|
|
};
|
|
}
|