# 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 { 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 < ${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 < ${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 = "