# 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 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 < ${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 < ${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; }; 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 ]); }; }