{ config, lib, pkgs, ... }: let cfg = config.sbruder.mailserver; certDir = config.security.acme.certs."${cfg.fqdn}".directory; in { options.sbruder.mailserver = with lib; with lib.types; { enable = mkEnableOption "simple mail server"; fqdn = mkOption { type = str; description = '' FQDN of the mail server It needs to have a matching reverse DNS record. Also, an acme certificate with this name has to be present. ''; example = "mail.example.com"; }; storage = mkOption { type = path; description = "Location of the storage for mails"; default = "/var/vmail"; }; domains = mkOption { type = listOf str; description = "Domains to serve"; example = [ "example.com" "example.org" ]; }; autoconfig = { enable = mkEnableOption "autoconfiguration of compatible clients. Requires autoconfig. to exist for all specified domains"; domain = mkOption { type = str; description = "Domain of the mail system."; example = "example.com"; }; displayName = mkOption { type = str; description = "Name of the mail system."; default = cfg.autoconfig.domain; }; displayShortName = mkOption { type = str; description = "Short name of the mail system."; default = cfg.autoconfig.displayName; }; }; users = mkOption { type = listOf (submodule { options = { address = mkOption { type = str; description = "Primary e-mail address of the user"; example = "jdoe@example.com"; }; passwordHash = mkOption { type = str; description = '' Bcrypt hash of the user’s password. Please note that it will be world-readable in the nix store. You can generate a password with `nix run nixpkgs.apacheHttpd -c htpasswd -nBC 12 "" | cut -d: -f2` ''; example = "$2y$05$SHxhwVGx.XCd19HAcb1NKuidUxW1BwU7GeO0ZIcMTc5t2uZoYLVRK"; }; aliases = mkOption { type = listOf str; description = '' A list of aliases for the user. If multiple users have the same alias defined, mail will be delivered to both of them. ''; default = [ ]; example = [ "j.doe@example.com" "jane.doe@example.com" "postmaster@example.com" ]; }; }; }); description = "Users of the mail server"; }; cleanHeaders = mkOption { type = listOf str; description = "A list of regular expressions that define what headers are filtered"; default = [ "/^\\s*Received:/" "/^\\s*User-Agent:/" "/^\\s*X-Mailer:/" "/^\\s*X-Originating-IP:/" ]; }; rejectSenders = mkOption { type = listOf str; description = "A list of senders to reject mails from"; default = [ ]; example = [ "newsletter@example.com" "spammer@example.com" ]; }; spam = { enable = (lib.mkEnableOption "spam filtering") // { default = true; }; }; dkim = { enable = (lib.mkEnableOption "DKIM signing") // { default = true; }; selector = lib.mkOption { type = str; description = "DKIM Selector to use"; default = "mail"; }; }; }; config = lib.mkIf cfg.enable { # Users and groups users.users.vmail = { uid = 10000; group = "vmail"; home = cfg.storage; createHome = true; }; users.groups.vmail.gid = 10000; # Firewall networking.firewall.allowedTCPPorts = [ 143 # IMAP 993 # IMAP (implicit TLS) 25 # SMTP 587 # SMTP submission 465 # SMTP submission (implicit TLS) ]; # Service dependencies systemd.services.dovecot2 = { wants = [ "acme-finished-${cfg.fqdn}.target" ]; after = [ "acme-finished-${cfg.fqdn}.target" ]; }; systemd.services.postfix = { wants = [ "acme-finished-${cfg.fqdn}.target" ]; requires = [ "dovecot2.service" ]; after = [ "acme-finished-${cfg.fqdn}.target" "dovecot2.service" ]; }; # Reload on certificate renewal security.acme.certs."${cfg.fqdn}".postRun = '' if systemctl is-active dovecot2; then systemctl --no-block reload dovecot2 fi if systemctl is-active postfix; then systemctl --no-block reload postfix fi ''; # Postfix security.dhparams.params.postfix = { }; services.postfix = let listToString = lib.concatStringsSep ","; valiases = let # List of attribute sets with single key-value pair plainAliases = (lib.flatten (map ({ address, aliases, ... }: map (alias: { "${alias}" = address; }) (aliases ++ lib.singleton address)) cfg.users)); # Attribute set with every alias mapped to a list of receivers mergedAliases = (lib.attrsets.foldAttrs (val: col: lib.singleton val ++ col) [ ] plainAliases); # Contents of the aliases file aliasesString = (lib.concatStringsSep "\n" (lib.mapAttrsToList (alias: addresses: "${alias} ${listToString addresses}") mergedAliases)); in pkgs.writeText "valiases" aliasesString; access_sender = pkgs.writeText "access_sender" (lib.concatMapStringsSep "\n" (sender: "${sender} REJECT") cfg.rejectSenders); submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" (lib.concatMapStringsSep "\n" (regex: "${regex} IGNORE") cfg.cleanHeaders); in { enable = true; enableSubmission = true; # plain/STARTTLS (latter is forced in submissionOptions) enableSubmissions = true; # submission with implicit TLS (TCP/465) hostname = cfg.fqdn; networksStyle = "host"; sslCert = "${certDir}/fullchain.pem"; sslKey = "${certDir}/key.pem"; recipientDelimiter = "+"; mapFiles = { inherit access_sender valiases; }; config = { # General smtpd_banner = "${cfg.fqdn} ESMTP NO UCE"; disable_vrfy_command = true; # disable check if mailbox exists enable_long_queue_ids = true; # better for debugging strict_rfc821_envelopes = true; # only accept properly formatted envelope message_size_limit = "50331648"; # 48 MiB virtual_mailbox_domains = listToString cfg.domains; virtual_mailbox_maps = "hash:/var/lib/postfix/conf/valiases"; virtual_alias_maps = "hash:/var/lib/postfix/conf/valiases"; virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; smtpd_recipient_restrictions = listToString [ "reject_non_fqdn_recipient" "reject_rbl_client ix.dnsbl.manitu.net" "reject_unknown_recipient_domain" "reject_unverified_recipient" ]; smtpd_client_restrictions = listToString [ "reject_rbl_client ix.dnsbl.manitu.net" "reject_unknown_client_hostname" ]; smtpd_sender_restrictions = listToString [ "check_sender_access hash:/var/lib/postfix/conf/access_sender" "reject_non_fqdn_sender" "reject_unknown_sender_domain" ]; # generated 2021-02-04, Mozilla Guideline v5.6, Postfix 3.5.6, OpenSSL 1.1.1i, intermediate configuration # https://ssl-config.mozilla.org/#server=postfix&version=3.5.6&config=intermediate&openssl=1.1.1i&guideline=5.6 smtpd_tls_security_level = "may"; smtpd_tls_auth_only = "yes"; smtpd_tls_mandatory_protocols = "!SSLv2, !SSLv3, !TLSv1, !TLSv1.1"; smtpd_tls_protocols = "!SSLv2, !SSLv3, !TLSv1, !TLSv1.1"; smtpd_tls_mandatory_ciphers = "medium"; smtpd_tls_loglevel = "1"; tls_medium_cipherlist = listToString [ "ECDHE-ECDSA-AES128-GCM-SHA256" "ECDHE-RSA-AES128-GCM-SHA256" "ECDHE-ECDSA-AES256-GCM-SHA384" "ECDHE-RSA-AES256-GCM-SHA384" "ECDHE-ECDSA-CHACHA20-POLY1305" "ECDHE-RSA-CHACHA20-POLY1305" "DHE-RSA-AES128-GCM-SHA256" "DHE-RSA-AES256-GCM-SHA384" ]; tls_preempt_cipherlist = "no"; smtpd_tls_dh1024_param_file = config.security.dhparams.params.postfix.path; smtpd_milters = lib.mkIf cfg.dkim.enable (lib.singleton "unix:/run/opendkim/opendkim.sock"); non_smtpd_milters = lib.mkIf cfg.dkim.enable (lib.singleton "unix:/run/opendkim/opendkim.sock"); }; # plain/STARTTLS (forced with smtpd_tls_security_level) submissionOptions = { smtpd_tls_security_level = "encrypt"; smtpd_sasl_auth_enable = "yes"; smtpd_sasl_type = "dovecot"; smtpd_sasl_path = "/run/dovecot2/auth"; smtpd_sender_login_maps = "hash:/etc/postfix/valiases"; smtpd_recipient_restrictions = listToString [ ]; smtpd_client_restrictions = listToString [ "permit_sasl_authenticated" "reject" ]; smtpd_sender_restrictions = listToString [ "reject_sender_login_mismatch" ]; cleanup_service_name = "submission-header-cleanup"; }; # implicit TLS submissionsOptions = config.services.postfix.submissionOptions; masterConfig = { submission-header-cleanup = { private = false; maxproc = 0; command = "cleanup"; args = [ "-o" "header_checks=pcre:${submissionHeaderCleanupRules}" ]; }; }; }; # Dovecot services.dovecot2 = let postfixCfg = config.services.postfix; passdb = pkgs.writeText "dovecot-users" (lib.concatMapStringsSep "\n" ({ address, passwordHash, ... }: "${address}:{BLF-CRYPT}${passwordHash}") cfg.users); in { enable = true; modules = with pkgs; [ dovecot_pigeonhole ]; enableLmtp = true; enablePAM = false; mailUser = "vmail"; mailGroup = "vmail"; mailLocation = "maildir:${cfg.storage}/%d/%n"; sslServerCert = "${certDir}/fullchain.pem"; sslServerKey = "${certDir}/key.pem"; mailboxes = { Archive = { specialUse = "Archive"; auto = "subscribe"; }; Sent = { specialUse = "Sent"; auto = "subscribe"; }; Drafts = { specialUse = "Drafts"; auto = "subscribe"; }; Trash = { specialUse = "Trash"; auto = "subscribe"; }; Spam = { specialUse = "Junk"; auto = "subscribe"; }; }; sieveScripts = { before = pkgs.writeText "spam.sieve" '' require "fileinto"; if header :is "X-Spam" "Yes" { fileinto "Spam"; } ''; }; extraConfig = '' # generated 2021-02-04, Mozilla Guideline v5.6, Dovecot 2.3.13, OpenSSL 1.1.1i, intermediate configuration # https://ssl-config.mozilla.org/#server=dovecot&version=2.3.13&config=intermediate&openssl=1.1.1i&guideline=5.6 ssl = required ssl_min_protocol = TLSv1.2 ssl_cipher_list = ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 ssl_prefer_server_ciphers = no protocol imap { mail_plugins = $mail_plugins imap_sieve } protocol lmtp { mail_plugins = $mail_plugins sieve } service imap-login { inet_listener imap { } } service lmtp { unix_listener dovecot-lmtp { mode = 0600 user = ${postfixCfg.user} group = ${postfixCfg.group} } } passdb { driver = passwd-file args = username_format=%u ${passdb} } userdb { driver = static args = uid=vmail gid=vmail home=${cfg.storage}/%d/%n } service auth { unix_listener auth { mode = 0660 user = ${postfixCfg.user} group = ${postfixCfg.group} } } lda_mailbox_autosubscribe = yes lda_mailbox_autocreate = yes plugin { sieve_plugins = sieve_imapsieve sieve_extprograms ${lib.optionalString cfg.spam.enable '' imapsieve_mailbox1_name = Spam imapsieve_mailbox1_causes = COPY imapsieve_mailbox1_before = file:/var/lib/dovecot/sieve/learn-spam.sieve imapsieve_mailbox2_name = * imapsieve_mailbox2_from = Spam imapsieve_mailbox2_causes = COPY imapsieve_mailbox2_before = file:/var/lib/dovecot/sieve/learn-ham.sieve sieve_pipe_bin_dir = ${pkgs.symlinkJoin { name = "sieve-pipe-bin-dir"; paths = with pkgs; [ rspamd ]; } }/bin ''} sieve_global_extensions = +vnd.dovecot.pipe } ''; }; systemd.services.dovecot2.preStart = lib.mkIf cfg.spam.enable (lib.mkAfter (lib.concatStrings (lib.mapAttrsToList (name: content: '' cp ${pkgs.writeText name content} /var/lib/dovecot/sieve/${name} '') { "learn-spam.sieve" = '' require ["vnd.dovecot.pipe", "copy", "imapsieve"]; pipe :copy "rspamc" ["learn_spam"]; ''; "learn-ham.sieve" = '' require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; if environment :matches "imap.mailbox" "*" { set "mailbox" "''${1}"; } if string "''${mailbox}" "Trash" { stop; } pipe :copy "rspamc" ["learn_ham"]; ''; }))); # DNS (recursor for DNSBLs etc.) services.resolved.enable = false; services.unbound = { enable = true; settings = { server = { interface = [ "127.0.0.53" ]; }; }; resolveLocalQueries = false; # does not work with .53 }; networking.resolvconf.extraConfig = '' name_servers='127.0.0.53' ''; # rspamd sops.secrets.rspamd-worker-controller = lib.mkIf cfg.spam.enable { owner = config.users.users.rspamd.name; sopsFile = ../machines + "/${config.networking.hostName}/secrets.yaml"; }; services.rspamd = { enable = cfg.spam.enable; postfix.enable = true; workers = { normal = { includes = [ "$CONFDIR/worker-normal.inc" ]; bindSockets = lib.singleton { socket = "/run/rspamd/rspamd.sock"; mode = "0660"; owner = "${config.services.rspamd.user}"; group = "${config.services.rspamd.group}"; }; }; controller = { includes = [ "$CONFDIR/worker-controller.inc" ]; bindSockets = [ "127.0.0.1:11334" ] ++ lib.optional config.sbruder.wireguard.home.enable "${config.sbruder.wireguard.home.address}:11334"; }; }; locals = { "dkim_signing.conf".text = '' enabled = false; ''; "logging.inc".text = '' # starts at info, drops to notice once started up level = "silent"; ''; "milter_headers.conf".text = '' extended_spam_headers = true; ''; "multimap.conf".text = '' SENDER_DOMAIN_BLOCKED { type = "from"; filter = "email:domain:tld"; map = "/var/lib/rspamd/blocked_sender_domains.map"; symbol = "SENDER_DOMAIN_BLOCKED"; description = "Sender’s effective second level domain is manually blocked"; score = 8.0; } ''; "redis.conf".text = '' servers = "127.0.0.1:${toString config.services.redis.servers.rspamd.port}" ''; "worker-controller.inc".source = config.sops.secrets.rspamd-worker-controller.path; # includes password }; }; services.redis = lib.mkIf cfg.spam.enable { vmOverCommit = true; servers.rspamd = { enable = true; port = 6379; }; }; # DKIM services.opendkim = lib.mkIf cfg.dkim.enable { enable = true; selector = cfg.dkim.selector; domains = "csl:${lib.concatStringsSep "," cfg.domains}"; configFile = pkgs.writeText "opendkim.conf" '' UMask 0002 ''; }; systemd.services.opendkim = lib.mkIf cfg.dkim.enable { # changed to use larger key size preStart = let inherit (config.services.opendkim) keyPath selector; in lib.mkForce '' cd "${keyPath}" if ! test -f ${selector}.private; then ${pkgs.opendkim}/bin/opendkim-genkey \ -s ${selector} \ -d all-domains-generic-key \ -b 4096 echo "Generated OpenDKIM key! Please update your DNS settings:\n" echo "-------------------------------------------------------------" cat ${selector}.txt echo "-------------------------------------------------------------" fi ''; }; users.users.postfix.extraGroups = lib.mkIf cfg.dkim.enable (lib.singleton config.users.users.opendkim.group); # Autoconfig services.nginx = lib.mkIf cfg.autoconfig.enable { enable = true; virtualHosts = lib.listToAttrs (map (domain: lib.nameValuePair "autoconfig.${domain}" { enableACME = true; forceSSL = true; locations."=/mail/config-v1.1.xml".alias = pkgs.writeText "config-v1.1.xml" '' ${lib.escapeXML cfg.autoconfig.domain} ${lib.escapeXML cfg.autoconfig.displayName} ${lib.escapeXML cfg.autoconfig.displayShortName} ${lib.escapeXML cfg.fqdn} 993 SSL password-cleartext %EMAILADDRESS% ${lib.escapeXML cfg.fqdn} 465 SSL password-cleartext %EMAILADDRESS% ''; }) cfg.domains); }; }; }