/*
* Building: We patch our version of stalwart and thus need to built it locally.
*   Be aware that this needs some hours, about 12Gb RAM and a few Gb free space in /tmp.
*   If you only want to deploy configuration changes and no software updates, consider building on the target VM.
*   It has stalwart in its nix store and does not need to rebuild it.
* Forwarding mails: Update the Sops-secrets in the machine directory, rebuild on the VM and deploy.
*   Everything else should happen automatically but new redirects might take up to two hours due HRZ infrastructure.
* Using the web admin interface: Set your SSH to do portforwarding of some local port to port 80 of the VM and
*   and use your personal admin account or create one using the fallback admin password.
* Create users with mail boxes: Go to the admin interface and create them.
* Stalwart mailserver docs can be found at https://stalw.art/docs
* DNS-Records: Collect the right DNS entries from the management interface and copy them to the DNS hoster. Caution:
*   Not all entries are applicable since we relay via HRZ.
*/
{
  config,
  lib,
  pkgs,
  ...
}: let
  inherit
    (lib)
    mkIf
    mkEnableOption
    mkOption
    ;
  inherit (lib.types) listOf strMatching nonEmptyStr path;
  cfg = config.services.mathebau-mail;
in {
  options.services.mathebau-mail = {
    enable = mkEnableOption "mathebau mail service";
    stalwartAdmin = mkOption {
      type = path;
      description = "Path to a file that contains the stalwart fallback admin password encoded for HTTP Basic Auth. Update together with the stalwartAdminHash and the pass store.";
    };
    stalwartAdminHash = mkOption {
      type = nonEmptyStr;
      description = "String containing the hashed fallback admin password. Update together with the stalwartAdmin setting and the pass store.";
    };
    domains = mkOption {
      type = listOf (lib.types.submodule {
        options = {
          domain = mkOption {
            description = "Domain name that we serve. We also push its addresses to HRZ.";
            type = strMatching "^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$"; #Regex from https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch08s15.html
          };
          allowlistPass = mkOption {
            description = "Password file for the HRZ API that gets a list of mailaddresses that we serve";
            type = path;
          };
          virt_aliases = mkOption {
            description = "File path to a virtual alias file applicable for this domain";
            type = path;
            default = "/dev/null"; # there might not be an alias file and reading an empty one works with our implementation
          };
        };
      });
    };
  };

  config = mkIf cfg.enable {
    services = {
      stalwart-mail = {
        enable = true;
        openFirewall = true;
        settings = {
          server = {
            hostname = "fb04184.mathematik.tu-darmstadt.de"; # Because the DNS PTR of 130.83.2.184 is this and this should be used in SMTP EHLO.
            listener = {
              "smtp" = {
                bind = ["[::]:25"];
                protocol = "smtp";
              };
              "submissions" = {
                bind = ["[::]:465"];
                protocol = "smtp";
                tls.implicit = true;
              };
              "imaptls" = {
                bind = ["[::]:993"];
                protocol = "imap";
                tls.implicit = true;
              };
              "management" = {
                # Cthulhu forwards requests for http://fb04184.mathematik.tu-darmstadt.de/.well-known/acme-challenge/ http://imap.mathebau.de/.well-known/acme-challenge/ and http://smtp.mathebau.de/.well-known/acme-challenge/ and http://mathebau.de/.well-known/acme-challenge/
                # for TLS certificate challenge validation
                # whereas the rest of the management interface is not available publically.
                # It can be reached via SSH and portforwarding.
                bind = ["[::]:80"];
                protocol = "http";
              };
            };
          };
          acme.letsencrypt = {
            directory = "https://acme-v02.api.letsencrypt.org/directory"; # This setting is necessary for this block to be activated
            challenge = "http-01";
            contact = ["root@mathebau.de"];
            domains = ["fb04184.mathematik.tu-darmstadt.de" "imap.mathebau.de" "smtp.mathebau.de" "mathebau.de"];
            default = true;
          };
          # Reevaluate after DKIM and DMARC deployment
          spam.header.is-spam = "Dummyheader"; # disable moving to spam which would conflict with forwarding
          auth = {
            # TODO check if HRZ conforms to these standards and we can validate them strictly
            dkim.verify = "relaxed";
            arc.verify = "relaxed";
            dmarc.verify = "relaxed";
            iprev.verify = "relaxed";
            spf.verify.ehlo = "relaxed";
            spf.verify.mail-from = "relaxed";

            # Sign *our* outgoing mails with the configured signatures.
            dkim.sign = [
              {
                "if" = "is_local_domain('', sender_domain) || sender_domain == 'lists.mathebau.de'";
                "then" = "['rsa-' + sender_domain, 'ed25519-' + sender_domain]";
              }
              {"else" = false;}
            ];
          };

          # Forward outgoing mail to HRZ or mail VMs.
          # see https://stalw.art/docs/smtp/outbound/routing/ relay host example
          queue.outbound = {
            next-hop = [
              {
                "if" = "rcpt_domain = 'lists.mathebau.de'";
                "then" = "'mailman'";
              }
              {
                "if" = "is_local_domain('', rcpt_domain)";
                "then" = "'local'";
              }
              {"else" = "'hrz'";}
            ];
            tls = {
              # we only talk to HRZ and our own VMs anyway
              mta-sts = "disable";
              dane = "disable";
              starttls = "optional"; # e.g. Lobon does not offer starttls
            };
          };
          remote."hrz" = {
            address = "mailout.hrz.tu-darmstadt.de";
            port = 25;
            protocol = "smtp";
            tls.implicit = false; # Don't assume TLS on this port but use STARTTLS
          };
          remote."mailman" = {
            address = "lobon.mathebau.de"; # must be created in DNS as a MX record because this field does not accept ip addresses.
            port = 25;
            protocol = "smtp";
            tls.implicit = false; # Don't assume TLS on this port but use STARTTLS
          };

          session.rcpt = {
            # In order to accept mail that we only forward
            # without having to generate an account.
            # Invalid addresses are filtered by DFN beforehand.
            # See also https://stalw.art/docs/smtp/inbound/rcpt/#catch-all-addresses
            catch-all = true;
            relay = [
              {
                "if" = "!is_empty(authenticated_as) || rcpt_domain == 'lists.mathebau.de' || starts_with(remote_ip, '192.168.0.')"; #TODO restrict trust by IP
                "then" = true;
              }
              {"else" = false;}
            ];
          };

          session.ehlo.require = [
            {
              "if" = "starts_with(remote_ip, '192.168.0.')"; #TODO setup vms properly
              "then" = false;
            }
            {"else" = true;}
          ];
          session.ehlo.reject-non-fqdn = [
            {
              "if" = "starts_with(remote_ip, '192.168.0.')"; #TODO setup vms properly
              "then" = false;
            }
            {"else" = true;}
          ];

          # Stalwart gets its configuration from two places: A TOML configuration file that we control in this module
          # and from a database that can be configured from web management interface or via Rest API.
          # We here define what comes from the TOML-file and especially add "sieve.trusted.scripts.*" to the default ones
          # because only TOML-based keys may use macros to load files from disk.
          # We want this to be able to load our sieve-script for mail forwarding.
          # See https://stalw.art/docs/configuration/overview/#local-and-database-settings for more details.
          config.local-keys =
            [
              "store.*"
              "directory.*"
              "tracer.*"
              "server.*"
              "!server.blocked-ip.*"
              "authentication.fallback-admin.*"
              "cluster.node-id"
              "storage.data"
              "storage.blob"
              "storage.lookup"
              "storage.fts"
              "storage.directory"
              "lookup.default.hostname"
              "certificate.*"
            ] # the default ones
            ++ ["sieve.trusted.*"]; #for macros to be able to include our redirection script
          sieve.trusted.scripts.redirects.contents = "%{file:/tmp/virt_aliases}%"; # generated redirect script
          sieve.trusted.from-addr = "sender"; # set the from-address to the original sender as specified in the MAIL FROM.
          sieve.trusted.from-name = "sender";
          sieve.trusted.return-path = "sender";
          # If we are the sender, we sign the message with DKIM. Else we leave it alone.
          sieve.trusted.sign = [
            {
              "if" = "is_local_domain('', sender_domain) || sender_domain == 'lists.mathebau.de'";
              "then" = "['rsa-' + sender_domain, 'ed25519-' + sender_domain]";
            }
            {"else" = false;}
          ];
          sieve.trusted.limits = {
            redirects = 50;
            out-messages = 50;
          };
          session.data.script = "'redirects'";

          # See https://stalw.art/docs/smtp/authentication/dkim/sign
          # We need two blocks per domain because the domain setting in the blocks does not accept variables like `sender_domain`.
          signature = let
            signatureTemplate = domain: {
              "rsa-${domain}" = {
                private-key = "%{file:/run/secrets/dkim_rsa}%";
                domain = "${domain}";
                selector = "rsa-default";
                headers = ["From" "To" "Cc" "Date" "Subject" "Message-ID" "Organization" "MIME-Version" "Content-Type" "In-Reply-To" "References" "List-Id" "User-Agent" "Thread-Topic" "Thread-Index"];
                algorithm = "rsa-sha256";
                canonicalization = "relaxed/relaxed";
              };
              "ed25519-${domain}" = {
                private-key = "%{file:/run/secrets/dkim_ed25519}%";
                domain = "${domain}";
                selector = "ed-default";
                headers = ["From" "To" "Cc" "Date" "Subject" "Message-ID" "Organization" "MIME-Version" "Content-Type" "In-Reply-To" "References" "List-Id" "User-Agent" "Thread-Topic" "Thread-Index"];
                algorithm = "ed25519-sha256";
                canonicalization = "relaxed/relaxed";
              };
            };
          in
            map signatureTemplate (["lists.mathebau.de"] ++ (map ({domain, ...}: domain) cfg.domains));

          # Sign *our* outgoing mails with the configured signatures.
          auth.dkim.sign = [
            {
              "if" = "is_local_domain('', sender_domain) || sender_domain == 'lists.mathebau.de'";
              "then" = "['rsa-' + sender_domain, 'ed25519-' + sender_domain]";
            }
            {"else" = false;}
          ];

          authentication.fallback-admin = {
            user = "admin";
            # see passwd on azathoth for plaintext or machine secret in encoded format for HTTP Basic AUTH
            secret = cfg.stalwartAdminHash;
          };
          store = {
            # structured data in SQLite, blobs on filesystem
            db.type = "sqlite";
            db.path = "/var/lib/stalwart-mail/data/index.sqlite3";
            fs.type = "fs";
            fs.path = "/var/lib/stalwart-mail/data/blobs";
          };
        };
      };
    };
    environment.persistence.${config.impermanence.name} = {
      directories = [
        "/var/lib/stalwart-mail"
      ];
      files = ["/root/.ssh/known_hosts"]; # for the backup server bragi
    };

    systemd = {
      timers."mailAllowlist" = {
        wantedBy = ["timers.target"];
        timerConfig = {
          OnBootSec = "1h"; # Run every hour
          OnUnitActiveSec = "1h";
          RandomizedDelaySec = "10m"; # prevent overload on regular intervals
          Unit = "mailAllowlist.service";
        };
      };
      services = {
        "mailAllowlist" = {
          description = "Allowlist update: Post the mail addresses to the HRZ allowllist";
          script = let
            scriptTemplate = {
              domain,
              allowlistPass,
              ...
            }: ''
              echo "process ${domain}"
              # This line gets the available mailboxes from stalwart's Rest API, searches for their addresses and collects them to a file for submission.
              # The regex searches for alphanumerics combined with some special characters as local paths and the right domain.
              # Exclude @domain.tld which is not a valid mail address but used for catch-all accounts.
              ${pkgs.curl}/bin/curl -s --header "authorization: Basic $(<${cfg.stalwartAdmin})" http://localhost/api/principal | ${pkgs.gnugrep}/bin/grep -o -e "[A-Za-z0-9.!#\$%&'*+-/=?^_{|}~]*@${domain}" | grep -v "@${domain}" | tee /tmp/addresses
              # This line searches for available redirects and adds them to the submission file.
              ${pkgs.gnugrep}/bin/grep -o -e "[A-Za-z0-9.!#\$%&'*+-/=?^_{|}~]*@${domain}" /tmp/virt_aliases >> /tmp/addresses # This doesn't catch all RFC conform local parts. Improve if you need.
              # Post local-parts to HRZ, see https://www-cgi.hrz.tu-darmstadt.de/mail/index.php?bereich=whitelist_upload
              ${pkgs.curl}/bin/curl -s https://www-cgi.hrz.tu-darmstadt.de/mail/whitelist-update.php -F emaildomain=${domain} -F password=$(cat ${allowlistPass}) -F emailliste=@/tmp/addresses -F meldungen=voll
              # Cleanup submission file
              rm /tmp/addresses
            '';
          in
            lib.strings.concatStringsSep "" (map scriptTemplate cfg.domains);
          serviceConfig = {
            Type = "oneshot";
            User = "stalwart-mail";
            NoNewPrivileges = true;
            # See https://www.man7.org/linux/man-pages/man5/systemd.exec.5.html
            PrivateTmp = false; # allow access to sieve script
            ProtectHome = true;
            ReadOnlyPaths = "/";
            ReadWritePaths = "/tmp";
            InaccessiblePaths = "-/lost+found";
            PrivateDevices = true;
            PrivateUsers = true;
            ProtectHostname = true;
            ProtectClock = true;
            ProtectKernelTunables = true;
            ProtectKernelModules = true;
            ProtectKernelLogs = true;
            ProtectControlGroups = true;
            LockPersonality = true;
            MemoryDenyWriteExecute = true;
            RestrictRealtime = true;
            RestrictSUIDSGID = true;
          };
        };
        # This service is defined by the nixpkgs stalwart module and we only modify it.
        "stalwart-mail" = {
          restartTriggers = lib.attrsets.mapAttrsToList (_: aliaslist: aliaslist.sopsFile) config.sops.secrets; # restart if secrets, especially alias files, have changed.
          serviceConfig.PrivateTmp = lib.mkForce false; # enable access to generated Sieve script
          serviceConfig.ProtectSystem = lib.mkForce "full"; # "strict" does not allow writing to /tmp which we need for unpacking the webadmin interface. "full" is less strict.
        };
        "virt-aliases-generator" = {
          description = "Virtual Aliases Generator: Generate a sieve script from the virtual alias file";
          script = lib.strings.concatStringsSep "" (["${pkgs.alias-to-sieve}/bin/alias_to_sieve "] ++ map (x: "${x.virt_aliases} ${x.domain} ") cfg.domains ++ ["> /tmp/virt_aliases"]);
          wantedBy = ["stalwart-mail.service"]; # Rerun on stalwart restart because forwardings may have changed.
          serviceConfig = {
            Type = "oneshot";
            User = "stalwart-mail";
            NoNewPrivileges = true;
            # See https://www.man7.org/linux/man-pages/man5/systemd.exec.5.html
            PrivateTmp = false;
            ProtectHome = true;
            ReadOnlyPaths = "/";
            ReadWritePaths = "/tmp";
            InaccessiblePaths = "-/lost+found";
            PrivateDevices = true;
            PrivateUsers = true;
            ProtectHostname = true;
            ProtectClock = true;
            ProtectKernelTunables = true;
            ProtectKernelModules = true;
            ProtectKernelLogs = true;
            ProtectControlGroups = true;
            LockPersonality = true;
            MemoryDenyWriteExecute = true;
            RestrictRealtime = true;
            RestrictSUIDSGID = true;
          };
        };
      };
    };
    # Backups
    services.borgbackup.jobs.mail = {
      paths = [
        "/var/lib/stalwart-mail/data"
      ];
      encryption.mode = "none"; # Otherwise the key is next to the backup or we have human interaction.
      environment = {
        BORG_RSH = "ssh -i /run/secrets/backupKey";
        # “Borg ensures that backups are not created on random drives that ‘just happen’ to contain a Borg repository.”
        # https://borgbackup.readthedocs.io/en/stable/deployment/automated-local.html
        # We don't want this in order to not need to persist borg cache and simplify new deployments.
        BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK = "yes";
      };
      repo = "borg@192.168.1.11:nyarlathotep"; # TODO for https://gitea.mathebau.de/Fachschaft/nixConfig/issues/33
      startAt = "daily";
      user = "root";
      group = "root";
    };
  };
}