# Email server configuration # Copyright (C) 2023-2024 Nguyễn Gia Phong # # This file is part of loang configuration. # # Loang configuration is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Loang configuration 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 Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with loang configuration. If not, see . { config, lib, pkgs, ... }: let certDir = config.security.acme.certs.${hostname}.directory; domain = config.networking.domain; hostname = "tem.${domain}"; publicHost = "chung.${domain}"; in { networking.firewall.allowedTCPPorts = [ 25 # SMTP-MTA 465 # SMTP-MSA 993 # IMAPS ]; services = { alps = { enable = true; imaps.host = hostname; theme = "alps"; }; maddy = { config = '' auth_map email_localpart auth.shadow local_authdb { use_helper no } log syslog /var/log/maddy.log storage.imapsql local_mailboxes { driver sqlite3 dsn imapsql.db } table.chain local_rewrites { optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3" optional_step static { entry postmaster cnx@$(primary_domain) } optional_step file /etc/maddy/aliases } msgpipeline local_routing { destination ${publicHost} { deliver_to smtp tcp://localhost:2525 } destination postmaster $(local_domains) { modify { replace_rcpt &local_rewrites } deliver_to &local_mailboxes } default_destination { reject 550 5.1.1 "User doesn't exist" } } smtp tcp://0.0.0.0:25 { limits { all rate 20 1s all concurrency 10 } dmarc yes check { require_mx_record dkim spf } source $(local_domains) { reject 501 5.1.8 "Use Submission for outgoing SMTP" } default_source { destination postmaster $(local_domains) { deliver_to &local_routing } default_destination { reject 550 5.1.1 "User doesn't exist" } } } submission tls://0.0.0.0:465 { limits { all rate 50 1s } auth &local_authdb source $(local_domains) { check { authorize_sender { prepare_email &local_rewrites user_to_email identity } } destination postmaster $(local_domains) { deliver_to &local_routing } default_destination { modify { dkim $(primary_domain) $(local_domains) default } deliver_to &remote_queue } } default_source { reject 501 5.1.8 "Non-local sender domain" } } target.remote outbound_delivery { limits { destination rate 20 1s destination concurrency 10 } mx_auth { dane mtasts { cache fs fs_dir mtasts_cache/ } local_policy { min_tls_level encrypted min_mx_level none } } } target.queue remote_queue { target &outbound_delivery autogenerated_msg_domain $(primary_domain) bounce { destination postmaster $(local_domains) { deliver_to &local_routing } default_destination { reject 550 5.0.0 "Refusing to send DSNs to non-local addresses" } } } imap tls://0.0.0.0:993 { auth &local_authdb storage &local_mailboxes } ''; enable = true; hostname = hostname; primaryDomain = domain; localDomains = [ domain publicHost ]; tls = { loader = "file"; certificates = [{ certPath = "${certDir}/cert.pem"; keyPath = "${certDir}/key.pem"; }]; }; }; postfix = { enable = true; # bridge between maddy and public-inbox enableSmtp = false; # override default port 25 extraMasterConf = '' 2525 inet n - - - - smtpd ''; }; public-inbox = { enable = true; http = { enable = true; port = 1430; }; inboxes = { test = { address = [ "test@${publicHost}" ]; description = "test list"; url = "https://${publicHost}/test"; }; }; mda.enable = true; postfix.enable = true; settings.publicinbox.wwwlisting = "match=domain"; }; nginx.virtualHosts = { "mta-sts.${domain}" = { enableACME = true; forceSSL = true; locations."/".root = pkgs.writeTextDir ".well-known/mta-sts.txt" '' version: STSv1 mode: enforce max_age: 604800 mx: ${hostname} ''; }; ${domain}.locations."^~ /.well-known/openpgpkey" = { root = with pkgs; stdenvNoCC.mkDerivation { pname = "wkd"; version = domain; src = ./wkd; nativeBuildInputs = [ gnupg ]; installPhase = let printWKDHash = "${gnupg}/libexec/gpg-wks-client --print-wkd-hash"; in '' hu=$out/.well-known/openpgpkey/hu mkdir -p $hu for key in *.asc do mb="''${key%.asc}@${domain}" hash=$(echo "$mb" | ${printWKDHash}) gpg --dearmor < "$key" > $hu/''${hash%" $mb"} done touch $out/.well-known/openpgpkey/policy ''; }; extraConfig = '' add_header Access-Control-Allow-Origin *; ''; }; "${publicHost}" = { enableACME = true; forceSSL = true; locations."/".proxyPass = let port = config.services.public-inbox.http.port; in "http://localhost:${toString port}"; }; ${hostname} = let alps = config.services.alps; in { enableACME = true; forceSSL = true; locations."/".proxyPass = "http://${alps.bindIP}:${toString alps.port}"; }; }; }; systemd.services.alps.unitConfig.Requires = "maddy.service"; users.extraUsers.maddy.extraGroups = [ "nginx" "shadow" ]; }