about summary refs log blame commit diff stats
path: root/modules/by-name/st/stalwart-mail/settings.nix
blob: 7032ae0c30cceda408647b264225a6fefd9cad63 (plain) (tree)
1
2
3
4
5
6
7





                                   






                                              



                                      
                                                                









                                                                                                                                                                                                  
 





















                                                                                                      
               
                                                           

                  
                                                           
 
                                                                

               
                                                           
             
                                                                  
                                                           
              
                                                           



                                                                              
                                                             












                                                    
                                        
                                     
                                                               




                                                       
                                          
                                      
                                                                 



                                                          
                                                                  




                                                        
                                                                  




                                                         
                                                                  


                                                     
                                                                  








                                                  
                    
                                                
                            
                                         
                   
                                                
                            



































































































































































                                                                                    





                                     

























                                                      
 
              
                         


                              







                                            































































































































































                                                                                             
{
  config,
  lib,
  pkgs,
  ...
}: let
  cfg = config.vhack.stalwart-mail;

  signaturesByDomain =
    (builtins.map ({name, ...}: {
        "if" = "sender_domain = '${name}'";
        "then" = "'${name}'";
      })
      (lib.attrsToList cfg.security.dkimKeys))
    ++ [{"else" = false;}];

  maybeVerificationMode =
    if cfg.security != null
    then cfg.security.verificationMode
    else "disable";
in {
  config.services.stalwart-mail.settings = lib.mkIf cfg.enable {
    # https://www.rfc-editor.org/rfc/rfc6376.html#section-3.3
    signature = let
      signatures =
        lib.mapAttrs (keyDomain: keyConfig: {
          private-key = "%{file:${config.age.secrets."stalwartMail${keyDomain}".path}}%";

          domain = keyDomain;

          selector = "mail";
          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 = keyConfig.keyAlgorithm;

          # How do we canonicalize the headers/body?
          # https://www.rfc-editor.org/rfc/rfc6376.html#section-3.4
          canonicalization = "simple/simple";

          expire = "50d";
          report = true;
        })
        cfg.security.dkimKeys;
    in
      lib.mkIf (cfg.security != null) signatures;

    auth = let
      # NOTE(@bpeetz): We disable all the checks if the `listener` is submissions, because the
      # user's email client will obviously not have the right IP address to pass SPF or
      # IPREV. It will also not be able to sign the message with DKIM (as we has to key). <2025-02-25>
      ifNotSmpt = valueTrue: valueFalse: [
        {
          "if" = "listener != 'submissions'";
          "then" = valueTrue;
        }
        {"else" = valueFalse;}
      ];
    in {
      iprev = {
        verify = ifNotSmpt maybeVerificationMode "disable";
      };
      spf = {
        verify = {
          ehlo = ifNotSmpt maybeVerificationMode "disable";

          mail-from = ifNotSmpt maybeVerificationMode "disable";
        };
      };
      dmarc = {
        verify = ifNotSmpt maybeVerificationMode "disable";
      };
      arc = {
        seal = lib.mkIf (cfg.security != null) signaturesByDomain;
        verify = ifNotSmpt maybeVerificationMode "disable";
      };
      dkim = {
        verify = ifNotSmpt maybeVerificationMode "disable";

        # Ignore insecure dkim signed messages (i.e., messages containing both
        # signed and appended not-signed content.)
        strict = true;

        sign =
          lib.mkIf (cfg.security != null) signaturesByDomain;
      };
    };
    report = {
      domain = "${cfg.fqdn}";
      submitter = "'${cfg.fqdn}'";
      analysis = {
        addresses = ["dmarc@*" "abuse@*"];
        forward = true;
        store = "30d";
      };
      tls.aggregate = {
        from-name = "'TLS Report'";
        from-address = "'noreply-tls@${cfg.fqdn}'";
        org-name = "'Foss Syndicate Mail Handling'";
        contact-info = "'${cfg.admin}'";
        send = "daily";
        max-size = 26214400; # 25 MiB
        sign = lib.mkIf (cfg.security != null) "'${cfg.fqdn}'";
      };
      dmarc = {
        aggregate = {
          from-name = "'DMARC Report'";
          from-address = "'noreply-dmarc@${cfg.fqdn}'";
          org-name = "'Foss Syndicate Mail Handling'";
          contact-info = "'${cfg.admin}'";
          send = "weekly";
          max-size = 26214400; # 25MiB
          sign = lib.mkIf (cfg.security != null) "'${cfg.fqdn}'";
        };
        from-name = "'Report Subsystem'";
        from-address = "'noreply-dmarc@${cfg.fqdn}'";
        subject = "'DMARC Authentication Failure Report'";
        send = "1/1d";
        sign = lib.mkIf (cfg.security != null) signaturesByDomain;
      };
      spf = {
        from-name = "'Report Subsystem'";
        from-address = "'noreply-spf@${cfg.fqdn}'";
        subject = "'SPF Authentication Failure Report'";
        send = "1/1d";
        sign = lib.mkIf (cfg.security != null) signaturesByDomain;
      };
      dkim = {
        from-name = "'Report Subsystem'";
        from-address = "'noreply-dkim@${cfg.fqdn}'";
        subject = "'DKIM Authentication Failure Report'";
        send = "1/1d";
        sign = lib.mkIf (cfg.security != null) signaturesByDomain;
      };
      dsn = {
        from-name = "'Mail Delivery Subsystem'";
        from-address = "'MAILER-DAEMON@${cfg.fqdn}'";
        sign = lib.mkIf (cfg.security != null) signaturesByDomain;
      };
    };
    queue = {
      schedule = {
        retry = "[2m, 5m, 10m, 15m, 30m, 1h, 2h]";
        notify = "[2h, 7h, 1d, 3d]";
        expire = "5d";
      };
      outbound = {
        tls = {
          starttls =
            if maybeVerificationMode == "strict"
            then "require"
            else "optional";
          allow-invalid-certs = false;
          ip-strategy = "ipv6_then_ipv4";
          mta-sts =
            if maybeVerificationMode == "strict"
            then "require"
            else "optional";
        };
      };
    };
    resolver = {
      type = "system";
      preserve-intermediates = true;
      concurrency = 2;
      timeout = "5s";
      attempts = 2;
      try-tcp-on-error = true;
      public-suffix = [
        "file://${pkgs.publicsuffix-list}/share/publicsuffix/public_suffix_list.dat"
      ];
    };

    spam-filter = {
      enable = true;
      header = {
        status = {
          enable = true;
          name = "X-Spam-Status";
        };
        result = {
          enable = true;
          name = "X-Spam-Result";
        };
      };
      bayes = {
        enable = true;

        # Learn from users putting mail into JUNK or taking mail out of it.
        account = {
          enable = true;
        };
      };

      # Fetch the newest spam-filter rules not from github, but from the nix
      # package.
      resource = "file://${cfg.package.passthru.spamfilter}/spam-filter.toml";
      auto-update = false;
    };

    webadmin = {
      # Fetch the newest webadmin bundle not from github, but from the nix
      # package.
      resource = "file://${cfg.package.passthru.webadmin}/webadmin.zip";
      auto-update = false;
      path = "/var/cache/stalwart-mail";
    };

    session = {
      milter = {
        # TODO: Add this <2025-02-07>
        # "clamav" = {
        #   enable = true;
        #   hostname = "127.0.0.1";
        #   port = 15112;
        #   tls = false;
        #   allow-invalid-certs = false;
        # };
      };
      ehlo = {
        require = true;
      };
      rcpt = {
        directory = "'in-memory'";
        catch-all = true;
        subaddressing = true;
      };
      data = {
        spam-filter = true;
        add-headers = {
          received = true;
          received-spf = true;
          auth-results = true;
          message-id = true;
          date = true;
          return-path = true;
          delivered-to = true;
        };
        auth = {
          mechanisms = ["LOGIN" "PLAIN"];
          directory = "'in-memory'";
          require = true;
          must-match-sender = true;
          errors = {
            total = 3;
            wait = "5s";
          };
        };
      };
      extensions = {
        pipelining = true;
        chunking = true;
        requiretls = true;
        no-soliciting = "";
        dsn = [
          {
            "if" = "!is_empty(authenticated_as)";
            "then" = true;
          }
          {"else" = false;}
        ];
        future-release = [
          {
            "if" = "!is_empty(authenticated_as)";
            "then" = "7d";
          }
          {"else" = false;}
        ];
        deliver-by = [
          {
            "if" = "!is_empty(authenticated_as)";
            "then" = "15d";
          }
          {"else" = false;}
        ];
        mt-priority = [
          {
            "if" = "!is_empty(authenticated_as)";
            "then" = "mixer";
          }
          {"else" = false;}
        ];
        vrfy = [
          {
            "if" = "!is_empty(authenticated_as)";
            "then" = true;
          }
          {"else" = false;}
        ];
        expn = [
          {
            "if" = "!is_empty(authenticated_as)";
            "then" = true;
          }
          {"else" = false;}
        ];
      };
    };

    jmap = {
      account = {
        purge.frequency = "0 0 *";
      };
      protocol = {
        changes.max-history = "14d";
      };
      email = {
        # NOTE(@bpeetz): We probably want to enable the auto-deletion of emails in
        # the "Junk" and "Deleted" items mail folders, but this should be
        # communicated to the users. <2025-02-07>
        auto-expunge = false;
      };
      mailbox = {
        max-depth = 50;
        max-name-length = 255;
      };
      folders = let
        mkFolder = name: {
          inherit name;
          create = true;
          subscribe = true;
        };
      in {
        inbox = mkFolder "INBOX";
        drafts = mkFolder "DRAFTS";
        sent = mkFolder "SENT";
        trash = mkFolder "TRASH";
        archive = mkFolder "ARCHIVE";
        junk = mkFolder "JUNK";
        shared = {name = "SHARED";};
      };
    };
    imap = {
      auth = {
        # Allow password login over non tls connection
        allow-plain-text = false;
      };
    };

    server = {
      hostname = cfg.fqdn;

      listener = {
        # TODO(@bpeetz): Add this <2025-02-08>
        # # HTTP (used for jmap)
        # "http" = {
        #   bind = ["[::]:8080"];
        #   protocol = "http";
        #   tls.implicit = true;
        # };

        # IMAP
        "imap" = {
          bind = ["[::]:993"];
          protocol = "imap";
          tls.implicit = true;
        };

        # SMTP
        "submissions" = {
          bind = ["[::]:465"];
          protocol = "smtp";
          tls.implicit = true;
        };
        "input" = {
          bind = ["[::]:25"];
          protocol = "smtp";
          tls = {
            enable = true;
            # Require an explicit `STARTTLS`
            implicit = false;
          };
        };

        # # POP3 (should be disabled, unless there is a real reason to use it)
        # "pop3" = {
        #   bind = ["[::]:995"];
        #   protocol = "pop3";
        #   tls.implicit = true;
        # };

        # # LMTP
        # "lmtp" = {
        #   bind = ["[::]:24"];
        #   protocol = "lmtp";
        # };

        # ManageSieve
        "managesieve" = {
          bind = ["[::]:4190"];
          protocol = "managesieve";
          tls.implicit = true;
        };
      };

      tls = {
        enable = true;

        # Expect the client connection to be encrypted from the start (i.e.,
        # without STARTTLS)
        implicit = true;

        certificate = "default";
      };

      # TODO(@bpeetz): Configure that <2025-02-07>
      # http = {
      #   url = "";
      #   allowed-endpoint = ["404"];
      # };

      auto-ban = {
        # Ban if the same IP fails to login 10 times in a day
        rate = "10/1d";

        # Ban the login for an user account, if different IP-Addresses tried and
        # failed to login 100 times in single day
        auth.rate = "100/1d";

        abuse.rate = "35/1d";

        loiter.rate = "150/1d";

        scan.rate = "150/1d";
      };

      cache = let
        MiB = 1024 * 1024;
      in {
        access-token.size = 10 * MiB;
        http-auth.size = 1 * MiB;
        permission.size = 5 * MiB;
        account.size = 10 * MiB;
        mailbox.size = 10 * MiB;
        thread.size = 10 * MiB;
        bayes.size = 10 * MiB;
        dns = {
          txt.size = 5 * MiB;
          mx.size = 5 * MiB;
          ptr.size = 1 * MiB;
          ipv4.size = 5 * MiB;
          ipv6.size = 5 * MiB;
          tlsa.size = 1 * MiB;
          mta-sts.size = 1 * MiB;
          rbl.size = 5 * MiB;
        };
      };
    };

    tracer = {
      # NOTE(@bpeetz):
      # We are using the console logger, because that has nice color output.
      # Simply using the console should be fine, as systemd pipes that to the journal
      # either way. <2025-02-08>
      console = {
        enable = true;
        ansi = true;
        level = "info";
        type = "console";
      };
    };

    store = {
      "rocksdb-data" = {
        type = "rocksdb";
        path = "${cfg.dataDirectory}/storage/data";
        compression = "lz4";

        # Perform “maintenance” every day at 3 am local time.
        purge.frequency = "0 3 *";
      };
      "rocksdb-full-text-search" = {
        type = "rocksdb";
        path = "${cfg.dataDirectory}/storage/full-text-search";
        compression = "lz4";

        # Perform “maintenance” every day at 2 am local time.
        purge.frequency = "0 2 *";
      };
      "file-system" = {
        type = "fs";
        path = "${cfg.dataDirectory}/storage/blobs";
        depth = 2;
        compression = "lz4";

        # Perform “maintenance” every day at 5:30 am local time.
        purge.frequency = "30 5 *";
      };
      "redis" = {
        type = "redis";
        redis-type = "single";
        urls = "unix://${config.services.redis.servers."stalwart-mail".unixSocket}";
        timeout = "10s";

        # Perform “maintenance” every day at 2:30 am local time.
        purge.frequency = "30 2 *";
      };
    };
    storage = {
      # PostgreSQL is an option, but this is recommended for single node
      # configurations.
      data = "rocksdb-data";

      # We could also re-use the data storage backend for that.
      blob = "file-system";

      full-text.default-language = "en";
      fts = "rocksdb-full-text-search";

      directory = "in-memory";

      lookup = "redis";

      # NOTE(@bpeetz): This will encrypt all emails with the users pgp key (if it
      # can be determined.) This is a wonderful feature, but quite tiresome, if
      # the user intends to read their email without a their pgp key present (for
      # example via their smartphone.) <2025-02-07>
      encryption.enable = false;
    };

    directory."in-memory" = {
      type = "memory";
      inherit (cfg) principals;
    };

    certificate = {
      "default" = {
        cert = "%{file:${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem}%";
        private-key = "%{file:${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem}%";
        default = true;
      };
    };
  };
}