# 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 alps = config.services.alps; domain = config.networking.domain; hostname = "tem.${domain}"; publicHost = "loa.${domain}"; public-inbox = config.services.public-inbox; in { networking.firewall.allowedTCPPorts = [ 25 # SMTP-MTA alps.smtps.port alps.imaps.port public-inbox.imap.port ]; 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/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:${toString alps.smtps.port} { 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:${toString alps.imaps.port} { auth &local_authdb storage &local_mailboxes } ''; enable = true; hostname = hostname; primaryDomain = domain; localDomains = [ domain publicHost ]; tls = { loader = "file"; certificates = let certDir = config.security.acme.certs.${hostname}.directory; in [ { 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; }; imap = let certDir = config.security.acme.certs.${publicHost}.directory; in { cert = "${certDir}/cert.pem"; enable = true; key = "${certDir}/key.pem"; port = 143; }; inboxes = builtins.mapAttrs (name: value: value // { address = [ "${name}@${publicHost}" ]; url = "https://${publicHost}/${name}"; newsgroup = "inbox.${name}"; }) { test.description = "test list"; chung = { description = "News, requests and patches for loang.net"; coderepo = [ "nixos-conf" "phylactery" "site" ]; }; }; mda.enable = true; postfix.enable = true; settings = { coderepo = builtins.listToAttrs (map (name: { name = name; value = { cgitUrl = "https://trong.loang.net/${name}"; dir = name; }; }) [ "nixos-conf" "phylactery" "site" ]); publicinbox = { imapserver = [ publicHost ]; 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 = public-inbox.http.port; in "http://localhost:${toString port}"; }; ${hostname} = { enableACME = true; forceSSL = true; locations."/".proxyPass = "http://${alps.bindIP}:${toString alps.port}"; }; }; }; systemd.services = { alps.unitConfig.Requires = "maddy.service"; public-inbox-imapd.serviceConfig.BindReadOnlyPaths = [ config.security.acme.certs.${publicHost}.directory ]; }; users.users = { maddy.extraGroups = [ config.security.acme.certs.${hostname}.group "shadow" ]; public-inbox.extraGroups = [ config.security.acme.certs.${publicHost}.group ]; }; }