{ config, lib, pkgs, ... }: with lib; let cfg = config.fudo.mail.dovecot; sieveDirectory = "${cfg.state-directory}/sieves"; in { options.fudo.mail.dovecot = with types; { enable = mkEnableOption "Enable Dovecot2 IMAP server."; debug = mkEnableOption "Enable debug logs."; state-directory = mkOption { type = str; description = "Directory at which to store server state."; }; mail-directory = mkOption { type = str; description = "Directory at which to store user email."; }; ports = { lmtp = mkOption { type = port; description = "Port on which to listen for LMTP connections."; default = 24; }; auth = mkOption { type = port; description = "Port on which to listen for auth requests."; default = 5447; }; userdb = mkOption { type = port; description = "Port on which to listen for userdb requests."; default = 5448; }; metrics = mkOption { type = port; description = "Port on which to serve metrics data."; default = 5034; }; admin = mkOption { type = port; description = "Port on which to listen for admin requests."; default = 5925; }; http-admin = mkOption { type = nullOr port; description = "Port on which to listen for admin HTTP API requests."; default = null; }; }; mail-user = mkOption { type = str; description = "User as which to run store & access mail."; default = "fudo-mail"; }; mail-group = mkOption { type = str; description = "Group as which to store & access mail."; default = "fudo-mail"; }; ssl = { certificate = mkOption { type = str; description = "Location of the Dovecot SSL certificate."; }; private-key = mkOption { type = str; description = "Location of the Dovecot SSL private key."; }; }; metrics = { user = mkOption { type = str; description = "User as which to fetch metrics."; default = "dovecot-metrics"; }; group = mkOption { type = str; description = "Group as which to fetch metrics."; default = "dovecot-metrics"; }; }; mailboxes = let mailboxOpts = { name, ... }: { options = { auto = mkOption { type = enum [ "no" "create" "subscribe" ]; description = "Whether to auto-create/subscribe."; default = "no"; }; specialUse = mkOption { type = nullOr (enum [ "All" "Archive" "Drafts" "Flagged" "Junk" "Sent" "Trash" ]); description = "Mailbox special use."; default = null; }; autoexpunge = mkOption { type = nullOr str; description = "How long to wait before clearing mail from this mailbox. Null is never."; default = null; }; }; }; in mkOption { type = attrsOf (submodule mailboxOpts); description = "Mailboxes to be created for dovecot."; default = { Trash = { auto = "create"; specialUse = "Trash"; autoexpunge = "30d"; }; Junk = { auto = "create"; specialUse = "Junk"; autoexpunge = "60d"; }; Drafts = { auto = "create"; specialUse = "Drafts"; autoexpunge = "60d"; }; Sent = { auto = "create"; specialUse = "Sent"; }; Archive = { auto = "no"; specialUse = "Archive"; }; Flagged = { auto = "subscribe"; specialUse = "Flagged"; }; }; }; rspamd = { host = mkOption { type = str; description = "Host to which spam/ham will be forwarded."; }; port = mkOption { type = port; description = "Port to which spam/ham will be forwarded."; }; }; max-user-connections = mkOption { type = int; description = "Maximum allowed simultaneous connections by one user."; default = 5; }; ldap-conf = mkOption { type = str; description = "Path to LDAP dovecot2 configuration."; }; admin-conf = mkOption { type = str; description = "Path to admin dovecot2 configuration."; }; }; config = mkIf cfg.enable { users = { users = { "${cfg.mail-user}" = { isSystemUser = true; group = cfg.mail-group; uid = 5025; }; "${cfg.metrics.user}" = { isSystemUser = true; group = cfg.metrics.group; }; }; groups = { "${cfg.mail-group}" = { members = [ cfg.mail-user ]; gid = 5025; }; "${cfg.metrics.group}".members = [ cfg.metrics.user ]; }; }; systemd = { tmpfiles.rules = [ "d ${cfg.state-directory} 0711 root root - -" "d ${cfg.mail-directory} 0750 ${cfg.mail-user} ${cfg.mail-group} - -" "d ${cfg.state-directory}/sieves 0750 ${config.services.dovecot2.user} ${config.services.dovecot2.group} - -" ]; services = { prometheus-dovecot-exporter = { requires = [ "dovecot2.service" ]; after = [ "dovecot2.service" ]; }; dovecot-sieve-generator = let isRegularFile = _: type: type == "regular"; sieves = filterAttrs isRegularFile (builtins.readDir ./sieves); headOrNull = lst: if lst == [ ] then null else head lst; stripExt = ext: filename: headOrNull (builtins.match "(.+)[.]${ext}$" filename); compileFile = filename: _: let filePath = ./sieves + "/${filename}"; fileBaseName = stripExt "sieve" filename; in '' if [ -f "${sieveDirectory}/${fileBaseName}.sieve" ]; then rm "${sieveDirectory}/${fileBaseName}.sieve" "${sieveDirectory}/${fileBaseName}.svbin" fi cp ${filePath} "${sieveDirectory}/${fileBaseName}.sieve" sievec "${sieveDirectory}/${fileBaseName}.sieve" "${sieveDirectory}/${fileBaseName}.svbin" chmod u+w "${sieveDirectory}/${fileBaseName}.sieve" ''; in { wantedBy = [ "dovecot2.service" ]; after = [ "dovecot2.service" ]; path = with pkgs; [ dovecot_pigeonhole ]; serviceConfig = { User = config.services.dovecot2.user; ReadWritePaths = [ sieveDirectory "/run/dovecot2" ]; ExecStart = pkgs.writeShellScript "generate-sieves.sh" (concatStringsSep "\n" (mapAttrsToList compileFile sieves)); PrivateDevices = true; PrivateTmp = true; PrivateMounts = true; ProtectControlGroups = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectSystem = true; ProtectHome = true; ProtectClock = true; ProtectKernelLogs = true; Type = "oneshot"; }; }; }; }; environment.etc."dovecot/conf.d/admin.conf" = { source = cfg.admin-conf; user = config.services.dovecot2.user; mode = "400"; }; services = { prometheus.exporters.dovecot = { enable = true; scopes = [ "user" "global" ]; user = cfg.metrics.user; listenAddress = "127.0.0.1"; port = cfg.ports.metrics; socketPath = "/var/run/dovecot2/old-stats"; }; dovecot2 = { enable = true; enableImap = true; enableLmtp = true; enablePAM = false; mailUser = cfg.mail-user; mailGroup = cfg.mail-group; mailLocation = "maildir:${cfg.mail-directory}/%u/"; createMailUser = false; sslServerCert = cfg.ssl.certificate; sslServerKey = cfg.ssl.private-key; mailboxes = cfg.mailboxes; modules = with pkgs; [ dovecot_pigeonhole dovecot_fts_xapian ]; protocols = [ "sieve" ]; mailPlugins = { globally.enable = [ "old_stats" "fts" "fts_xapian" ]; perProtocol = { imap.enable = [ "imap_sieve" "fts" "fts_xapian" ]; lmtp.enable = [ "sieve" "fts" "fts_xapian" ]; }; }; sieveScripts = { after = builtins.toFile "spam.sieve" '' require "fileinto"; if header :is "X-Spam" "Yes" { fileinto "Junk"; stop; } ''; }; extraConfig = let # Add learn_ham & learn_spam to dovecot2 path for use by sieves pipeBin = let teachRspamd = msg: pkgs.writeShellApplication { name = "rspamd_${msg}"; runtimeInputs = with pkgs; [ rspamd ]; text = "exec rspamc -h ${cfg.rspamd.host}:${ toString cfg.rspamd.port } ${msg}"; }; learnHam = teachRspamd "learn_ham"; learnSpam = teachRspamd "learn_spam"; in pkgs.buildEnv { name = "rspam_pipe_bin"; paths = [ learnHam learnSpam ]; }; mailUserUid = config.users.users."${cfg.mail-user}".uid; mailUserGid = config.users.group."${cfg.mail-group}".gid; in '' ## Extra Config !include /etc/dovecot/conf.d/admin.conf ${lib.optionalString cfg.debug '' mail_debug = yes auth_debug = yes verbose_ssl = yes ''} plugin { fts = xapian fts_xapian = partial=3 full=20 ${ optionalString cfg.debug "verbose=2" } fts_autoindex = yes fts_enforced = yes fts_autoindex_exclude = \Trash fts_autoindex_exclude = \Junk fts_decoder = decode2text } service indexer-worker { vsz_limit = 0 } mail_access_groups = ${cfg.mail-group} # When looking up usernames, just use the name, not the full address auth_username_format = %n auth_mechanisms = login plain service lmtp { # Enable logging in debug mode ${optionalString cfg.debug "executable = lmtp -L"} inet_listener dovecot-lmtp { address = 0.0.0.0 port = ${toString cfg.ports.lmtp} } ## Drop privs, since all mail is owned by one user # user = ${cfg.mail-user} # group = ${cfg.mail-group} ### Necessary bceause: ## - for security reasons lmtp service must be started as root since ## version 2.2.36. lmtp will drop root privileges after initialisation but it needs ## to open /self/proc/io as root before that." ## See: https://dovecot.org/list/dovecot/2019-July/116674.html user = root } passdb { driver = ldap args = ${cfg.ldap-conf} } # All users map to one actual system user userdb { driver = static args = uid=${toString mailUserUid} home=${cfg.mail-directory}/%u } service imap { vsz_limit = 1024M } namespace inbox { separator = "/" inbox = yes } service doveadm { unix_listener doveadm-server { user = ${config.services.dovecot2.user} group = ${config.services.dovecot2.group} } inet_listener { port = ${toString cfg.ports.admin} } ${ optionalString (!isNull cfg.ports.http-admin) '' inet_listener http { port = ${toString cfg.ports.http-admin} } '' } } plugin { sieve_plugins = sieve_imapsieve sieve_extprograms sieve = file:${cfg.state-directory}/sieves/%u/scripts;active=${cfg.state-directory}/sieves/%u/active.sieve sieve_default = file:${sieveDirectory}/%u/default.sieve sieve_default_name = default # From elsewhere to Spam folder imapsieve_mailbox1_name = Junk imapsieve_mailbox1_causes = COPY imapsieve_mailbox1_before = file:${sieveDirectory}/spam.svbin # From Spam folder to elsewhere imapsieve_mailbox2_name = * imapsieve_mailbox2_from = Junk imapsieve_mailbox2_causes = COPY imapsieve_mailbox2_before = file:${sieveDirectory}/ham.svbin sieve_pipe_bin_dir = ${pipeBin}/bin sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.environment } service decode2text { executable = script ${pkgs.dovecot}/libexec/dovecot/decode2text.sh user = ${config.services.dovecot2.user} unix_listener decode2text { mode = 0660 } } recipient_delimiter = + lmtp_save_to_detail_mailbox = yes lda_mailbox_autosubscribe = yes lda_mailbox_autocreate = yes service old-stats { unix_listener old-stats { user = ${cfg.metrics.user} group = ${cfg.metrics.group} } fifo_listener old-stats-mail { mode = 0660 user = ${config.services.dovecot2.user} group = ${config.services.dovecot2.group} } fifo_listener old-stats-user { mode = 0660 user = ${config.services.dovecot2.user} group = ${config.services.dovecot2.group} } } plugin { old_stats_refresh = 30 secs old_stats_track_cmds = yes } ''; }; }; }; }