diff options
author | Alyssa Ross <hi@alyssa.is> | 2022-05-31 09:59:33 +0000 |
---|---|---|
committer | Alyssa Ross <hi@alyssa.is> | 2022-05-31 09:59:57 +0000 |
commit | 9ff36293d1e428cd7bf03e8d4b03611b6d361c28 (patch) | |
tree | 1ab51a42b868c55b83f6ccdb80371b9888739dd9 /nixos/modules/services/mail | |
parent | 1c4fcd0d4b0541e674ee56ace1053e23e562cc80 (diff) | |
parent | ddc3c396a51918043bb0faa6f676abd9562be62c (diff) | |
download | nixpkgs-archive.tar nixpkgs-archive.tar.gz nixpkgs-archive.tar.bz2 nixpkgs-archive.tar.lz nixpkgs-archive.tar.xz nixpkgs-archive.tar.zst nixpkgs-archive.zip |
Last good Nixpkgs for Weston+nouveau? archive
I came this commit hash to terwiz[m] on IRC, who is trying to figure out what the last version of Spectrum that worked on their NUC with Nvidia graphics is.
Diffstat (limited to 'nixos/modules/services/mail')
27 files changed, 6140 insertions, 0 deletions
diff --git a/nixos/modules/services/mail/clamsmtp.nix b/nixos/modules/services/mail/clamsmtp.nix new file mode 100644 index 00000000000..fc1267c5d28 --- /dev/null +++ b/nixos/modules/services/mail/clamsmtp.nix @@ -0,0 +1,181 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.clamsmtp; + clamdSocket = "/run/clamav/clamd.ctl"; # See services/security/clamav.nix +in +{ + ##### interface + options = { + services.clamsmtp = { + enable = mkOption { + type = types.bool; + default = false; + description = "Whether to enable clamsmtp."; + }; + + instances = mkOption { + description = "Instances of clamsmtp to run."; + type = types.listOf (types.submodule { options = { + action = mkOption { + type = types.enum [ "bounce" "drop" "pass" ]; + default = "drop"; + description = + '' + Action to take when a virus is detected. + + Note that viruses often spoof sender addresses, so bouncing is + in most cases not a good idea. + ''; + }; + + header = mkOption { + type = types.str; + default = ""; + example = "X-Virus-Scanned: ClamAV using ClamSMTP"; + description = + '' + A header to add to scanned messages. See clamsmtpd.conf(5) for + more details. Empty means no header. + ''; + }; + + keepAlives = mkOption { + type = types.int; + default = 0; + description = + '' + Number of seconds to wait between each NOOP sent to the sending + server. 0 to disable. + + This is meant for slow servers where the sending MTA times out + waiting for clamd to scan the file. + ''; + }; + + listen = mkOption { + type = types.str; + example = "127.0.0.1:10025"; + description = + '' + Address to wait for incoming SMTP connections on. See + clamsmtpd.conf(5) for more details. + ''; + }; + + quarantine = mkOption { + type = types.bool; + default = false; + description = + '' + Whether to quarantine files that contain viruses by leaving them + in the temporary directory. + ''; + }; + + maxConnections = mkOption { + type = types.int; + default = 64; + description = "Maximum number of connections to accept at once."; + }; + + outAddress = mkOption { + type = types.str; + description = + '' + Address of the SMTP server to send email to once it has been + scanned. + ''; + }; + + tempDirectory = mkOption { + type = types.str; + default = "/tmp"; + description = + '' + Temporary directory that needs to be accessible to both clamd + and clamsmtpd. + ''; + }; + + timeout = mkOption { + type = types.int; + default = 180; + description = "Time-out for network connections."; + }; + + transparentProxy = mkOption { + type = types.bool; + default = false; + description = "Enable clamsmtp's transparent proxy support."; + }; + + virusAction = mkOption { + type = with types; nullOr path; + default = null; + description = + '' + Command to run when a virus is found. Please see VIRUS ACTION in + clamsmtpd(8) for a discussion of this option and its safe use. + ''; + }; + + xClient = mkOption { + type = types.bool; + default = false; + description = + '' + Send the XCLIENT command to the receiving server, for forwarding + client addresses and connection information if the receiving + server supports this feature. + ''; + }; + };}); + }; + }; + }; + + ##### implementation + config = let + configfile = conf: pkgs.writeText "clamsmtpd.conf" + '' + Action: ${conf.action} + ClamAddress: ${clamdSocket} + Header: ${conf.header} + KeepAlives: ${toString conf.keepAlives} + Listen: ${conf.listen} + Quarantine: ${if conf.quarantine then "on" else "off"} + MaxConnections: ${toString conf.maxConnections} + OutAddress: ${conf.outAddress} + TempDirectory: ${conf.tempDirectory} + TimeOut: ${toString conf.timeout} + TransparentProxy: ${if conf.transparentProxy then "on" else "off"} + User: clamav + ${optionalString (conf.virusAction != null) "VirusAction: ${conf.virusAction}"} + XClient: ${if conf.xClient then "on" else "off"} + ''; + in + mkIf cfg.enable { + assertions = [ + { assertion = config.services.clamav.daemon.enable; + message = "clamsmtp requires clamav to be enabled"; + } + ]; + + systemd.services = listToAttrs (imap1 (i: conf: + nameValuePair "clamsmtp-${toString i}" { + description = "ClamSMTP instance ${toString i}"; + wantedBy = [ "multi-user.target" ]; + script = "exec ${pkgs.clamsmtp}/bin/clamsmtpd -f ${configfile conf}"; + after = [ "clamav-daemon.service" ]; + requires = [ "clamav-daemon.service" ]; + serviceConfig.Type = "forking"; + serviceConfig.PrivateTmp = "yes"; + unitConfig.JoinsNamespaceOf = "clamav-daemon.service"; + } + ) cfg.instances); + }; + + meta.maintainers = with lib.maintainers; [ ekleog ]; +} diff --git a/nixos/modules/services/mail/davmail.nix b/nixos/modules/services/mail/davmail.nix new file mode 100644 index 00000000000..e9f31e6fb39 --- /dev/null +++ b/nixos/modules/services/mail/davmail.nix @@ -0,0 +1,99 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.davmail; + + configType = with types; + oneOf [ (attrsOf configType) str int bool ] // { + description = "davmail config type (str, int, bool or attribute set thereof)"; + }; + + toStr = val: if isBool val then boolToString val else toString val; + + linesForAttrs = attrs: concatMap (name: let value = attrs.${name}; in + if isAttrs value + then map (line: name + "." + line) (linesForAttrs value) + else [ "${name}=${toStr value}" ] + ) (attrNames attrs); + + configFile = pkgs.writeText "davmail.properties" (concatStringsSep "\n" (linesForAttrs cfg.config)); + +in + + { + options.services.davmail = { + enable = mkEnableOption "davmail, an MS Exchange gateway"; + + url = mkOption { + type = types.str; + description = "Outlook Web Access URL to access the exchange server, i.e. the base webmail URL."; + example = "https://outlook.office365.com/EWS/Exchange.asmx"; + }; + + config = mkOption { + type = configType; + default = {}; + description = '' + Davmail configuration. Refer to + <link xlink:href="http://davmail.sourceforge.net/serversetup.html"/> + and <link xlink:href="http://davmail.sourceforge.net/advanced.html"/> + for details on supported values. + ''; + example = literalExpression '' + { + davmail.allowRemote = true; + davmail.imapPort = 55555; + davmail.bindAddress = "10.0.1.2"; + davmail.smtpSaveInSent = true; + davmail.folderSizeLimit = 10; + davmail.caldavAutoSchedule = false; + log4j.logger.rootLogger = "DEBUG"; + } + ''; + }; + }; + + config = mkIf cfg.enable { + + services.davmail.config = { + davmail = mapAttrs (name: mkDefault) { + server = true; + disableUpdateCheck = true; + logFilePath = "/var/log/davmail/davmail.log"; + logFileSize = "1MB"; + mode = "auto"; + url = cfg.url; + caldavPort = 1080; + imapPort = 1143; + ldapPort = 1389; + popPort = 1110; + smtpPort = 1025; + }; + log4j = { + logger.davmail = mkDefault "WARN"; + logger.httpclient.wire = mkDefault "WARN"; + logger.org.apache.commons.httpclient = mkDefault "WARN"; + rootLogger = mkDefault "WARN"; + }; + }; + + systemd.services.davmail = { + description = "DavMail POP/IMAP/SMTP Exchange Gateway"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.davmail}/bin/davmail ${configFile}"; + Restart = "on-failure"; + DynamicUser = "yes"; + LogsDirectory = "davmail"; + }; + }; + + environment.systemPackages = [ pkgs.davmail ]; + }; + } diff --git a/nixos/modules/services/mail/dkimproxy-out.nix b/nixos/modules/services/mail/dkimproxy-out.nix new file mode 100644 index 00000000000..f4ac9e47007 --- /dev/null +++ b/nixos/modules/services/mail/dkimproxy-out.nix @@ -0,0 +1,120 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.dkimproxy-out; + keydir = "/var/lib/dkimproxy-out"; + privkey = "${keydir}/private.key"; + pubkey = "${keydir}/public.key"; +in +{ + ##### interface + options = { + services.dkimproxy-out = { + enable = mkOption { + type = types.bool; + default = false; + description = + '' + Whether to enable dkimproxy_out. + + Note that a key will be auto-generated, and can be found in + ${keydir}. + ''; + }; + + listen = mkOption { + type = types.str; + example = "127.0.0.1:10027"; + description = "Address:port DKIMproxy should listen on."; + }; + + relay = mkOption { + type = types.str; + example = "127.0.0.1:10028"; + description = "Address:port DKIMproxy should forward mail to."; + }; + + domains = mkOption { + type = with types; listOf str; + example = [ "example.org" "example.com" ]; + description = "List of domains DKIMproxy can sign for."; + }; + + selector = mkOption { + type = types.str; + example = "selector1"; + description = + '' + The selector to use for DKIM key identification. + + For example, if 'selector1' is used here, then for each domain + 'example.org' given in `domain`, 'selector1._domainkey.example.org' + should contain the TXT record indicating the public key is the one + in ${pubkey}: "v=DKIM1; t=s; p=[THE PUBLIC KEY]". + ''; + }; + + keySize = mkOption { + type = types.int; + default = 2048; + description = + '' + Size of the RSA key to use to sign outgoing emails. Note that the + maximum mandatorily verified as per RFC6376 is 2048. + ''; + }; + + # TODO: allow signature for other schemes than dkim(c=relaxed/relaxed)? + # This being the scheme used by gmail, maybe nothing more is needed for + # reasonable use. + }; + }; + + ##### implementation + config = let + configfile = pkgs.writeText "dkimproxy_out.conf" + '' + listen ${cfg.listen} + relay ${cfg.relay} + + domain ${concatStringsSep "," cfg.domains} + selector ${cfg.selector} + + signature dkim(c=relaxed/relaxed) + + keyfile ${privkey} + ''; + in + mkIf cfg.enable { + users.groups.dkimproxy-out = {}; + users.users.dkimproxy-out = { + description = "DKIMproxy_out daemon"; + group = "dkimproxy-out"; + isSystemUser = true; + }; + + systemd.services.dkimproxy-out = { + description = "DKIMproxy_out"; + wantedBy = [ "multi-user.target" ]; + preStart = '' + if [ ! -d "${keydir}" ]; then + mkdir -p "${keydir}" + chmod 0700 "${keydir}" + ${pkgs.openssl}/bin/openssl genrsa -out "${privkey}" ${toString cfg.keySize} + ${pkgs.openssl}/bin/openssl rsa -in "${privkey}" -pubout -out "${pubkey}" + chown -R dkimproxy-out:dkimproxy-out "${keydir}" + fi + ''; + script = '' + exec ${pkgs.dkimproxy}/bin/dkimproxy.out --conf_file=${configfile} + ''; + serviceConfig = { + User = "dkimproxy-out"; + PermissionsStartOnly = true; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ ekleog ]; +} diff --git a/nixos/modules/services/mail/dovecot.nix b/nixos/modules/services/mail/dovecot.nix new file mode 100644 index 00000000000..a8c1f176782 --- /dev/null +++ b/nixos/modules/services/mail/dovecot.nix @@ -0,0 +1,462 @@ +{ options, config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.dovecot2; + dovecotPkg = pkgs.dovecot; + + baseDir = "/run/dovecot2"; + stateDir = "/var/lib/dovecot"; + + dovecotConf = concatStrings [ + '' + base_dir = ${baseDir} + protocols = ${concatStringsSep " " cfg.protocols} + sendmail_path = /run/wrappers/bin/sendmail + # defining mail_plugins must be done before the first protocol {} filter because of https://doc.dovecot.org/configuration_manual/config_file/config_file_syntax/#variable-expansion + mail_plugins = $mail_plugins ${concatStringsSep " " cfg.mailPlugins.globally.enable} + '' + + ( + concatStringsSep "\n" ( + mapAttrsToList ( + protocol: plugins: '' + protocol ${protocol} { + mail_plugins = $mail_plugins ${concatStringsSep " " plugins.enable} + } + '' + ) cfg.mailPlugins.perProtocol + ) + ) + + ( + if cfg.sslServerCert == null then '' + ssl = no + disable_plaintext_auth = no + '' else '' + ssl_cert = <${cfg.sslServerCert} + ssl_key = <${cfg.sslServerKey} + ${optionalString (cfg.sslCACert != null) ("ssl_ca = <" + cfg.sslCACert)} + ${optionalString cfg.enableDHE ''ssl_dh = <${config.security.dhparams.params.dovecot2.path}''} + disable_plaintext_auth = yes + '' + ) + + '' + default_internal_user = ${cfg.user} + default_internal_group = ${cfg.group} + ${optionalString (cfg.mailUser != null) "mail_uid = ${cfg.mailUser}"} + ${optionalString (cfg.mailGroup != null) "mail_gid = ${cfg.mailGroup}"} + + mail_location = ${cfg.mailLocation} + + maildir_copy_with_hardlinks = yes + pop3_uidl_format = %08Xv%08Xu + + auth_mechanisms = plain login + + service auth { + user = root + } + '' + + ( + optionalString cfg.enablePAM '' + userdb { + driver = passwd + } + + passdb { + driver = pam + args = ${optionalString cfg.showPAMFailure "failure_show_msg=yes"} dovecot2 + } + '' + ) + + ( + optionalString (cfg.sieveScripts != {}) '' + plugin { + ${concatStringsSep "\n" (mapAttrsToList (to: from: "sieve_${to} = ${stateDir}/sieve/${to}") cfg.sieveScripts)} + } + '' + ) + + ( + optionalString (cfg.mailboxes != {}) '' + namespace inbox { + inbox=yes + ${concatStringsSep "\n" (map mailboxConfig (attrValues cfg.mailboxes))} + } + '' + ) + + ( + optionalString cfg.enableQuota '' + service quota-status { + executable = ${dovecotPkg}/libexec/dovecot/quota-status -p postfix + inet_listener { + port = ${cfg.quotaPort} + } + client_limit = 1 + } + + plugin { + quota_rule = *:storage=${cfg.quotaGlobalPerUser} + quota = count:User quota # per virtual mail user quota + quota_status_success = DUNNO + quota_status_nouser = DUNNO + quota_status_overquota = "552 5.2.2 Mailbox is full" + quota_grace = 10%% + quota_vsizes = yes + } + '' + ) + + cfg.extraConfig + ]; + + modulesDir = pkgs.symlinkJoin { + name = "dovecot-modules"; + paths = map (pkg: "${pkg}/lib/dovecot") ([ dovecotPkg ] ++ map (module: module.override { dovecot = dovecotPkg; }) cfg.modules); + }; + + mailboxConfig = mailbox: '' + mailbox "${mailbox.name}" { + auto = ${toString mailbox.auto} + '' + optionalString (mailbox.autoexpunge != null) '' + autoexpunge = ${mailbox.autoexpunge} + '' + optionalString (mailbox.specialUse != null) '' + special_use = \${toString mailbox.specialUse} + '' + "}"; + + mailboxes = { name, ... }: { + options = { + name = mkOption { + type = types.strMatching ''[^"]+''; + example = "Spam"; + default = name; + readOnly = true; + description = "The name of the mailbox."; + }; + auto = mkOption { + type = types.enum [ "no" "create" "subscribe" ]; + default = "no"; + example = "subscribe"; + description = "Whether to automatically create or create and subscribe to the mailbox or not."; + }; + specialUse = mkOption { + type = types.nullOr (types.enum [ "All" "Archive" "Drafts" "Flagged" "Junk" "Sent" "Trash" ]); + default = null; + example = "Junk"; + description = "Null if no special use flag is set. Other than that every use flag mentioned in the RFC is valid."; + }; + autoexpunge = mkOption { + type = types.nullOr types.str; + default = null; + example = "60d"; + description = '' + To automatically remove all email from the mailbox which is older than the + specified time. + ''; + }; + }; + }; +in +{ + imports = [ + (mkRemovedOptionModule [ "services" "dovecot2" "package" ] "") + ]; + + options.services.dovecot2 = { + enable = mkEnableOption "the dovecot 2.x POP3/IMAP server"; + + enablePop3 = mkEnableOption "starting the POP3 listener (when Dovecot is enabled)."; + + enableImap = mkEnableOption "starting the IMAP listener (when Dovecot is enabled)." // { default = true; }; + + enableLmtp = mkEnableOption "starting the LMTP listener (when Dovecot is enabled)."; + + protocols = mkOption { + type = types.listOf types.str; + default = []; + description = "Additional listeners to start when Dovecot is enabled."; + }; + + user = mkOption { + type = types.str; + default = "dovecot2"; + description = "Dovecot user name."; + }; + + group = mkOption { + type = types.str; + default = "dovecot2"; + description = "Dovecot group name."; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + example = "mail_debug = yes"; + description = "Additional entries to put verbatim into Dovecot's config file."; + }; + + mailPlugins = + let + plugins = hint: types.submodule { + options = { + enable = mkOption { + type = types.listOf types.str; + default = []; + description = "mail plugins to enable as a list of strings to append to the ${hint} <literal>$mail_plugins</literal> configuration variable"; + }; + }; + }; + in + mkOption { + type = with types; submodule { + options = { + globally = mkOption { + description = "Additional entries to add to the mail_plugins variable for all protocols"; + type = plugins "top-level"; + example = { enable = [ "virtual" ]; }; + default = { enable = []; }; + }; + perProtocol = mkOption { + description = "Additional entries to add to the mail_plugins variable, per protocol"; + type = attrsOf (plugins "corresponding per-protocol"); + default = {}; + example = { imap = [ "imap_acl" ]; }; + }; + }; + }; + description = "Additional entries to add to the mail_plugins variable, globally and per protocol"; + example = { + globally.enable = [ "acl" ]; + perProtocol.imap.enable = [ "imap_acl" ]; + }; + default = { globally.enable = []; perProtocol = {}; }; + }; + + configFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Config file used for the whole dovecot configuration."; + apply = v: if v != null then v else pkgs.writeText "dovecot.conf" dovecotConf; + }; + + mailLocation = mkOption { + type = types.str; + default = "maildir:/var/spool/mail/%u"; /* Same as inbox, as postfix */ + example = "maildir:~/mail:INBOX=/var/spool/mail/%u"; + description = '' + Location that dovecot will use for mail folders. Dovecot mail_location option. + ''; + }; + + mailUser = mkOption { + type = types.nullOr types.str; + default = null; + description = "Default user to store mail for virtual users."; + }; + + mailGroup = mkOption { + type = types.nullOr types.str; + default = null; + description = "Default group to store mail for virtual users."; + }; + + createMailUser = mkEnableOption ''automatically creating the user + given in <option>services.dovecot.user</option> and the group + given in <option>services.dovecot.group</option>.'' // { default = true; }; + + modules = mkOption { + type = types.listOf types.package; + default = []; + example = literalExpression "[ pkgs.dovecot_pigeonhole ]"; + description = '' + Symlinks the contents of lib/dovecot of every given package into + /etc/dovecot/modules. This will make the given modules available + if a dovecot package with the module_dir patch applied is being used. + ''; + }; + + sslCACert = mkOption { + type = types.nullOr types.str; + default = null; + description = "Path to the server's CA certificate key."; + }; + + sslServerCert = mkOption { + type = types.nullOr types.str; + default = null; + description = "Path to the server's public key."; + }; + + sslServerKey = mkOption { + type = types.nullOr types.str; + default = null; + description = "Path to the server's private key."; + }; + + enablePAM = mkEnableOption "creating a own Dovecot PAM service and configure PAM user logins." // { default = true; }; + + enableDHE = mkEnableOption "enable ssl_dh and generation of primes for the key exchange." // { default = true; }; + + sieveScripts = mkOption { + type = types.attrsOf types.path; + default = {}; + description = "Sieve scripts to be executed. Key is a sequence, e.g. 'before2', 'after' etc."; + }; + + showPAMFailure = mkEnableOption "showing the PAM failure message on authentication error (useful for OTPW)."; + + mailboxes = mkOption { + type = with types; coercedTo + (listOf unspecified) + (list: listToAttrs (map (entry: { name = entry.name; value = removeAttrs entry ["name"]; }) list)) + (attrsOf (submodule mailboxes)); + default = {}; + example = literalExpression '' + { + Spam = { specialUse = "Junk"; auto = "create"; }; + } + ''; + description = "Configure mailboxes and auto create or subscribe them."; + }; + + enableQuota = mkEnableOption "the dovecot quota service."; + + quotaPort = mkOption { + type = types.str; + default = "12340"; + description = '' + The Port the dovecot quota service binds to. + If using postfix, add check_policy_service inet:localhost:12340 to your smtpd_recipient_restrictions in your postfix config. + ''; + }; + quotaGlobalPerUser = mkOption { + type = types.str; + default = "100G"; + example = "10G"; + description = "Quota limit for the user in bytes. Supports suffixes b, k, M, G, T and %."; + }; + + }; + + + config = mkIf cfg.enable { + security.pam.services.dovecot2 = mkIf cfg.enablePAM {}; + + security.dhparams = mkIf (cfg.sslServerCert != null && cfg.enableDHE) { + enable = true; + params.dovecot2 = {}; + }; + services.dovecot2.protocols = + optional cfg.enableImap "imap" + ++ optional cfg.enablePop3 "pop3" + ++ optional cfg.enableLmtp "lmtp"; + + services.dovecot2.mailPlugins = mkIf cfg.enableQuota { + globally.enable = [ "quota" ]; + perProtocol.imap.enable = [ "imap_quota" ]; + }; + + users.users = { + dovenull = + { + uid = config.ids.uids.dovenull2; + description = "Dovecot user for untrusted logins"; + group = "dovenull"; + }; + } // optionalAttrs (cfg.user == "dovecot2") { + dovecot2 = + { + uid = config.ids.uids.dovecot2; + description = "Dovecot user"; + group = cfg.group; + }; + } // optionalAttrs (cfg.createMailUser && cfg.mailUser != null) { + ${cfg.mailUser} = + { description = "Virtual Mail User"; isSystemUser = true; } // optionalAttrs (cfg.mailGroup != null) + { group = cfg.mailGroup; }; + }; + + users.groups = { + dovenull.gid = config.ids.gids.dovenull2; + } // optionalAttrs (cfg.group == "dovecot2") { + dovecot2.gid = config.ids.gids.dovecot2; + } // optionalAttrs (cfg.createMailUser && cfg.mailGroup != null) { + ${cfg.mailGroup} = {}; + }; + + environment.etc."dovecot/modules".source = modulesDir; + environment.etc."dovecot/dovecot.conf".source = cfg.configFile; + + systemd.services.dovecot2 = { + description = "Dovecot IMAP/POP3 server"; + + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ cfg.configFile modulesDir ]; + + startLimitIntervalSec = 60; # 1 min + serviceConfig = { + Type = "notify"; + ExecStart = "${dovecotPkg}/sbin/dovecot -F"; + ExecReload = "${dovecotPkg}/sbin/doveadm reload"; + Restart = "on-failure"; + RestartSec = "1s"; + RuntimeDirectory = [ "dovecot2" ]; + }; + + # When copying sieve scripts preserve the original time stamp + # (should be 0) so that the compiled sieve script is newer than + # the source file and Dovecot won't try to compile it. + preStart = '' + rm -rf ${stateDir}/sieve + '' + optionalString (cfg.sieveScripts != {}) '' + mkdir -p ${stateDir}/sieve + ${concatStringsSep "\n" ( + mapAttrsToList ( + to: from: '' + if [ -d '${from}' ]; then + mkdir '${stateDir}/sieve/${to}' + cp -p "${from}/"*.sieve '${stateDir}/sieve/${to}' + else + cp -p '${from}' '${stateDir}/sieve/${to}' + fi + ${pkgs.dovecot_pigeonhole}/bin/sievec '${stateDir}/sieve/${to}' + '' + ) cfg.sieveScripts + )} + chown -R '${cfg.mailUser}:${cfg.mailGroup}' '${stateDir}/sieve' + ''; + }; + + environment.systemPackages = [ dovecotPkg ]; + + warnings = mkIf (any isList options.services.dovecot2.mailboxes.definitions) [ + "Declaring `services.dovecot2.mailboxes' as a list is deprecated and will break eval in 21.05! See the release notes for more info for migration." + ]; + + assertions = [ + { + assertion = (cfg.sslServerCert == null) == (cfg.sslServerKey == null) + && (cfg.sslCACert != null -> !(cfg.sslServerCert == null || cfg.sslServerKey == null)); + message = "dovecot needs both sslServerCert and sslServerKey defined for working crypto"; + } + { + assertion = cfg.showPAMFailure -> cfg.enablePAM; + message = "dovecot is configured with showPAMFailure while enablePAM is disabled"; + } + { + assertion = cfg.sieveScripts != {} -> (cfg.mailUser != null && cfg.mailGroup != null); + message = "dovecot requires mailUser and mailGroup to be set when sieveScripts is set"; + } + ]; + + }; + +} diff --git a/nixos/modules/services/mail/dspam.nix b/nixos/modules/services/mail/dspam.nix new file mode 100644 index 00000000000..766ebc8095a --- /dev/null +++ b/nixos/modules/services/mail/dspam.nix @@ -0,0 +1,150 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.dspam; + + dspam = pkgs.dspam; + + defaultSock = "/run/dspam/dspam.sock"; + + cfgfile = pkgs.writeText "dspam.conf" '' + Home /var/lib/dspam + StorageDriver ${dspam}/lib/dspam/lib${cfg.storageDriver}_drv.so + + Trust root + Trust ${cfg.user} + SystemLog on + UserLog on + + ${optionalString (cfg.domainSocket != null) '' + ServerDomainSocketPath "${cfg.domainSocket}" + ClientHost "${cfg.domainSocket}" + ''} + + ${cfg.extraConfig} + ''; + +in { + + ###### interface + + options = { + + services.dspam = { + + enable = mkOption { + type = types.bool; + default = false; + description = "Whether to enable the dspam spam filter."; + }; + + user = mkOption { + type = types.str; + default = "dspam"; + description = "User for the dspam daemon."; + }; + + group = mkOption { + type = types.str; + default = "dspam"; + description = "Group for the dspam daemon."; + }; + + storageDriver = mkOption { + type = types.str; + default = "hash"; + description = "Storage driver backend to use for dspam."; + }; + + domainSocket = mkOption { + type = types.nullOr types.path; + default = defaultSock; + description = "Path to local domain socket which is used for communication with the daemon. Set to null to disable UNIX socket."; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = "Additional dspam configuration."; + }; + + maintenanceInterval = mkOption { + type = types.nullOr types.str; + default = null; + description = "If set, maintenance script will be run at specified (in systemd.timer format) interval"; + }; + + }; + + }; + + + ###### implementation + + config = mkIf cfg.enable (mkMerge [ + { + users.users = optionalAttrs (cfg.user == "dspam") { + dspam = { + group = cfg.group; + uid = config.ids.uids.dspam; + }; + }; + + users.groups = optionalAttrs (cfg.group == "dspam") { + dspam.gid = config.ids.gids.dspam; + }; + + environment.systemPackages = [ dspam ]; + + environment.etc."dspam/dspam.conf".source = cfgfile; + + systemd.services.dspam = { + description = "dspam spam filtering daemon"; + wantedBy = [ "multi-user.target" ]; + after = [ "postgresql.service" ]; + restartTriggers = [ cfgfile ]; + + serviceConfig = { + ExecStart = "${dspam}/bin/dspam --daemon --nofork"; + User = cfg.user; + Group = cfg.group; + RuntimeDirectory = optional (cfg.domainSocket == defaultSock) "dspam"; + RuntimeDirectoryMode = optional (cfg.domainSocket == defaultSock) "0750"; + StateDirectory = "dspam"; + StateDirectoryMode = "0750"; + LogsDirectory = "dspam"; + LogsDirectoryMode = "0750"; + # DSPAM segfaults on just about every error + Restart = "on-abort"; + RestartSec = "1s"; + }; + }; + } + + (mkIf (cfg.maintenanceInterval != null) { + systemd.timers.dspam-maintenance = { + description = "Timer for dspam maintenance script"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = cfg.maintenanceInterval; + Unit = "dspam-maintenance.service"; + }; + }; + + systemd.services.dspam-maintenance = { + description = "dspam maintenance script"; + restartTriggers = [ cfgfile ]; + + serviceConfig = { + ExecStart = "${dspam}/bin/dspam_maintenance --verbose"; + Type = "oneshot"; + User = cfg.user; + Group = cfg.group; + }; + }; + }) + ]); +} diff --git a/nixos/modules/services/mail/exim.nix b/nixos/modules/services/mail/exim.nix new file mode 100644 index 00000000000..7356db2b6a6 --- /dev/null +++ b/nixos/modules/services/mail/exim.nix @@ -0,0 +1,132 @@ +{ config, lib, pkgs, ... }: + +let + inherit (lib) literalExpression mkIf mkOption singleton types; + inherit (pkgs) coreutils; + cfg = config.services.exim; +in + +{ + + ###### interface + + options = { + + services.exim = { + + enable = mkOption { + type = types.bool; + default = false; + description = "Whether to enable the Exim mail transfer agent."; + }; + + config = mkOption { + type = types.lines; + default = ""; + description = '' + Verbatim Exim configuration. This should not contain exim_user, + exim_group, exim_path, or spool_directory. + ''; + }; + + user = mkOption { + type = types.str; + default = "exim"; + description = '' + User to use when no root privileges are required. + In particular, this applies when receiving messages and when doing + remote deliveries. (Local deliveries run as various non-root users, + typically as the owner of a local mailbox.) Specifying this value + as root is not supported. + ''; + }; + + group = mkOption { + type = types.str; + default = "exim"; + description = '' + Group to use when no root privileges are required. + ''; + }; + + spoolDir = mkOption { + type = types.path; + default = "/var/spool/exim"; + description = '' + Location of the spool directory of exim. + ''; + }; + + package = mkOption { + type = types.package; + default = pkgs.exim; + defaultText = literalExpression "pkgs.exim"; + description = '' + The Exim derivation to use. + This can be used to enable features such as LDAP or PAM support. + ''; + }; + + queueRunnerInterval = mkOption { + type = types.str; + default = "5m"; + description = '' + How often to spawn a new queue runner. + ''; + }; + }; + + }; + + + ###### implementation + + config = mkIf cfg.enable { + + environment = { + etc."exim.conf".text = '' + exim_user = ${cfg.user} + exim_group = ${cfg.group} + exim_path = /run/wrappers/bin/exim + spool_directory = ${cfg.spoolDir} + ${cfg.config} + ''; + systemPackages = [ cfg.package ]; + }; + + users.users.${cfg.user} = { + description = "Exim mail transfer agent user"; + uid = config.ids.uids.exim; + group = cfg.group; + }; + + users.groups.${cfg.group} = { + gid = config.ids.gids.exim; + }; + + security.wrappers.exim = + { setuid = true; + owner = "root"; + group = "root"; + source = "${cfg.package}/bin/exim"; + }; + + systemd.services.exim = { + description = "Exim Mail Daemon"; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ config.environment.etc."exim.conf".source ]; + serviceConfig = { + ExecStart = "${cfg.package}/bin/exim -bdf -q${cfg.queueRunnerInterval}"; + ExecReload = "${coreutils}/bin/kill -HUP $MAINPID"; + }; + preStart = '' + if ! test -d ${cfg.spoolDir}; then + ${coreutils}/bin/mkdir -p ${cfg.spoolDir} + ${coreutils}/bin/chown ${cfg.user}:${cfg.group} ${cfg.spoolDir} + fi + ''; + }; + + }; + +} diff --git a/nixos/modules/services/mail/maddy.nix b/nixos/modules/services/mail/maddy.nix new file mode 100644 index 00000000000..0b06905ac6f --- /dev/null +++ b/nixos/modules/services/mail/maddy.nix @@ -0,0 +1,273 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + name = "maddy"; + + cfg = config.services.maddy; + + defaultConfig = '' + # Minimal configuration with TLS disabled, adapted from upstream example + # configuration here https://github.com/foxcpp/maddy/blob/master/maddy.conf + # Do not use this in production! + + tls off + + auth.pass_table local_authdb { + table sql_table { + driver sqlite3 + dsn credentials.db + table_name passwords + } + } + + storage.imapsql local_mailboxes { + driver sqlite3 + dsn imapsql.db + } + + table.chain local_rewrites { + optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3" + optional_step static { + entry postmaster postmaster@$(primary_domain) + } + optional_step file /etc/maddy/aliases + } + msgpipeline local_routing { + 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 tcp://0.0.0.0:587 { + 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 tcp://0.0.0.0:143 { + auth &local_authdb + storage &local_mailboxes + } + ''; + +in { + options = { + services.maddy = { + + enable = mkEnableOption "Maddy, a free an open source mail server"; + + user = mkOption { + default = "maddy"; + type = with types; uniq string; + description = '' + User account under which maddy runs. + + <note><para> + If left as the default value this user will automatically be created + on system activation, otherwise the sysadmin is responsible for + ensuring the user exists before the maddy service starts. + </para></note> + ''; + }; + + group = mkOption { + default = "maddy"; + type = with types; uniq string; + description = '' + Group account under which maddy runs. + + <note><para> + If left as the default value this group will automatically be created + on system activation, otherwise the sysadmin is responsible for + ensuring the group exists before the maddy service starts. + </para></note> + ''; + }; + + hostname = mkOption { + default = "localhost"; + type = with types; uniq string; + example = ''example.com''; + description = '' + Hostname to use. It should be FQDN. + ''; + }; + + primaryDomain = mkOption { + default = "localhost"; + type = with types; uniq string; + example = ''mail.example.com''; + description = '' + Primary MX domain to use. It should be FQDN. + ''; + }; + + localDomains = mkOption { + type = with types; listOf str; + default = ["$(primary_domain)"]; + example = [ + "$(primary_domain)" + "example.com" + "other.example.com" + ]; + description = '' + Define list of allowed domains. + ''; + }; + + config = mkOption { + type = with types; nullOr lines; + default = defaultConfig; + description = '' + Server configuration, see + <link xlink:href="https://maddy.email">https://maddy.email</link> for + more information. The default configuration of this module will setup + minimal maddy instance for mail transfer without TLS encryption. + <note><para> + This should not be used in a production environment. + </para></note> + ''; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = '' + Open the configured incoming and outgoing mail server ports. + ''; + }; + + }; + }; + + config = mkIf cfg.enable { + + systemd = { + packages = [ pkgs.maddy ]; + services.maddy = { + serviceConfig = { + User = cfg.user; + Group = cfg.group; + StateDirectory = [ "maddy" ]; + }; + restartTriggers = [ config.environment.etc."maddy/maddy.conf".source ]; + wantedBy = [ "multi-user.target" ]; + }; + }; + + environment.etc."maddy/maddy.conf" = { + text = '' + $(hostname) = ${cfg.hostname} + $(primary_domain) = ${cfg.primaryDomain} + $(local_domains) = ${toString cfg.localDomains} + hostname ${cfg.hostname} + ${cfg.config} + ''; + }; + + users.users = optionalAttrs (cfg.user == name) { + ${name} = { + isSystemUser = true; + group = cfg.group; + description = "Maddy mail transfer agent user"; + }; + }; + + users.groups = optionalAttrs (cfg.group == name) { + ${cfg.group} = { }; + }; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPorts = [ 25 143 587 ]; + }; + + environment.systemPackages = [ + pkgs.maddy + ]; + }; +} diff --git a/nixos/modules/services/mail/mail.nix b/nixos/modules/services/mail/mail.nix new file mode 100644 index 00000000000..fcc7ff6db91 --- /dev/null +++ b/nixos/modules/services/mail/mail.nix @@ -0,0 +1,34 @@ +{ config, options, lib, ... }: + +with lib; + +{ + + ###### interface + + options = { + + services.mail = { + + sendmailSetuidWrapper = mkOption { + type = types.nullOr options.security.wrappers.type.nestedTypes.elemType; + default = null; + internal = true; + description = '' + Configuration for the sendmail setuid wapper. + ''; + }; + + }; + + }; + + ###### implementation + + config = mkIf (config.services.mail.sendmailSetuidWrapper != null) { + + security.wrappers.sendmail = config.services.mail.sendmailSetuidWrapper; + + }; + +} diff --git a/nixos/modules/services/mail/mailcatcher.nix b/nixos/modules/services/mail/mailcatcher.nix new file mode 100644 index 00000000000..84f06ed199d --- /dev/null +++ b/nixos/modules/services/mail/mailcatcher.nix @@ -0,0 +1,68 @@ +{ config, pkgs, lib, ... }: + +let + cfg = config.services.mailcatcher; + + inherit (lib) mkEnableOption mkIf mkOption types optionalString; +in +{ + # interface + + options = { + + services.mailcatcher = { + enable = mkEnableOption "MailCatcher"; + + http.ip = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "The ip address of the http server."; + }; + + http.port = mkOption { + type = types.port; + default = 1080; + description = "The port address of the http server."; + }; + + http.path = mkOption { + type = with types; nullOr str; + default = null; + description = "Prefix to all HTTP paths."; + example = "/mailcatcher"; + }; + + smtp.ip = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "The ip address of the smtp server."; + }; + + smtp.port = mkOption { + type = types.port; + default = 1025; + description = "The port address of the smtp server."; + }; + }; + + }; + + # implementation + + config = mkIf cfg.enable { + environment.systemPackages = [ pkgs.mailcatcher ]; + + systemd.services.mailcatcher = { + description = "MailCatcher Service"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + DynamicUser = true; + Restart = "always"; + ExecStart = "${pkgs.mailcatcher}/bin/mailcatcher --foreground --no-quit --http-ip ${cfg.http.ip} --http-port ${toString cfg.http.port} --smtp-ip ${cfg.smtp.ip} --smtp-port ${toString cfg.smtp.port}" + optionalString (cfg.http.path != null) " --http-path ${cfg.http.path}"; + AmbientCapabilities = optionalString (cfg.http.port < 1024 || cfg.smtp.port < 1024) "cap_net_bind_service"; + }; + }; + }; +} diff --git a/nixos/modules/services/mail/mailhog.nix b/nixos/modules/services/mail/mailhog.nix new file mode 100644 index 00000000000..b113f4ff3de --- /dev/null +++ b/nixos/modules/services/mail/mailhog.nix @@ -0,0 +1,82 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.mailhog; + + args = lib.concatStringsSep " " ( + [ + "-api-bind-addr :${toString cfg.apiPort}" + "-smtp-bind-addr :${toString cfg.smtpPort}" + "-ui-bind-addr :${toString cfg.uiPort}" + "-storage ${cfg.storage}" + ] ++ lib.optional (cfg.storage == "maildir") + "-maildir-path $STATE_DIRECTORY" + ++ cfg.extraArgs + ); + +in +{ + ###### interface + + imports = [ + (mkRemovedOptionModule [ "services" "mailhog" "user" ] "") + ]; + + options = { + + services.mailhog = { + enable = mkEnableOption "MailHog"; + + storage = mkOption { + type = types.enum [ "maildir" "memory" ]; + default = "memory"; + description = "Store mails on disk or in memory."; + }; + + apiPort = mkOption { + type = types.port; + default = 8025; + description = "Port on which the API endpoint will listen."; + }; + + smtpPort = mkOption { + type = types.port; + default = 1025; + description = "Port on which the SMTP endpoint will listen."; + }; + + uiPort = mkOption { + type = types.port; + default = 8025; + description = "Port on which the HTTP UI will listen."; + }; + + extraArgs = mkOption { + type = types.listOf types.str; + default = []; + description = "List of additional arguments to pass to the MailHog process."; + }; + }; + }; + + + ###### implementation + + config = mkIf cfg.enable { + + systemd.services.mailhog = { + description = "MailHog - Web and API based SMTP testing"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "exec"; + ExecStart = "${pkgs.mailhog}/bin/MailHog ${args}"; + DynamicUser = true; + Restart = "on-failure"; + StateDirectory = "mailhog"; + }; + }; + }; +} diff --git a/nixos/modules/services/mail/mailman.nix b/nixos/modules/services/mail/mailman.nix new file mode 100644 index 00000000000..0c9b38b44b2 --- /dev/null +++ b/nixos/modules/services/mail/mailman.nix @@ -0,0 +1,462 @@ +{ config, pkgs, lib, ... }: # mailman.nix + +with lib; + +let + + cfg = config.services.mailman; + + pythonEnv = pkgs.python3.withPackages (ps: + [ps.mailman ps.mailman-web] + ++ lib.optional cfg.hyperkitty.enable ps.mailman-hyperkitty + ++ cfg.extraPythonPackages); + + # This deliberately doesn't use recursiveUpdate so users can + # override the defaults. + webSettings = { + DEFAULT_FROM_EMAIL = cfg.siteOwner; + SERVER_EMAIL = cfg.siteOwner; + ALLOWED_HOSTS = [ "localhost" "127.0.0.1" ] ++ cfg.webHosts; + COMPRESS_OFFLINE = true; + STATIC_ROOT = "/var/lib/mailman-web-static"; + MEDIA_ROOT = "/var/lib/mailman-web/media"; + LOGGING = { + version = 1; + disable_existing_loggers = true; + handlers.console.class = "logging.StreamHandler"; + loggers.django = { + handlers = [ "console" ]; + level = "INFO"; + }; + }; + HAYSTACK_CONNECTIONS.default = { + ENGINE = "haystack.backends.whoosh_backend.WhooshEngine"; + PATH = "/var/lib/mailman-web/fulltext-index"; + }; + } // cfg.webSettings; + + webSettingsJSON = pkgs.writeText "settings.json" (builtins.toJSON webSettings); + + # TODO: Should this be RFC42-ised so that users can set additional options without modifying the module? + postfixMtaConfig = pkgs.writeText "mailman-postfix.cfg" '' + [postfix] + postmap_command: ${pkgs.postfix}/bin/postmap + transport_file_type: hash + ''; + + mailmanCfg = lib.generators.toINI {} cfg.settings; + + mailmanHyperkittyCfg = pkgs.writeText "mailman-hyperkitty.cfg" '' + [general] + # This is your HyperKitty installation, preferably on the localhost. This + # address will be used by Mailman to forward incoming emails to HyperKitty + # for archiving. It does not need to be publicly available, in fact it's + # better if it is not. + base_url: ${cfg.hyperkitty.baseUrl} + + # Shared API key, must be the identical to the value in HyperKitty's + # settings. + api_key: @API_KEY@ + ''; + +in { + + ###### interface + + imports = [ + (mkRenamedOptionModule [ "services" "mailman" "hyperkittyBaseUrl" ] + [ "services" "mailman" "hyperkitty" "baseUrl" ]) + + (mkRemovedOptionModule [ "services" "mailman" "hyperkittyApiKey" ] '' + The Hyperkitty API key is now generated on first run, and not + stored in the world-readable Nix store. To continue using + Hyperkitty, you must set services.mailman.hyperkitty.enable = true. + '') + ]; + + options = { + + services.mailman = { + + enable = mkOption { + type = types.bool; + default = false; + description = "Enable Mailman on this host. Requires an active MTA on the host (e.g. Postfix)."; + }; + + package = mkOption { + type = types.package; + default = pkgs.mailman; + defaultText = literalExpression "pkgs.mailman"; + example = literalExpression "pkgs.mailman.override { archivers = []; }"; + description = "Mailman package to use"; + }; + + enablePostfix = mkOption { + type = types.bool; + default = true; + example = false; + description = '' + Enable Postfix integration. Requires an active Postfix installation. + + If you want to use another MTA, set this option to false and configure + settings in services.mailman.settings.mta. + + Refer to the Mailman manual for more info. + ''; + }; + + siteOwner = mkOption { + type = types.str; + example = "postmaster@example.org"; + description = '' + Certain messages that must be delivered to a human, but which can't + be delivered to a list owner (e.g. a bounce from a list owner), will + be sent to this address. It should point to a human. + ''; + }; + + webHosts = mkOption { + type = types.listOf types.str; + default = []; + description = '' + The list of hostnames and/or IP addresses from which the Mailman Web + UI will accept requests. By default, "localhost" and "127.0.0.1" are + enabled. All additional names under which your web server accepts + requests for the UI must be listed here or incoming requests will be + rejected. + ''; + }; + + webUser = mkOption { + type = types.str; + default = "mailman-web"; + description = '' + User to run mailman-web as + ''; + }; + + webSettings = mkOption { + type = types.attrs; + default = {}; + description = '' + Overrides for the default mailman-web Django settings. + ''; + }; + + serve = { + enable = mkEnableOption "Automatic nginx and uwsgi setup for mailman-web"; + }; + + extraPythonPackages = mkOption { + description = "Packages to add to the python environment used by mailman and mailman-web"; + type = types.listOf types.package; + default = []; + }; + + settings = mkOption { + description = "Settings for mailman.cfg"; + type = types.attrsOf (types.attrsOf types.str); + default = {}; + }; + + hyperkitty = { + enable = mkEnableOption "the Hyperkitty archiver for Mailman"; + + baseUrl = mkOption { + type = types.str; + default = "http://localhost:18507/archives/"; + description = '' + Where can Mailman connect to Hyperkitty's internal API, preferably on + localhost? + ''; + }; + }; + + }; + }; + + ###### implementation + + config = mkIf cfg.enable { + + services.mailman.settings = { + mailman.site_owner = lib.mkDefault cfg.siteOwner; + mailman.layout = "fhs"; + + "paths.fhs" = { + bin_dir = "${pkgs.python3Packages.mailman}/bin"; + var_dir = "/var/lib/mailman"; + queue_dir = "$var_dir/queue"; + template_dir = "$var_dir/templates"; + log_dir = "/var/log/mailman"; + lock_dir = "$var_dir/lock"; + etc_dir = "/etc"; + ext_dir = "$etc_dir/mailman.d"; + pid_file = "/run/mailman/master.pid"; + }; + + mta.configuration = lib.mkDefault (if cfg.enablePostfix then "${postfixMtaConfig}" else throw "When Mailman Postfix integration is disabled, set `services.mailman.settings.mta.configuration` to the path of the config file required to integrate with your MTA."); + + "archiver.hyperkitty" = lib.mkIf cfg.hyperkitty.enable { + class = "mailman_hyperkitty.Archiver"; + enable = "yes"; + configuration = "/var/lib/mailman/mailman-hyperkitty.cfg"; + }; + } // (let + loggerNames = ["root" "archiver" "bounce" "config" "database" "debug" "error" "fromusenet" "http" "locks" "mischief" "plugins" "runner" "smtp"]; + loggerSectionNames = map (n: "logging.${n}") loggerNames; + in lib.genAttrs loggerSectionNames(name: { handler = "stderr"; }) + ); + + assertions = let + inherit (config.services) postfix; + + requirePostfixHash = optionPath: dataFile: + with lib; + let + expected = "hash:/var/lib/mailman/data/${dataFile}"; + value = attrByPath optionPath [] postfix; + in + { assertion = postfix.enable -> isList value && elem expected value; + message = '' + services.postfix.${concatStringsSep "." optionPath} must contain + "${expected}". + See <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>. + ''; + }; + in (lib.optionals cfg.enablePostfix [ + { assertion = postfix.enable; + message = '' + Mailman's default NixOS configuration requires Postfix to be enabled. + + If you want to use another MTA, set services.mailman.enablePostfix + to false and configure settings in services.mailman.settings.mta. + + Refer to <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html> + for more info. + ''; + } + (requirePostfixHash [ "relayDomains" ] "postfix_domains") + (requirePostfixHash [ "config" "transport_maps" ] "postfix_lmtp") + (requirePostfixHash [ "config" "local_recipient_maps" ] "postfix_lmtp") + ]); + + users.users.mailman = { + description = "GNU Mailman"; + isSystemUser = true; + group = "mailman"; + }; + users.users.mailman-web = lib.mkIf (cfg.webUser == "mailman-web") { + description = "GNU Mailman web interface"; + isSystemUser = true; + group = "mailman"; + }; + users.groups.mailman = {}; + + environment.etc."mailman.cfg".text = mailmanCfg; + + environment.etc."mailman3/settings.py".text = '' + import os + + # Required by mailman_web.settings, but will be overridden when + # settings_local.json is loaded. + os.environ["SECRET_KEY"] = "" + + from mailman_web.settings.base import * + from mailman_web.settings.mailman import * + + import json + + with open('${webSettingsJSON}') as f: + globals().update(json.load(f)) + + with open('/var/lib/mailman-web/settings_local.json') as f: + globals().update(json.load(f)) + ''; + + services.nginx = mkIf cfg.serve.enable { + enable = mkDefault true; + virtualHosts."${lib.head cfg.webHosts}" = { + serverAliases = cfg.webHosts; + locations = { + "/".extraConfig = "uwsgi_pass unix:/run/mailman-web.socket;"; + "/static/".alias = webSettings.STATIC_ROOT + "/"; + }; + }; + }; + + environment.systemPackages = [ (pkgs.buildEnv { + name = "mailman-tools"; + # We don't want to pollute the system PATH with a python + # interpreter etc. so let's pick only the stuff we actually + # want from pythonEnv + pathsToLink = ["/bin"]; + paths = [pythonEnv]; + postBuild = '' + find $out/bin/ -mindepth 1 -not -name "mailman*" -delete + ''; + }) ]; + + services.postfix = lib.mkIf cfg.enablePostfix { + recipientDelimiter = "+"; # bake recipient addresses in mail envelopes via VERP + config = { + owner_request_special = "no"; # Mailman handles -owner addresses on its own + }; + }; + + systemd.sockets.mailman-uwsgi = lib.mkIf cfg.serve.enable { + wantedBy = ["sockets.target"]; + before = ["nginx.service"]; + socketConfig.ListenStream = "/run/mailman-web.socket"; + }; + systemd.services = { + mailman = { + description = "GNU Mailman Master Process"; + after = [ "network.target" ]; + restartTriggers = [ config.environment.etc."mailman.cfg".source ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${pythonEnv}/bin/mailman start"; + ExecStop = "${pythonEnv}/bin/mailman stop"; + User = "mailman"; + Group = "mailman"; + Type = "forking"; + RuntimeDirectory = "mailman"; + LogsDirectory = "mailman"; + PIDFile = "/run/mailman/master.pid"; + }; + }; + + mailman-settings = { + description = "Generate settings files (including secrets) for Mailman"; + before = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ]; + requiredBy = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ]; + path = with pkgs; [ jq ]; + serviceConfig.Type = "oneshot"; + script = '' + mailmanDir=/var/lib/mailman + mailmanWebDir=/var/lib/mailman-web + + mailmanCfg=$mailmanDir/mailman-hyperkitty.cfg + mailmanWebCfg=$mailmanWebDir/settings_local.json + + install -m 0775 -o mailman -g mailman -d /var/lib/mailman-web-static + install -m 0770 -o mailman -g mailman -d $mailmanDir + install -m 0770 -o ${cfg.webUser} -g mailman -d $mailmanWebDir + + if [ ! -e $mailmanWebCfg ]; then + hyperkittyApiKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64) + secretKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64) + + mailmanWebCfgTmp=$(mktemp) + jq -n '.MAILMAN_ARCHIVER_KEY=$archiver_key | .SECRET_KEY=$secret_key' \ + --arg archiver_key "$hyperkittyApiKey" \ + --arg secret_key "$secretKey" \ + >"$mailmanWebCfgTmp" + chown root:mailman "$mailmanWebCfgTmp" + chmod 440 "$mailmanWebCfgTmp" + mv -n "$mailmanWebCfgTmp" "$mailmanWebCfg" + fi + + hyperkittyApiKey="$(jq -r .MAILMAN_ARCHIVER_KEY "$mailmanWebCfg")" + mailmanCfgTmp=$(mktemp) + sed "s/@API_KEY@/$hyperkittyApiKey/g" ${mailmanHyperkittyCfg} >"$mailmanCfgTmp" + chown mailman:mailman "$mailmanCfgTmp" + mv "$mailmanCfgTmp" "$mailmanCfg" + ''; + }; + + mailman-web-setup = { + description = "Prepare mailman-web files and database"; + before = [ "mailman-uwsgi.service" ]; + requiredBy = [ "mailman-uwsgi.service" ]; + restartTriggers = [ config.environment.etc."mailman3/settings.py".source ]; + script = '' + [[ -e "${webSettings.STATIC_ROOT}" ]] && find "${webSettings.STATIC_ROOT}/" -mindepth 1 -delete + ${pythonEnv}/bin/mailman-web migrate + ${pythonEnv}/bin/mailman-web collectstatic + ${pythonEnv}/bin/mailman-web compress + ''; + serviceConfig = { + User = cfg.webUser; + Group = "mailman"; + Type = "oneshot"; + WorkingDirectory = "/var/lib/mailman-web"; + }; + }; + + mailman-uwsgi = mkIf cfg.serve.enable (let + uwsgiConfig.uwsgi = { + type = "normal"; + plugins = ["python3"]; + home = pythonEnv; + module = "mailman_web.wsgi"; + http = "127.0.0.1:18507"; + }; + uwsgiConfigFile = pkgs.writeText "uwsgi-mailman.json" (builtins.toJSON uwsgiConfig); + in { + wantedBy = ["multi-user.target"]; + requires = ["mailman-uwsgi.socket" "mailman-web-setup.service"]; + restartTriggers = [ config.environment.etc."mailman3/settings.py".source ]; + serviceConfig = { + # Since the mailman-web settings.py obstinately creates a logs + # dir in the cwd, change to the (writable) runtime directory before + # starting uwsgi. + ExecStart = "${pkgs.coreutils}/bin/env -C $RUNTIME_DIRECTORY ${pkgs.uwsgi.override { plugins = ["python3"]; }}/bin/uwsgi --json ${uwsgiConfigFile}"; + User = cfg.webUser; + Group = "mailman"; + RuntimeDirectory = "mailman-uwsgi"; + }; + }); + + mailman-daily = { + description = "Trigger daily Mailman events"; + startAt = "daily"; + restartTriggers = [ config.environment.etc."mailman.cfg".source ]; + serviceConfig = { + ExecStart = "${pythonEnv}/bin/mailman digests --send"; + User = "mailman"; + Group = "mailman"; + }; + }; + + hyperkitty = lib.mkIf cfg.hyperkitty.enable { + description = "GNU Hyperkitty QCluster Process"; + after = [ "network.target" ]; + restartTriggers = [ config.environment.etc."mailman3/settings.py".source ]; + wantedBy = [ "mailman.service" "multi-user.target" ]; + serviceConfig = { + ExecStart = "${pythonEnv}/bin/mailman-web qcluster"; + User = cfg.webUser; + Group = "mailman"; + WorkingDirectory = "/var/lib/mailman-web"; + }; + }; + } // flip lib.mapAttrs' { + "minutely" = "minutely"; + "quarter_hourly" = "*:00/15"; + "hourly" = "hourly"; + "daily" = "daily"; + "weekly" = "weekly"; + "yearly" = "yearly"; + } (name: startAt: + lib.nameValuePair "hyperkitty-${name}" (lib.mkIf cfg.hyperkitty.enable { + description = "Trigger ${name} Hyperkitty events"; + inherit startAt; + restartTriggers = [ config.environment.etc."mailman3/settings.py".source ]; + serviceConfig = { + ExecStart = "${pythonEnv}/bin/mailman-web runjobs ${name}"; + User = cfg.webUser; + Group = "mailman"; + WorkingDirectory = "/var/lib/mailman-web"; + }; + })); + }; + + meta = { + maintainers = with lib.maintainers; [ lheckemann qyliss ]; + doc = ./mailman.xml; + }; + +} diff --git a/nixos/modules/services/mail/mailman.xml b/nixos/modules/services/mail/mailman.xml new file mode 100644 index 00000000000..27247fb064f --- /dev/null +++ b/nixos/modules/services/mail/mailman.xml @@ -0,0 +1,94 @@ +<chapter xmlns="http://docbook.org/ns/docbook" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:xi="http://www.w3.org/2001/XInclude" + version="5.0" + xml:id="module-services-mailman"> + <title>Mailman</title> + <para> + <link xlink:href="https://www.list.org">Mailman</link> is free + software for managing electronic mail discussion and e-newsletter + lists. Mailman and its web interface can be configured using the + corresponding NixOS module. Note that this service is best used with + an existing, securely configured Postfix setup, as it does not automatically configure this. + </para> + + <section xml:id="module-services-mailman-basic-usage"> + <title>Basic usage with Postfix</title> + <para> + For a basic configuration with Postfix as the MTA, the following settings are suggested: + <programlisting>{ config, ... }: { + services.postfix = { + enable = true; + relayDomains = ["hash:/var/lib/mailman/data/postfix_domains"]; + sslCert = config.security.acme.certs."lists.example.org".directory + "/full.pem"; + sslKey = config.security.acme.certs."lists.example.org".directory + "/key.pem"; + config = { + transport_maps = ["hash:/var/lib/mailman/data/postfix_lmtp"]; + local_recipient_maps = ["hash:/var/lib/mailman/data/postfix_lmtp"]; + }; + }; + services.mailman = { + <link linkend="opt-services.mailman.enable">enable</link> = true; + <link linkend="opt-services.mailman.serve.enable">serve.enable</link> = true; + <link linkend="opt-services.mailman.hyperkitty.enable">hyperkitty.enable</link> = true; + <link linkend="opt-services.mailman.webHosts">webHosts</link> = ["lists.example.org"]; + <link linkend="opt-services.mailman.siteOwner">siteOwner</link> = "mailman@example.org"; + }; + <link linkend="opt-services.nginx.virtualHosts._name_.enableACME">services.nginx.virtualHosts."lists.example.org".enableACME</link> = true; + <link linkend="opt-networking.firewall.allowedTCPPorts">networking.firewall.allowedTCPPorts</link> = [ 25 80 443 ]; +}</programlisting> + </para> + <para> + DNS records will also be required: + <itemizedlist> + <listitem><para><literal>AAAA</literal> and <literal>A</literal> records pointing to the host in question, in order for browsers to be able to discover the address of the web server;</para></listitem> + <listitem><para>An <literal>MX</literal> record pointing to a domain name at which the host is reachable, in order for other mail servers to be able to deliver emails to the mailing lists it hosts.</para></listitem> + </itemizedlist> + </para> + <para> + After this has been done and appropriate DNS records have been + set up, the Postorius mailing list manager and the Hyperkitty + archive browser will be available at + https://lists.example.org/. Note that this setup is not + sufficient to deliver emails to most email providers nor to + avoid spam -- a number of additional measures for authenticating + incoming and outgoing mails, such as SPF, DMARC and DKIM are + necessary, but outside the scope of the Mailman module. + </para> + </section> + <section xml:id="module-services-mailman-other-mtas"> + <title>Using with other MTAs</title> + <para> + Mailman also supports other MTA, though with a little bit more configuration. For example, to use Mailman with Exim, you can use the following settings: + <programlisting>{ config, ... }: { + services = { + mailman = { + enable = true; + siteOwner = "mailman@example.org"; + <link linkend="opt-services.mailman.enablePostfix">enablePostfix</link> = false; + settings.mta = { + incoming = "mailman.mta.exim4.LMTP"; + outgoing = "mailman.mta.deliver.deliver"; + lmtp_host = "localhost"; + lmtp_port = "8024"; + smtp_host = "localhost"; + smtp_port = "25"; + configuration = "python:mailman.config.exim4"; + }; + }; + exim = { + enable = true; + # You can configure Exim in a separate file to reduce configuration.nix clutter + config = builtins.readFile ./exim.conf; + }; + }; +}</programlisting> + </para> + <para> + The exim config needs some special additions to work with Mailman. Currently + NixOS can't manage Exim config with such granularity. Please refer to + <link xlink:href="https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html">Mailman documentation</link> + for more info on configuring Mailman for working with Exim. + </para> + </section> +</chapter> diff --git a/nixos/modules/services/mail/mlmmj.nix b/nixos/modules/services/mail/mlmmj.nix new file mode 100644 index 00000000000..fd74f2dc5f0 --- /dev/null +++ b/nixos/modules/services/mail/mlmmj.nix @@ -0,0 +1,171 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + concatMapLines = f: l: lib.concatStringsSep "\n" (map f l); + + cfg = config.services.mlmmj; + stateDir = "/var/lib/mlmmj"; + spoolDir = "/var/spool/mlmmj"; + listDir = domain: list: "${spoolDir}/${domain}/${list}"; + listCtl = domain: list: "${listDir domain list}/control"; + transport = domain: list: "${domain}--${list}@local.list.mlmmj mlmmj:${domain}/${list}"; + virtual = domain: list: "${list}@${domain} ${domain}--${list}@local.list.mlmmj"; + alias = domain: list: "${list}: \"|${pkgs.mlmmj}/bin/mlmmj-receive -L ${listDir domain list}/\""; + subjectPrefix = list: "[${list}]"; + listAddress = domain: list: "${list}@${domain}"; + customHeaders = domain: list: [ + "List-Id: ${list}" + "Reply-To: ${list}@${domain}" + "List-Post: <mailto:${list}@${domain}>" + "List-Help: <mailto:${list}+help@${domain}>" + "List-Subscribe: <mailto:${list}+subscribe@${domain}>" + "List-Unsubscribe: <mailto:${list}+unsubscribe@${domain}>" + ]; + footer = domain: list: "To unsubscribe send a mail to ${list}+unsubscribe@${domain}"; + createList = d: l: + let ctlDir = listCtl d l; in + '' + for DIR in incoming queue queue/discarded archive text subconf unsubconf \ + bounce control moderation subscribers.d digesters.d requeue \ + nomailsubs.d + do + mkdir -p '${listDir d l}'/"$DIR" + done + ${pkgs.coreutils}/bin/mkdir -p ${ctlDir} + echo ${listAddress d l} > '${ctlDir}/listaddress' + [ ! -e ${ctlDir}/customheaders ] && \ + echo "${lib.concatStringsSep "\n" (customHeaders d l)}" > '${ctlDir}/customheaders' + [ ! -e ${ctlDir}/footer ] && \ + echo ${footer d l} > '${ctlDir}/footer' + [ ! -e ${ctlDir}/prefix ] && \ + echo ${subjectPrefix l} > '${ctlDir}/prefix' + ''; +in + +{ + + ###### interface + + options = { + + services.mlmmj = { + + enable = mkOption { + type = types.bool; + default = false; + description = "Enable mlmmj"; + }; + + user = mkOption { + type = types.str; + default = "mlmmj"; + description = "mailinglist local user"; + }; + + group = mkOption { + type = types.str; + default = "mlmmj"; + description = "mailinglist local group"; + }; + + listDomain = mkOption { + type = types.str; + default = "localhost"; + description = "Set the mailing list domain"; + }; + + mailLists = mkOption { + type = types.listOf types.str; + default = []; + description = "The collection of hosted maillists"; + }; + + maintInterval = mkOption { + type = types.str; + default = "20min"; + description = '' + Time interval between mlmmj-maintd runs, see + <citerefentry><refentrytitle>systemd.time</refentrytitle> + <manvolnum>7</manvolnum></citerefentry> for format information. + ''; + }; + + }; + + }; + + ###### implementation + + config = mkIf cfg.enable { + + users.users.${cfg.user} = { + description = "mlmmj user"; + home = stateDir; + createHome = true; + uid = config.ids.uids.mlmmj; + group = cfg.group; + useDefaultShell = true; + }; + + users.groups.${cfg.group} = { + gid = config.ids.gids.mlmmj; + }; + + services.postfix = { + enable = true; + recipientDelimiter= "+"; + masterConfig.mlmmj = { + type = "unix"; + private = true; + privileged = true; + chroot = false; + wakeup = 0; + command = "pipe"; + args = [ + "flags=ORhu" + "user=mlmmj" + "argv=${pkgs.mlmmj}/bin/mlmmj-receive" + "-F" + "-L" + "${spoolDir}/$nexthop" + ]; + }; + + extraAliases = concatMapLines (alias cfg.listDomain) cfg.mailLists; + + extraConfig = "propagate_unmatched_extensions = virtual"; + + virtual = concatMapLines (virtual cfg.listDomain) cfg.mailLists; + transport = concatMapLines (transport cfg.listDomain) cfg.mailLists; + }; + + environment.systemPackages = [ pkgs.mlmmj ]; + + system.activationScripts.mlmmj = '' + ${pkgs.coreutils}/bin/mkdir -p ${stateDir} ${spoolDir}/${cfg.listDomain} + ${pkgs.coreutils}/bin/chown -R ${cfg.user}:${cfg.group} ${spoolDir} + ${concatMapLines (createList cfg.listDomain) cfg.mailLists} + ${pkgs.postfix}/bin/postmap /etc/postfix/virtual + ${pkgs.postfix}/bin/postmap /etc/postfix/transport + ''; + + systemd.services.mlmmj-maintd = { + description = "mlmmj maintenance daemon"; + serviceConfig = { + User = cfg.user; + Group = cfg.group; + ExecStart = "${pkgs.mlmmj}/bin/mlmmj-maintd -F -d ${spoolDir}/${cfg.listDomain}"; + }; + }; + + systemd.timers.mlmmj-maintd = { + description = "mlmmj maintenance timer"; + timerConfig.OnUnitActiveSec = cfg.maintInterval; + wantedBy = [ "timers.target" ]; + }; + }; + +} diff --git a/nixos/modules/services/mail/nullmailer.nix b/nixos/modules/services/mail/nullmailer.nix new file mode 100644 index 00000000000..f9c34566997 --- /dev/null +++ b/nixos/modules/services/mail/nullmailer.nix @@ -0,0 +1,244 @@ +{ config, lib, pkgs, ... }: + +with lib; + +{ + + options = { + + services.nullmailer = { + enable = mkOption { + type = types.bool; + default = false; + description = "Whether to enable nullmailer daemon."; + }; + + user = mkOption { + type = types.str; + default = "nullmailer"; + description = '' + User to use to run nullmailer-send. + ''; + }; + + group = mkOption { + type = types.str; + default = "nullmailer"; + description = '' + Group to use to run nullmailer-send. + ''; + }; + + setSendmail = mkOption { + type = types.bool; + default = true; + description = "Whether to set the system sendmail to nullmailer's."; + }; + + remotesFile = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Path to the <code>remotes</code> control file. This file contains a + list of remote servers to which to send each message. + + See <code>man 8 nullmailer-send</code> for syntax and available + options. + ''; + }; + + config = { + adminaddr = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + If set, all recipients to users at either "localhost" (the literal string) + or the canonical host name (from the me control attribute) are remapped to this address. + This is provided to allow local daemons to be able to send email to + "somebody@localhost" and have it go somewhere sensible instead of being bounced + by your relay host. To send to multiple addresses, + put them all on one line separated by a comma. + ''; + }; + + allmailfrom = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + If set, content will override the envelope sender on all messages. + ''; + }; + + defaultdomain = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The content of this attribute is appended to any host name that + does not contain a period (except localhost), including defaulthost + and idhost. Defaults to the value of the me attribute, if it exists, + otherwise the literal name defauldomain. + ''; + }; + + defaulthost = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The content of this attribute is appended to any address that + is missing a host name. Defaults to the value of the me control + attribute, if it exists, otherwise the literal name defaulthost. + ''; + }; + + doublebounceto = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + If the original sender was empty (the original message was a + delivery status or disposition notification), the double bounce + is sent to the address in this attribute. + ''; + }; + + helohost = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Sets the environment variable $HELOHOST which is used by the + SMTP protocol module to set the parameter given to the HELO command. + Defaults to the value of the me configuration attribute. + ''; + }; + + idhost = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The content of this attribute is used when building the message-id + string for the message. Defaults to the canonicalized value of defaulthost. + ''; + }; + + maxpause = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The maximum time to pause between successive queue runs, in seconds. + Defaults to 24 hours (86400). + ''; + }; + + me = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The fully-qualifiled host name of the computer running nullmailer. + Defaults to the literal name me. + ''; + }; + + pausetime = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The minimum time to pause between successive queue runs when there + are messages in the queue, in seconds. Defaults to 1 minute (60). + Each time this timeout is reached, the timeout is doubled to a + maximum of maxpause. After new messages are injected, the timeout + is reset. If this is set to 0, nullmailer-send will exit + immediately after going through the queue once (one-shot mode). + ''; + }; + + remotes = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + A list of remote servers to which to send each message. Each line + contains a remote host name or address followed by an optional + protocol string, separated by white space. + + See <code>man 8 nullmailer-send</code> for syntax and available + options. + + WARNING: This is stored world-readable in the nix store. If you need + to specify any secret credentials here, consider using the + <code>remotesFile</code> option instead. + ''; + }; + + sendtimeout = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The time to wait for a remote module listed above to complete sending + a message before killing it and trying again, in seconds. + Defaults to 1 hour (3600). If this is set to 0, nullmailer-send + will wait forever for messages to complete sending. + ''; + }; + }; + }; + }; + + config = let + cfg = config.services.nullmailer; + in mkIf cfg.enable { + + assertions = [ + { assertion = cfg.config.remotes == null || cfg.remotesFile == null; + message = "Only one of `remotesFile` or `config.remotes` may be used at a time."; + } + ]; + + environment = { + systemPackages = [ pkgs.nullmailer ]; + etc = let + validAttrs = filterAttrs (name: value: value != null) cfg.config; + in + (foldl' (as: name: as // { "nullmailer/${name}".text = validAttrs.${name}; }) {} (attrNames validAttrs)) + // optionalAttrs (cfg.remotesFile != null) { "nullmailer/remotes".source = cfg.remotesFile; }; + }; + + users = { + users.${cfg.user} = { + description = "Nullmailer relay-only mta user"; + group = cfg.group; + isSystemUser = true; + }; + + groups.${cfg.group} = { }; + }; + + systemd.tmpfiles.rules = [ + "d /var/spool/nullmailer - ${cfg.user} - - -" + ]; + + systemd.services.nullmailer = { + description = "nullmailer"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + preStart = '' + mkdir -p /var/spool/nullmailer/{queue,tmp,failed} + rm -f /var/spool/nullmailer/trigger && mkfifo -m 660 /var/spool/nullmailer/trigger + ''; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + ExecStart = "${pkgs.nullmailer}/bin/nullmailer-send"; + Restart = "always"; + }; + }; + + services.mail.sendmailSetuidWrapper = mkIf cfg.setSendmail { + program = "sendmail"; + source = "${pkgs.nullmailer}/bin/sendmail"; + owner = cfg.user; + group = cfg.group; + setuid = true; + setgid = true; + }; + }; +} diff --git a/nixos/modules/services/mail/offlineimap.nix b/nixos/modules/services/mail/offlineimap.nix new file mode 100644 index 00000000000..45147758119 --- /dev/null +++ b/nixos/modules/services/mail/offlineimap.nix @@ -0,0 +1,72 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.offlineimap; +in { + + options.services.offlineimap = { + enable = mkEnableOption "OfflineIMAP, a software to dispose your mailbox(es) as a local Maildir(s)"; + + install = mkOption { + type = types.bool; + default = false; + description = '' + Whether to install a user service for Offlineimap. Once + the service is started, emails will be fetched automatically. + + The service must be manually started for each user with + "systemctl --user start offlineimap" or globally through + <varname>services.offlineimap.enable</varname>. + ''; + }; + + package = mkOption { + type = types.package; + default = pkgs.offlineimap; + defaultText = literalExpression "pkgs.offlineimap"; + description = "Offlineimap derivation to use."; + }; + + path = mkOption { + type = types.listOf types.path; + default = []; + example = literalExpression "[ pkgs.pass pkgs.bash pkgs.notmuch ]"; + description = "List of derivations to put in Offlineimap's path."; + }; + + onCalendar = mkOption { + type = types.str; + default = "*:0/3"; # every 3 minutes + description = "How often is offlineimap started. Default is '*:0/3' meaning every 3 minutes. See systemd.time(7) for more information about the format."; + }; + + timeoutStartSec = mkOption { + type = types.str; + default = "120sec"; # Kill if still alive after 2 minutes + description = "How long waiting for offlineimap before killing it. Default is '120sec' meaning every 2 minutes. See systemd.time(7) for more information about the format."; + }; + }; + config = mkIf (cfg.enable || cfg.install) { + systemd.user.services.offlineimap = { + description = "Offlineimap: a software to dispose your mailbox(es) as a local Maildir(s)"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${cfg.package}/bin/offlineimap -u syslog -o -1"; + TimeoutStartSec = cfg.timeoutStartSec; + }; + path = cfg.path; + }; + environment.systemPackages = [ cfg.package ]; + systemd.user.timers.offlineimap = { + description = "offlineimap timer"; + timerConfig = { + Unit = "offlineimap.service"; + OnCalendar = cfg.onCalendar; + # start immediately after computer is started: + Persistent = "true"; + }; + } // optionalAttrs cfg.enable { wantedBy = [ "default.target" ]; }; + }; +} diff --git a/nixos/modules/services/mail/opendkim.nix b/nixos/modules/services/mail/opendkim.nix new file mode 100644 index 00000000000..f1ffc5d3aee --- /dev/null +++ b/nixos/modules/services/mail/opendkim.nix @@ -0,0 +1,167 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.opendkim; + + defaultSock = "local:/run/opendkim/opendkim.sock"; + + keyFile = "${cfg.keyPath}/${cfg.selector}.private"; + + args = [ "-f" "-l" + "-p" cfg.socket + "-d" cfg.domains + "-k" keyFile + "-s" cfg.selector + ] ++ optionals (cfg.configFile != null) [ "-x" cfg.configFile ]; + +in { + imports = [ + (mkRenamedOptionModule [ "services" "opendkim" "keyFile" ] [ "services" "opendkim" "keyPath" ]) + ]; + + ###### interface + + options = { + + services.opendkim = { + + enable = mkOption { + type = types.bool; + default = false; + description = "Whether to enable the OpenDKIM sender authentication system."; + }; + + socket = mkOption { + type = types.str; + default = defaultSock; + description = "Socket which is used for communication with OpenDKIM."; + }; + + user = mkOption { + type = types.str; + default = "opendkim"; + description = "User for the daemon."; + }; + + group = mkOption { + type = types.str; + default = "opendkim"; + description = "Group for the daemon."; + }; + + domains = mkOption { + type = types.str; + default = "csl:${config.networking.hostName}"; + defaultText = literalExpression ''"csl:''${config.networking.hostName}"''; + example = "csl:example.com,mydomain.net"; + description = '' + Local domains set (see <literal>opendkim(8)</literal> for more information on datasets). + Messages from them are signed, not verified. + ''; + }; + + keyPath = mkOption { + type = types.path; + description = '' + The path that opendkim should put its generated private keys into. + The DNS settings will be found in this directory with the name selector.txt. + ''; + default = "/var/lib/opendkim/keys"; + }; + + selector = mkOption { + type = types.str; + description = "Selector to use when signing."; + }; + + configFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Additional opendkim configuration."; + }; + + }; + + }; + + + ###### implementation + + config = mkIf cfg.enable { + + users.users = optionalAttrs (cfg.user == "opendkim") { + opendkim = { + group = cfg.group; + uid = config.ids.uids.opendkim; + }; + }; + + users.groups = optionalAttrs (cfg.group == "opendkim") { + opendkim.gid = config.ids.gids.opendkim; + }; + + environment.systemPackages = [ pkgs.opendkim ]; + + systemd.tmpfiles.rules = [ + "d '${cfg.keyPath}' - ${cfg.user} ${cfg.group} - -" + ]; + + systemd.services.opendkim = { + description = "OpenDKIM signing and verification daemon"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + preStart = '' + cd "${cfg.keyPath}" + if ! test -f ${cfg.selector}.private; then + ${pkgs.opendkim}/bin/opendkim-genkey -s ${cfg.selector} -d all-domains-generic-key + echo "Generated OpenDKIM key! Please update your DNS settings:\n" + echo "-------------------------------------------------------------" + cat ${cfg.selector}.txt + echo "-------------------------------------------------------------" + fi + ''; + + serviceConfig = { + ExecStart = "${pkgs.opendkim}/bin/opendkim ${escapeShellArgs args}"; + User = cfg.user; + Group = cfg.group; + RuntimeDirectory = optional (cfg.socket == defaultSock) "opendkim"; + StateDirectory = "opendkim"; + StateDirectoryMode = "0700"; + ReadWritePaths = [ cfg.keyPath ]; + + AmbientCapabilities = []; + CapabilityBoundingSet = ""; + DevicePolicy = "closed"; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateTmp = true; + PrivateUsers = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6 AF_UNIX" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged @resources" ]; + UMask = "0077"; + }; + }; + + }; +} diff --git a/nixos/modules/services/mail/opensmtpd.nix b/nixos/modules/services/mail/opensmtpd.nix new file mode 100644 index 00000000000..e7632be2804 --- /dev/null +++ b/nixos/modules/services/mail/opensmtpd.nix @@ -0,0 +1,135 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.opensmtpd; + conf = pkgs.writeText "smtpd.conf" cfg.serverConfiguration; + args = concatStringsSep " " cfg.extraServerArgs; + + sendmail = pkgs.runCommand "opensmtpd-sendmail" { preferLocalBuild = true; } '' + mkdir -p $out/bin + ln -s ${cfg.package}/sbin/smtpctl $out/bin/sendmail + ''; + +in { + + ###### interface + + imports = [ + (mkRenamedOptionModule [ "services" "opensmtpd" "addSendmailToSystemPath" ] [ "services" "opensmtpd" "setSendmail" ]) + ]; + + options = { + + services.opensmtpd = { + + enable = mkOption { + type = types.bool; + default = false; + description = "Whether to enable the OpenSMTPD server."; + }; + + package = mkOption { + type = types.package; + default = pkgs.opensmtpd; + defaultText = literalExpression "pkgs.opensmtpd"; + description = "The OpenSMTPD package to use."; + }; + + setSendmail = mkOption { + type = types.bool; + default = true; + description = "Whether to set the system sendmail to OpenSMTPD's."; + }; + + extraServerArgs = mkOption { + type = types.listOf types.str; + default = []; + example = [ "-v" "-P mta" ]; + description = '' + Extra command line arguments provided when the smtpd process + is started. + ''; + }; + + serverConfiguration = mkOption { + type = types.lines; + example = '' + listen on lo + accept for any deliver to lmtp localhost:24 + ''; + description = '' + The contents of the smtpd.conf configuration file. See the + OpenSMTPD documentation for syntax information. + ''; + }; + + procPackages = mkOption { + type = types.listOf types.package; + default = []; + description = '' + Packages to search for filters, tables, queues, and schedulers. + + Add OpenSMTPD-extras here if you want to use the filters, etc. from + that package. + ''; + }; + }; + + }; + + + ###### implementation + + config = mkIf cfg.enable rec { + users.groups = { + smtpd.gid = config.ids.gids.smtpd; + smtpq.gid = config.ids.gids.smtpq; + }; + + users.users = { + smtpd = { + description = "OpenSMTPD process user"; + uid = config.ids.uids.smtpd; + group = "smtpd"; + }; + smtpq = { + description = "OpenSMTPD queue user"; + uid = config.ids.uids.smtpq; + group = "smtpq"; + }; + }; + + security.wrappers.smtpctl = { + owner = "root"; + group = "smtpq"; + setuid = false; + setgid = true; + source = "${cfg.package}/bin/smtpctl"; + }; + + services.mail.sendmailSetuidWrapper = mkIf cfg.setSendmail + (security.wrappers.smtpctl // { program = "sendmail"; }); + + systemd.tmpfiles.rules = [ + "d /var/spool/smtpd 711 root - - -" + "d /var/spool/smtpd/offline 770 root smtpq - -" + "d /var/spool/smtpd/purge 700 smtpq root - -" + ]; + + systemd.services.opensmtpd = let + procEnv = pkgs.buildEnv { + name = "opensmtpd-procs"; + paths = [ cfg.package ] ++ cfg.procPackages; + pathsToLink = [ "/libexec/opensmtpd" ]; + }; + in { + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + serviceConfig.ExecStart = "${cfg.package}/sbin/smtpd -d -f ${conf} ${args}"; + environment.OPENSMTPD_PROC_PATH = "${procEnv}/libexec/opensmtpd"; + }; + }; +} diff --git a/nixos/modules/services/mail/pfix-srsd.nix b/nixos/modules/services/mail/pfix-srsd.nix new file mode 100644 index 00000000000..e3dbf2a014f --- /dev/null +++ b/nixos/modules/services/mail/pfix-srsd.nix @@ -0,0 +1,56 @@ +{ config, lib, pkgs, ... }: + +with lib; + +{ + + ###### interface + + options = { + + services.pfix-srsd = { + enable = mkOption { + default = false; + type = types.bool; + description = "Whether to run the postfix sender rewriting scheme daemon."; + }; + + domain = mkOption { + description = "The domain for which to enable srs"; + type = types.str; + example = "example.com"; + }; + + secretsFile = mkOption { + description = '' + The secret data used to encode the SRS address. + to generate, use a command like: + <literal>for n in $(seq 5); do dd if=/dev/urandom count=1 bs=1024 status=none | sha256sum | sed 's/ -$//' | sed 's/^/ /'; done</literal> + ''; + type = types.path; + default = "/var/lib/pfix-srsd/secrets"; + }; + }; + }; + + ###### implementation + + config = mkIf config.services.pfix-srsd.enable { + environment = { + systemPackages = [ pkgs.pfixtools ]; + }; + + systemd.services.pfix-srsd = { + description = "Postfix sender rewriting scheme daemon"; + before = [ "postfix.service" ]; + #note that we use requires rather than wants because postfix + #is unable to process (almost) all mail without srsd + requiredBy = [ "postfix.service" ]; + serviceConfig = { + Type = "forking"; + PIDFile = "/run/pfix-srsd.pid"; + ExecStart = "${pkgs.pfixtools}/bin/pfix-srsd -p /run/pfix-srsd.pid -I ${config.services.pfix-srsd.domain} ${config.services.pfix-srsd.secretsFile}"; + }; + }; + }; +} diff --git a/nixos/modules/services/mail/postfix.nix b/nixos/modules/services/mail/postfix.nix new file mode 100644 index 00000000000..23d3574ae27 --- /dev/null +++ b/nixos/modules/services/mail/postfix.nix @@ -0,0 +1,988 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.postfix; + user = cfg.user; + group = cfg.group; + setgidGroup = cfg.setgidGroup; + + haveAliases = cfg.postmasterAlias != "" || cfg.rootAlias != "" + || cfg.extraAliases != ""; + haveCanonical = cfg.canonical != ""; + haveTransport = cfg.transport != ""; + haveVirtual = cfg.virtual != ""; + haveLocalRecipients = cfg.localRecipients != null; + + clientAccess = + optional (cfg.dnsBlacklistOverrides != "") + "check_client_access hash:/etc/postfix/client_access"; + + dnsBl = + optionals (cfg.dnsBlacklists != []) + (map (s: "reject_rbl_client " + s) cfg.dnsBlacklists); + + clientRestrictions = concatStringsSep ", " (clientAccess ++ dnsBl); + + mainCf = let + escape = replaceStrings ["$"] ["$$"]; + mkList = items: "\n " + concatStringsSep ",\n " items; + mkVal = value: + if isList value then mkList value + else " " + (if value == true then "yes" + else if value == false then "no" + else toString value); + mkEntry = name: value: "${escape name} =${mkVal value}"; + in + concatStringsSep "\n" (mapAttrsToList mkEntry cfg.config) + + "\n" + cfg.extraConfig; + + masterCfOptions = { options, config, name, ... }: { + options = { + name = mkOption { + type = types.str; + default = name; + example = "smtp"; + description = '' + The name of the service to run. Defaults to the attribute set key. + ''; + }; + + type = mkOption { + type = types.enum [ "inet" "unix" "unix-dgram" "fifo" "pass" ]; + default = "unix"; + example = "inet"; + description = "The type of the service"; + }; + + private = mkOption { + type = types.bool; + example = false; + description = '' + Whether the service's sockets and storage directory is restricted to + be only available via the mail system. If <literal>null</literal> is + given it uses the postfix default <literal>true</literal>. + ''; + }; + + privileged = mkOption { + type = types.bool; + example = true; + description = ""; + }; + + chroot = mkOption { + type = types.bool; + example = true; + description = '' + Whether the service is chrooted to have only access to the + <option>services.postfix.queueDir</option> and the closure of + store paths specified by the <option>program</option> option. + ''; + }; + + wakeup = mkOption { + type = types.int; + example = 60; + description = '' + Automatically wake up the service after the specified number of + seconds. If <literal>0</literal> is given, never wake the service + up. + ''; + }; + + wakeupUnusedComponent = mkOption { + type = types.bool; + example = false; + description = '' + If set to <literal>false</literal> the component will only be woken + up if it is used. This is equivalent to postfix' notion of adding a + question mark behind the wakeup time in + <filename>master.cf</filename> + ''; + }; + + maxproc = mkOption { + type = types.int; + example = 1; + description = '' + The maximum number of processes to spawn for this service. If the + value is <literal>0</literal> it doesn't have any limit. If + <literal>null</literal> is given it uses the postfix default of + <literal>100</literal>. + ''; + }; + + command = mkOption { + type = types.str; + default = name; + example = "smtpd"; + description = '' + A program name specifying a Postfix service/daemon process. + By default it's the attribute <option>name</option>. + ''; + }; + + args = mkOption { + type = types.listOf types.str; + default = []; + example = [ "-o" "smtp_helo_timeout=5" ]; + description = '' + Arguments to pass to the <option>command</option>. There is no shell + processing involved and shell syntax is passed verbatim to the + process. + ''; + }; + + rawEntry = mkOption { + type = types.listOf types.str; + default = []; + internal = true; + description = '' + The raw configuration line for the <filename>master.cf</filename>. + ''; + }; + }; + + config.rawEntry = let + mkBool = bool: if bool then "y" else "n"; + mkArg = arg: "${optionalString (hasPrefix "-" arg) "\n "}${arg}"; + + maybeOption = fun: option: + if options.${option}.isDefined then fun config.${option} else "-"; + + # This is special, because we have two options for this value. + wakeup = let + wakeupDefined = options.wakeup.isDefined; + wakeupUCDefined = options.wakeupUnusedComponent.isDefined; + finalValue = toString config.wakeup + + optionalString (wakeupUCDefined && !config.wakeupUnusedComponent) "?"; + in if wakeupDefined then finalValue else "-"; + + in [ + config.name + config.type + (maybeOption mkBool "private") + (maybeOption (b: mkBool (!b)) "privileged") + (maybeOption mkBool "chroot") + wakeup + (maybeOption toString "maxproc") + (config.command + " " + concatMapStringsSep " " mkArg config.args) + ]; + }; + + masterCfContent = let + + labels = [ + "# service" "type" "private" "unpriv" "chroot" "wakeup" "maxproc" + "command + args" + ]; + + labelDefaults = [ + "# " "" "(yes)" "(yes)" "(no)" "(never)" "(100)" "" "" + ]; + + masterCf = mapAttrsToList (const (getAttr "rawEntry")) cfg.masterConfig; + + # A list of the maximum width of the columns across all lines and labels + maxWidths = let + foldLine = line: acc: let + columnLengths = map stringLength line; + in zipListsWith max acc columnLengths; + # We need to handle the last column specially here, because it's + # open-ended (command + args). + lines = [ labels labelDefaults ] ++ (map (l: init l ++ [""]) masterCf); + in foldr foldLine (genList (const 0) (length labels)) lines; + + # Pad a string with spaces from the right (opposite of fixedWidthString). + pad = width: str: let + padWidth = width - stringLength str; + padding = concatStrings (genList (const " ") padWidth); + in str + optionalString (padWidth > 0) padding; + + # It's + 2 here, because that's the amount of spacing between columns. + fullWidth = foldr (width: acc: acc + width + 2) 0 maxWidths; + + formatLine = line: concatStringsSep " " (zipListsWith pad maxWidths line); + + formattedLabels = let + sep = "# " + concatStrings (genList (const "=") (fullWidth + 5)); + lines = [ sep (formatLine labels) (formatLine labelDefaults) sep ]; + in concatStringsSep "\n" lines; + + in formattedLabels + "\n" + concatMapStringsSep "\n" formatLine masterCf + "\n" + cfg.extraMasterConf; + + headerCheckOptions = { ... }: + { + options = { + pattern = mkOption { + type = types.str; + default = "/^.*/"; + example = "/^X-Mailer:/"; + description = "A regexp pattern matching the header"; + }; + action = mkOption { + type = types.str; + default = "DUNNO"; + example = "BCC mail@example.com"; + description = "The action to be executed when the pattern is matched"; + }; + }; + }; + + headerChecks = concatStringsSep "\n" (map (x: "${x.pattern} ${x.action}") cfg.headerChecks) + cfg.extraHeaderChecks; + + aliases = let seperator = if cfg.aliasMapType == "hash" then ":" else ""; in + optionalString (cfg.postmasterAlias != "") '' + postmaster${seperator} ${cfg.postmasterAlias} + '' + + optionalString (cfg.rootAlias != "") '' + root${seperator} ${cfg.rootAlias} + '' + + cfg.extraAliases + ; + + aliasesFile = pkgs.writeText "postfix-aliases" aliases; + canonicalFile = pkgs.writeText "postfix-canonical" cfg.canonical; + virtualFile = pkgs.writeText "postfix-virtual" cfg.virtual; + localRecipientMapFile = pkgs.writeText "postfix-local-recipient-map" (concatMapStrings (x: x + " ACCEPT\n") cfg.localRecipients); + checkClientAccessFile = pkgs.writeText "postfix-check-client-access" cfg.dnsBlacklistOverrides; + mainCfFile = pkgs.writeText "postfix-main.cf" mainCf; + masterCfFile = pkgs.writeText "postfix-master.cf" masterCfContent; + transportFile = pkgs.writeText "postfix-transport" cfg.transport; + headerChecksFile = pkgs.writeText "postfix-header-checks" headerChecks; + +in + +{ + + ###### interface + + options = { + + services.postfix = { + + enable = mkOption { + type = types.bool; + default = false; + description = "Whether to run the Postfix mail server."; + }; + + enableSmtp = mkOption { + type = types.bool; + default = true; + description = "Whether to enable smtp in master.cf."; + }; + + enableSubmission = mkOption { + type = types.bool; + default = false; + description = "Whether to enable smtp submission."; + }; + + enableSubmissions = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable smtp submission via smtps. + + According to RFC 8314 this should be preferred + over STARTTLS for submission of messages by end user clients. + ''; + }; + + submissionOptions = mkOption { + type = with types; attrsOf str; + default = { + smtpd_tls_security_level = "encrypt"; + smtpd_sasl_auth_enable = "yes"; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + milter_macro_daemon_name = "ORIGINATING"; + }; + example = { + smtpd_tls_security_level = "encrypt"; + smtpd_sasl_auth_enable = "yes"; + smtpd_sasl_type = "dovecot"; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + milter_macro_daemon_name = "ORIGINATING"; + }; + description = "Options for the submission config in master.cf"; + }; + + submissionsOptions = mkOption { + type = with types; attrsOf str; + default = { + smtpd_sasl_auth_enable = "yes"; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + milter_macro_daemon_name = "ORIGINATING"; + }; + example = { + smtpd_sasl_auth_enable = "yes"; + smtpd_sasl_type = "dovecot"; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + milter_macro_daemon_name = "ORIGINATING"; + }; + description = '' + Options for the submission config via smtps in master.cf. + + smtpd_tls_security_level will be set to encrypt, if it is missing + or has one of the values "may" or "none". + + smtpd_tls_wrappermode with value "yes" will be added automatically. + ''; + }; + + setSendmail = mkOption { + type = types.bool; + default = true; + description = "Whether to set the system sendmail to postfix's."; + }; + + user = mkOption { + type = types.str; + default = "postfix"; + description = "What to call the Postfix user (must be used only for postfix)."; + }; + + group = mkOption { + type = types.str; + default = "postfix"; + description = "What to call the Postfix group (must be used only for postfix)."; + }; + + setgidGroup = mkOption { + type = types.str; + default = "postdrop"; + description = " + How to call postfix setgid group (for postdrop). Should + be uniquely used group. + "; + }; + + networks = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + example = ["192.168.0.1/24"]; + description = " + Net masks for trusted - allowed to relay mail to third parties - + hosts. Leave empty to use mynetworks_style configuration or use + default (localhost-only). + "; + }; + + networksStyle = mkOption { + type = types.str; + default = ""; + description = " + Name of standard way of trusted network specification to use, + leave blank if you specify it explicitly or if you want to use + default (localhost-only). + "; + }; + + hostname = mkOption { + type = types.str; + default = ""; + description =" + Hostname to use. Leave blank to use just the hostname of machine. + It should be FQDN. + "; + }; + + domain = mkOption { + type = types.str; + default = ""; + description =" + Domain to use. Leave blank to use hostname minus first component. + "; + }; + + origin = mkOption { + type = types.str; + default = ""; + description =" + Origin to use in outgoing e-mail. Leave blank to use hostname. + "; + }; + + destination = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + example = ["localhost"]; + description = " + Full (!) list of domains we deliver locally. Leave blank for + acceptable Postfix default. + "; + }; + + relayDomains = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + example = ["localdomain"]; + description = " + List of domains we agree to relay to. Default is empty. + "; + }; + + relayHost = mkOption { + type = types.str; + default = ""; + description = " + Mail relay for outbound mail. + "; + }; + + relayPort = mkOption { + type = types.int; + default = 25; + description = " + SMTP port for relay mail relay. + "; + }; + + lookupMX = mkOption { + type = types.bool; + default = false; + description = " + Whether relay specified is just domain whose MX must be used. + "; + }; + + postmasterAlias = mkOption { + type = types.str; + default = "root"; + description = " + Who should receive postmaster e-mail. Multiple values can be added by + separating values with comma. + "; + }; + + rootAlias = mkOption { + type = types.str; + default = ""; + description = " + Who should receive root e-mail. Blank for no redirection. + Multiple values can be added by separating values with comma. + "; + }; + + extraAliases = mkOption { + type = types.lines; + default = ""; + description = " + Additional entries to put verbatim into aliases file, cf. man-page aliases(8). + "; + }; + + aliasMapType = mkOption { + type = with types; enum [ "hash" "regexp" "pcre" ]; + default = "hash"; + example = "regexp"; + description = "The format the alias map should have. Use regexp if you want to use regular expressions."; + }; + + config = mkOption { + type = with types; attrsOf (oneOf [ bool str (listOf str) ]); + description = '' + The main.cf configuration file as key value set. + ''; + example = { + mail_owner = "postfix"; + smtp_tls_security_level = "may"; + }; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = " + Extra lines to be added verbatim to the main.cf configuration file. + "; + }; + + tlsTrustedAuthorities = mkOption { + type = types.str; + default = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + defaultText = literalExpression ''"''${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"''; + description = '' + File containing trusted certification authorities (CA) to verify certificates of mailservers contacted for mail delivery. This basically sets smtp_tls_CAfile and enables opportunistic tls. Defaults to NixOS trusted certification authorities. + ''; + }; + + sslCert = mkOption { + type = types.str; + default = ""; + description = "SSL certificate to use."; + }; + + sslKey = mkOption { + type = types.str; + default = ""; + description = "SSL key to use."; + }; + + recipientDelimiter = mkOption { + type = types.str; + default = ""; + example = "+"; + description = " + Delimiter for address extension: so mail to user+test can be handled by ~user/.forward+test + "; + }; + + canonical = mkOption { + type = types.lines; + default = ""; + description = '' + Entries for the <citerefentry><refentrytitle>canonical</refentrytitle> + <manvolnum>5</manvolnum></citerefentry> table. + ''; + }; + + virtual = mkOption { + type = types.lines; + default = ""; + description = " + Entries for the virtual alias map, cf. man-page virtual(5). + "; + }; + + virtualMapType = mkOption { + type = types.enum ["hash" "regexp" "pcre"]; + default = "hash"; + description = '' + What type of virtual alias map file to use. Use <literal>"regexp"</literal> for regular expressions. + ''; + }; + + localRecipients = mkOption { + type = with types; nullOr (listOf str); + default = null; + description = '' + List of accepted local users. Specify a bare username, an + <literal>"@domain.tld"</literal> wild-card, or a complete + <literal>"user@domain.tld"</literal> address. If set, these names end + up in the local recipient map -- see the local(8) man-page -- and + effectively replace the system user database lookup that's otherwise + used by default. + ''; + }; + + transport = mkOption { + default = ""; + type = types.lines; + description = " + Entries for the transport map, cf. man-page transport(8). + "; + }; + + dnsBlacklists = mkOption { + default = []; + type = with types; listOf str; + description = "dns blacklist servers to use with smtpd_client_restrictions"; + }; + + dnsBlacklistOverrides = mkOption { + default = ""; + type = types.lines; + description = "contents of check_client_access for overriding dnsBlacklists"; + }; + + masterConfig = mkOption { + type = types.attrsOf (types.submodule masterCfOptions); + default = {}; + example = + { submission = { + type = "inet"; + args = [ "-o" "smtpd_tls_security_level=encrypt" ]; + }; + }; + description = '' + An attribute set of service options, which correspond to the service + definitions usually done within the Postfix + <filename>master.cf</filename> file. + ''; + }; + + extraMasterConf = mkOption { + type = types.lines; + default = ""; + example = "submission inet n - n - - smtpd"; + description = "Extra lines to append to the generated master.cf file."; + }; + + enableHeaderChecks = mkOption { + type = types.bool; + default = false; + example = true; + description = "Whether to enable postfix header checks"; + }; + + headerChecks = mkOption { + type = types.listOf (types.submodule headerCheckOptions); + default = []; + example = [ { pattern = "/^X-Spam-Flag:/"; action = "REDIRECT spam@example.com"; } ]; + description = "Postfix header checks."; + }; + + extraHeaderChecks = mkOption { + type = types.lines; + default = ""; + example = "/^X-Spam-Flag:/ REDIRECT spam@example.com"; + description = "Extra lines to /etc/postfix/header_checks file."; + }; + + aliasFiles = mkOption { + type = types.attrsOf types.path; + default = {}; + description = "Aliases' tables to be compiled and placed into /var/lib/postfix/conf."; + }; + + mapFiles = mkOption { + type = types.attrsOf types.path; + default = {}; + description = "Maps to be compiled and placed into /var/lib/postfix/conf."; + }; + + useSrs = mkOption { + type = types.bool; + default = false; + description = "Whether to enable sender rewriting scheme"; + }; + + }; + + }; + + + ###### implementation + + config = mkIf config.services.postfix.enable (mkMerge [ + { + + environment = { + etc.postfix.source = "/var/lib/postfix/conf"; + + # This makes it comfortable to run 'postqueue/postdrop' for example. + systemPackages = [ pkgs.postfix ]; + }; + + services.pfix-srsd.enable = config.services.postfix.useSrs; + + services.mail.sendmailSetuidWrapper = mkIf config.services.postfix.setSendmail { + program = "sendmail"; + source = "${pkgs.postfix}/bin/sendmail"; + owner = "root"; + group = setgidGroup; + setuid = false; + setgid = true; + }; + + security.wrappers.mailq = { + program = "mailq"; + source = "${pkgs.postfix}/bin/mailq"; + owner = "root"; + group = setgidGroup; + setuid = false; + setgid = true; + }; + + security.wrappers.postqueue = { + program = "postqueue"; + source = "${pkgs.postfix}/bin/postqueue"; + owner = "root"; + group = setgidGroup; + setuid = false; + setgid = true; + }; + + security.wrappers.postdrop = { + program = "postdrop"; + source = "${pkgs.postfix}/bin/postdrop"; + owner = "root"; + group = setgidGroup; + setuid = false; + setgid = true; + }; + + users.users = optionalAttrs (user == "postfix") + { postfix = { + description = "Postfix mail server user"; + uid = config.ids.uids.postfix; + group = group; + }; + }; + + users.groups = + optionalAttrs (group == "postfix") + { ${group}.gid = config.ids.gids.postfix; + } + // optionalAttrs (setgidGroup == "postdrop") + { ${setgidGroup}.gid = config.ids.gids.postdrop; + }; + + systemd.services.postfix = + { description = "Postfix mail server"; + + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + path = [ pkgs.postfix ]; + + serviceConfig = { + Type = "forking"; + Restart = "always"; + PIDFile = "/var/lib/postfix/queue/pid/master.pid"; + ExecStart = "${pkgs.postfix}/bin/postfix start"; + ExecStop = "${pkgs.postfix}/bin/postfix stop"; + ExecReload = "${pkgs.postfix}/bin/postfix reload"; + }; + + preStart = '' + # Backwards compatibility + if [ ! -d /var/lib/postfix ] && [ -d /var/postfix ]; then + mkdir -p /var/lib + mv /var/postfix /var/lib/postfix + fi + + # All permissions set according ${pkgs.postfix}/etc/postfix/postfix-files script + mkdir -p /var/lib/postfix /var/lib/postfix/queue/{pid,public,maildrop} + chmod 0755 /var/lib/postfix + chown root:root /var/lib/postfix + + rm -rf /var/lib/postfix/conf + mkdir -p /var/lib/postfix/conf + chmod 0755 /var/lib/postfix/conf + ln -sf ${pkgs.postfix}/etc/postfix/postfix-files /var/lib/postfix/conf/postfix-files + ln -sf ${mainCfFile} /var/lib/postfix/conf/main.cf + ln -sf ${masterCfFile} /var/lib/postfix/conf/master.cf + + ${concatStringsSep "\n" (mapAttrsToList (to: from: '' + ln -sf ${from} /var/lib/postfix/conf/${to} + ${pkgs.postfix}/bin/postalias /var/lib/postfix/conf/${to} + '') cfg.aliasFiles)} + ${concatStringsSep "\n" (mapAttrsToList (to: from: '' + ln -sf ${from} /var/lib/postfix/conf/${to} + ${pkgs.postfix}/bin/postmap /var/lib/postfix/conf/${to} + '') cfg.mapFiles)} + + mkdir -p /var/spool/mail + chown root:root /var/spool/mail + chmod a+rwxt /var/spool/mail + ln -sf /var/spool/mail /var/ + + #Finally delegate to postfix checking remain directories in /var/lib/postfix and set permissions on them + ${pkgs.postfix}/bin/postfix set-permissions config_directory=/var/lib/postfix/conf + ''; + }; + + services.postfix.config = (mapAttrs (_: v: mkDefault v) { + compatibility_level = pkgs.postfix.version; + mail_owner = cfg.user; + default_privs = "nobody"; + + # NixOS specific locations + data_directory = "/var/lib/postfix/data"; + queue_directory = "/var/lib/postfix/queue"; + + # Default location of everything in package + meta_directory = "${pkgs.postfix}/etc/postfix"; + command_directory = "${pkgs.postfix}/bin"; + sample_directory = "/etc/postfix"; + newaliases_path = "${pkgs.postfix}/bin/newaliases"; + mailq_path = "${pkgs.postfix}/bin/mailq"; + readme_directory = false; + sendmail_path = "${pkgs.postfix}/bin/sendmail"; + daemon_directory = "${pkgs.postfix}/libexec/postfix"; + manpage_directory = "${pkgs.postfix}/share/man"; + html_directory = "${pkgs.postfix}/share/postfix/doc/html"; + shlib_directory = false; + mail_spool_directory = "/var/spool/mail/"; + setgid_group = cfg.setgidGroup; + }) + // optionalAttrs (cfg.relayHost != "") { relayhost = if cfg.lookupMX + then "${cfg.relayHost}:${toString cfg.relayPort}" + else "[${cfg.relayHost}]:${toString cfg.relayPort}"; } + // optionalAttrs config.networking.enableIPv6 { inet_protocols = mkDefault "all"; } + // optionalAttrs (cfg.networks != null) { mynetworks = cfg.networks; } + // optionalAttrs (cfg.networksStyle != "") { mynetworks_style = cfg.networksStyle; } + // optionalAttrs (cfg.hostname != "") { myhostname = cfg.hostname; } + // optionalAttrs (cfg.domain != "") { mydomain = cfg.domain; } + // optionalAttrs (cfg.origin != "") { myorigin = cfg.origin; } + // optionalAttrs (cfg.destination != null) { mydestination = cfg.destination; } + // optionalAttrs (cfg.relayDomains != null) { relay_domains = cfg.relayDomains; } + // optionalAttrs (cfg.recipientDelimiter != "") { recipient_delimiter = cfg.recipientDelimiter; } + // optionalAttrs haveAliases { alias_maps = [ "${cfg.aliasMapType}:/etc/postfix/aliases" ]; } + // optionalAttrs haveTransport { transport_maps = [ "hash:/etc/postfix/transport" ]; } + // optionalAttrs haveVirtual { virtual_alias_maps = [ "${cfg.virtualMapType}:/etc/postfix/virtual" ]; } + // optionalAttrs haveLocalRecipients { local_recipient_maps = [ "hash:/etc/postfix/local_recipients" ] ++ optional haveAliases "$alias_maps"; } + // optionalAttrs (cfg.dnsBlacklists != []) { smtpd_client_restrictions = clientRestrictions; } + // optionalAttrs cfg.useSrs { + sender_canonical_maps = [ "tcp:127.0.0.1:10001" ]; + sender_canonical_classes = [ "envelope_sender" ]; + recipient_canonical_maps = [ "tcp:127.0.0.1:10002" ]; + recipient_canonical_classes = [ "envelope_recipient" ]; + } + // optionalAttrs cfg.enableHeaderChecks { header_checks = [ "regexp:/etc/postfix/header_checks" ]; } + // optionalAttrs (cfg.tlsTrustedAuthorities != "") { + smtp_tls_CAfile = cfg.tlsTrustedAuthorities; + smtp_tls_security_level = mkDefault "may"; + } + // optionalAttrs (cfg.sslCert != "") { + smtp_tls_cert_file = cfg.sslCert; + smtp_tls_key_file = cfg.sslKey; + + smtp_tls_security_level = mkDefault "may"; + + smtpd_tls_cert_file = cfg.sslCert; + smtpd_tls_key_file = cfg.sslKey; + + smtpd_tls_security_level = "may"; + }; + + services.postfix.masterConfig = { + pickup = { + private = false; + wakeup = 60; + maxproc = 1; + }; + cleanup = { + private = false; + maxproc = 0; + }; + qmgr = { + private = false; + wakeup = 300; + maxproc = 1; + }; + tlsmgr = { + wakeup = 1000; + wakeupUnusedComponent = false; + maxproc = 1; + }; + rewrite = { + command = "trivial-rewrite"; + }; + bounce = { + maxproc = 0; + }; + defer = { + maxproc = 0; + command = "bounce"; + }; + trace = { + maxproc = 0; + command = "bounce"; + }; + verify = { + maxproc = 1; + }; + flush = { + private = false; + wakeup = 1000; + wakeupUnusedComponent = false; + maxproc = 0; + }; + proxymap = { + command = "proxymap"; + }; + proxywrite = { + maxproc = 1; + command = "proxymap"; + }; + showq = { + private = false; + }; + error = {}; + retry = { + command = "error"; + }; + discard = {}; + local = { + privileged = true; + }; + virtual = { + privileged = true; + }; + lmtp = { + }; + anvil = { + maxproc = 1; + }; + scache = { + maxproc = 1; + }; + } // optionalAttrs cfg.enableSubmission { + submission = { + type = "inet"; + private = false; + command = "smtpd"; + args = let + mkKeyVal = opt: val: [ "-o" (opt + "=" + val) ]; + in concatLists (mapAttrsToList mkKeyVal cfg.submissionOptions); + }; + } // optionalAttrs cfg.enableSmtp { + smtp_inet = { + name = "smtp"; + type = "inet"; + private = false; + command = "smtpd"; + }; + smtp = {}; + relay = { + command = "smtp"; + args = [ "-o" "smtp_fallback_relay=" ]; + }; + } // optionalAttrs cfg.enableSubmissions { + submissions = { + type = "inet"; + private = false; + command = "smtpd"; + args = let + mkKeyVal = opt: val: [ "-o" (opt + "=" + val) ]; + adjustSmtpTlsSecurityLevel = !(cfg.submissionsOptions ? smtpd_tls_security_level) || + cfg.submissionsOptions.smtpd_tls_security_level == "none" || + cfg.submissionsOptions.smtpd_tls_security_level == "may"; + submissionsOptions = cfg.submissionsOptions // { + smtpd_tls_wrappermode = "yes"; + } // optionalAttrs adjustSmtpTlsSecurityLevel { + smtpd_tls_security_level = "encrypt"; + }; + in concatLists (mapAttrsToList mkKeyVal submissionsOptions); + }; + }; + } + + (mkIf haveAliases { + services.postfix.aliasFiles.aliases = aliasesFile; + }) + (mkIf haveCanonical { + services.postfix.mapFiles.canonical = canonicalFile; + }) + (mkIf haveTransport { + services.postfix.mapFiles.transport = transportFile; + }) + (mkIf haveVirtual { + services.postfix.mapFiles.virtual = virtualFile; + }) + (mkIf haveLocalRecipients { + services.postfix.mapFiles.local_recipients = localRecipientMapFile; + }) + (mkIf cfg.enableHeaderChecks { + services.postfix.mapFiles.header_checks = headerChecksFile; + }) + (mkIf (cfg.dnsBlacklists != []) { + services.postfix.mapFiles.client_access = checkClientAccessFile; + }) + ]); + + imports = [ + (mkRemovedOptionModule [ "services" "postfix" "sslCACert" ] + "services.postfix.sslCACert was replaced by services.postfix.tlsTrustedAuthorities. In case you intend that your server should validate requested client certificates use services.postfix.extraConfig.") + + (mkChangedOptionModule [ "services" "postfix" "useDane" ] + [ "services" "postfix" "config" "smtp_tls_security_level" ] + (config: mkIf config.services.postfix.useDane "dane")) + ]; +} diff --git a/nixos/modules/services/mail/postfixadmin.nix b/nixos/modules/services/mail/postfixadmin.nix new file mode 100644 index 00000000000..a0846ad5290 --- /dev/null +++ b/nixos/modules/services/mail/postfixadmin.nix @@ -0,0 +1,199 @@ +{ lib, config, pkgs, ... }: + +with lib; + +let + cfg = config.services.postfixadmin; + fpm = config.services.phpfpm.pools.postfixadmin; + localDB = cfg.database.host == "localhost"; + user = if localDB then cfg.database.username else "nginx"; +in +{ + options.services.postfixadmin = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable postfixadmin. + + Also enables nginx virtual host management. + Further nginx configuration can be done by adapting <literal>services.nginx.virtualHosts.<name></literal>. + See <xref linkend="opt-services.nginx.virtualHosts"/> for further information. + ''; + }; + + hostName = mkOption { + type = types.str; + example = "postfixadmin.example.com"; + description = "Hostname to use for the nginx vhost"; + }; + + adminEmail = mkOption { + type = types.str; + example = "postmaster@example.com"; + description = '' + Defines the Site Admin's email address. + This will be used to send emails from to create mailboxes and + from Send Email / Broadcast message pages. + ''; + }; + + setupPasswordFile = mkOption { + type = types.path; + description = '' + Password file for the admin. + Generate with <literal>php -r "echo password_hash('some password here', PASSWORD_DEFAULT);"</literal> + ''; + }; + + database = { + username = mkOption { + type = types.str; + default = "postfixadmin"; + description = '' + Username for the postgresql connection. + If <literal>database.host</literal> is set to <literal>localhost</literal>, a unix user and group of the same name will be created as well. + ''; + }; + host = mkOption { + type = types.str; + default = "localhost"; + description = '' + Host of the postgresql server. If this is not set to + <literal>localhost</literal>, you have to create the + postgresql user and database yourself, with appropriate + permissions. + ''; + }; + passwordFile = mkOption { + type = types.path; + description = "Password file for the postgresql connection. Must be readable by user <literal>nginx</literal>."; + }; + dbname = mkOption { + type = types.str; + default = "postfixadmin"; + description = "Name of the postgresql database"; + }; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = "Extra configuration for the postfixadmin instance, see postfixadmin's config.inc.php for available options."; + }; + }; + + config = mkIf cfg.enable { + environment.etc."postfixadmin/config.local.php".text = '' + <?php + + $CONF['setup_password'] = file_get_contents('${cfg.setupPasswordFile}'); + + $CONF['database_type'] = 'pgsql'; + $CONF['database_host'] = ${if localDB then "null" else "'${cfg.database.host}'"}; + ${optionalString localDB "$CONF['database_user'] = '${cfg.database.username}';"} + $CONF['database_password'] = ${if localDB then "'dummy'" else "file_get_contents('${cfg.database.passwordFile}')"}; + $CONF['database_name'] = '${cfg.database.dbname}'; + $CONF['configured'] = true; + + ${cfg.extraConfig} + ''; + + systemd.tmpfiles.rules = [ "d /var/cache/postfixadmin/templates_c 700 ${user} ${user}" ]; + + services.nginx = { + enable = true; + virtualHosts = { + ${cfg.hostName} = { + forceSSL = mkDefault true; + enableACME = mkDefault true; + locations."/" = { + root = "${pkgs.postfixadmin}/public"; + index = "index.php"; + extraConfig = '' + location ~* \.php$ { + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${fpm.socket}; + include ${config.services.nginx.package}/conf/fastcgi_params; + include ${pkgs.nginx}/conf/fastcgi.conf; + } + ''; + }; + }; + }; + }; + + services.postgresql = mkIf localDB { + enable = true; + ensureUsers = [ { + name = cfg.database.username; + } ]; + }; + # The postgresql module doesn't currently support concepts like + # objects owners and extensions; for now we tack on what's needed + # here. + systemd.services.postfixadmin-postgres = let pgsql = config.services.postgresql; in mkIf localDB { + after = [ "postgresql.service" ]; + bindsTo = [ "postgresql.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ + pgsql.package + pkgs.util-linux + ]; + script = '' + set -eu + + PSQL() { + psql --port=${toString pgsql.port} "$@" + } + + PSQL -tAc "SELECT 1 FROM pg_database WHERE datname = '${cfg.database.dbname}'" | grep -q 1 || PSQL -tAc 'CREATE DATABASE "${cfg.database.dbname}" OWNER "${cfg.database.username}"' + current_owner=$(PSQL -tAc "SELECT pg_catalog.pg_get_userbyid(datdba) FROM pg_catalog.pg_database WHERE datname = '${cfg.database.dbname}'") + if [[ "$current_owner" != "${cfg.database.username}" ]]; then + PSQL -tAc 'ALTER DATABASE "${cfg.database.dbname}" OWNER TO "${cfg.database.username}"' + if [[ -e "${config.services.postgresql.dataDir}/.reassigning_${cfg.database.dbname}" ]]; then + echo "Reassigning ownership of database ${cfg.database.dbname} to user ${cfg.database.username} failed on last boot. Failing..." + exit 1 + fi + touch "${config.services.postgresql.dataDir}/.reassigning_${cfg.database.dbname}" + PSQL "${cfg.database.dbname}" -tAc "REASSIGN OWNED BY \"$current_owner\" TO \"${cfg.database.username}\"" + rm "${config.services.postgresql.dataDir}/.reassigning_${cfg.database.dbname}" + fi + ''; + + serviceConfig = { + User = pgsql.superUser; + Type = "oneshot"; + RemainAfterExit = true; + }; + }; + + users.users.${user} = mkIf localDB { + group = user; + isSystemUser = true; + createHome = false; + }; + users.groups.${user} = mkIf localDB {}; + + services.phpfpm.pools.postfixadmin = { + user = user; + phpPackage = pkgs.php74; + phpOptions = '' + error_log = 'stderr' + log_errors = on + ''; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = "nginx"; + "listen.group" = "nginx"; + "listen.mode" = "0660"; + "pm" = "dynamic"; + "pm.max_children" = 75; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 1; + "pm.max_spare_servers" = 20; + "pm.max_requests" = 500; + "catch_workers_output" = true; + }; + }; + }; +} diff --git a/nixos/modules/services/mail/postgrey.nix b/nixos/modules/services/mail/postgrey.nix new file mode 100644 index 00000000000..7c206e3725e --- /dev/null +++ b/nixos/modules/services/mail/postgrey.nix @@ -0,0 +1,205 @@ +{ config, lib, pkgs, ... }: + +with lib; let + + cfg = config.services.postgrey; + + natural = with types; addCheck int (x: x >= 0); + natural' = with types; addCheck int (x: x > 0); + + socket = with types; addCheck (either (submodule unixSocket) (submodule inetSocket)) (x: x ? path || x ? port); + + inetSocket = with types; { + options = { + addr = mkOption { + type = nullOr str; + default = null; + example = "127.0.0.1"; + description = "The address to bind to. Localhost if null"; + }; + port = mkOption { + type = natural'; + default = 10030; + description = "Tcp port to bind to"; + }; + }; + }; + + unixSocket = with types; { + options = { + path = mkOption { + type = path; + default = "/run/postgrey.sock"; + description = "Path of the unix socket"; + }; + + mode = mkOption { + type = str; + default = "0777"; + description = "Mode of the unix socket"; + }; + }; + }; + +in { + imports = [ + (mkMergedOptionModule [ [ "services" "postgrey" "inetAddr" ] [ "services" "postgrey" "inetPort" ] ] [ "services" "postgrey" "socket" ] (config: let + value = p: getAttrFromPath p config; + inetAddr = [ "services" "postgrey" "inetAddr" ]; + inetPort = [ "services" "postgrey" "inetPort" ]; + in + if value inetAddr == null + then { path = "/run/postgrey.sock"; } + else { addr = value inetAddr; port = value inetPort; } + )) + ]; + + options = { + services.postgrey = with types; { + enable = mkOption { + type = bool; + default = false; + description = "Whether to run the Postgrey daemon"; + }; + socket = mkOption { + type = socket; + default = { + path = "/run/postgrey.sock"; + mode = "0777"; + }; + example = { + addr = "127.0.0.1"; + port = 10030; + }; + description = "Socket to bind to"; + }; + greylistText = mkOption { + type = str; + default = "Greylisted for %%s seconds"; + description = "Response status text for greylisted messages; use %%s for seconds left until greylisting is over and %%r for mail domain of recipient"; + }; + greylistAction = mkOption { + type = str; + default = "DEFER_IF_PERMIT"; + description = "Response status for greylisted messages (see access(5))"; + }; + greylistHeader = mkOption { + type = str; + default = "X-Greylist: delayed %%t seconds by postgrey-%%v at %%h; %%d"; + description = "Prepend header to greylisted mails; use %%t for seconds delayed due to greylisting, %%v for the version of postgrey, %%d for the date, and %%h for the host"; + }; + delay = mkOption { + type = natural; + default = 300; + description = "Greylist for N seconds"; + }; + maxAge = mkOption { + type = natural; + default = 35; + description = "Delete entries from whitelist if they haven't been seen for N days"; + }; + retryWindow = mkOption { + type = either str natural; + default = 2; + example = "12h"; + description = "Allow N days for the first retry. Use string with appended 'h' to specify time in hours"; + }; + lookupBySubnet = mkOption { + type = bool; + default = true; + description = "Strip the last N bits from IP addresses, determined by IPv4CIDR and IPv6CIDR"; + }; + IPv4CIDR = mkOption { + type = natural; + default = 24; + description = "Strip N bits from IPv4 addresses if lookupBySubnet is true"; + }; + IPv6CIDR = mkOption { + type = natural; + default = 64; + description = "Strip N bits from IPv6 addresses if lookupBySubnet is true"; + }; + privacy = mkOption { + type = bool; + default = true; + description = "Store data using one-way hash functions (SHA1)"; + }; + autoWhitelist = mkOption { + type = nullOr natural'; + default = 5; + description = "Whitelist clients after successful delivery of N messages"; + }; + whitelistClients = mkOption { + type = listOf path; + default = []; + description = "Client address whitelist files (see postgrey(8))"; + }; + whitelistRecipients = mkOption { + type = listOf path; + default = []; + description = "Recipient address whitelist files (see postgrey(8))"; + }; + }; + }; + + config = mkIf cfg.enable { + + environment.systemPackages = [ pkgs.postgrey ]; + + users = { + users = { + postgrey = { + description = "Postgrey Daemon"; + uid = config.ids.uids.postgrey; + group = "postgrey"; + }; + }; + groups = { + postgrey = { + gid = config.ids.gids.postgrey; + }; + }; + }; + + systemd.services.postgrey = let + bind-flag = if cfg.socket ? path then + "--unix=${cfg.socket.path} --socketmode=${cfg.socket.mode}" + else + ''--inet=${optionalString (cfg.socket.addr != null) (cfg.socket.addr + ":")}${toString cfg.socket.port}''; + in { + description = "Postfix Greylisting Service"; + wantedBy = [ "multi-user.target" ]; + before = [ "postfix.service" ]; + preStart = '' + mkdir -p /var/postgrey + chown postgrey:postgrey /var/postgrey + chmod 0770 /var/postgrey + ''; + serviceConfig = { + Type = "simple"; + ExecStart = ''${pkgs.postgrey}/bin/postgrey \ + ${bind-flag} \ + --group=postgrey --user=postgrey \ + --dbdir=/var/postgrey \ + --delay=${toString cfg.delay} \ + --max-age=${toString cfg.maxAge} \ + --retry-window=${toString cfg.retryWindow} \ + ${if cfg.lookupBySubnet then "--lookup-by-subnet" else "--lookup-by-host"} \ + --ipv4cidr=${toString cfg.IPv4CIDR} --ipv6cidr=${toString cfg.IPv6CIDR} \ + ${optionalString cfg.privacy "--privacy"} \ + --auto-whitelist-clients=${toString (if cfg.autoWhitelist == null then 0 else cfg.autoWhitelist)} \ + --greylist-action=${cfg.greylistAction} \ + --greylist-text="${cfg.greylistText}" \ + --x-greylist-header="${cfg.greylistHeader}" \ + ${concatMapStringsSep " " (x: "--whitelist-clients=" + x) cfg.whitelistClients} \ + ${concatMapStringsSep " " (x: "--whitelist-recipients=" + x) cfg.whitelistRecipients} + ''; + Restart = "always"; + RestartSec = 5; + TimeoutSec = 10; + }; + }; + + }; + +} diff --git a/nixos/modules/services/mail/postsrsd.nix b/nixos/modules/services/mail/postsrsd.nix new file mode 100644 index 00000000000..2ebc675ab10 --- /dev/null +++ b/nixos/modules/services/mail/postsrsd.nix @@ -0,0 +1,135 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.services.postsrsd; + +in { + + ###### interface + + options = { + + services.postsrsd = { + + enable = mkOption { + type = types.bool; + default = false; + description = "Whether to enable the postsrsd SRS server for Postfix."; + }; + + secretsFile = mkOption { + type = types.path; + default = "/var/lib/postsrsd/postsrsd.secret"; + description = "Secret keys used for signing and verification"; + }; + + domain = mkOption { + type = types.str; + description = "Domain name for rewrite"; + }; + + separator = mkOption { + type = types.enum ["-" "=" "+"]; + default = "="; + description = "First separator character in generated addresses"; + }; + + # bindAddress = mkOption { # uncomment once 1.5 is released + # type = types.str; + # default = "127.0.0.1"; + # description = "Socket listen address"; + # }; + + forwardPort = mkOption { + type = types.int; + default = 10001; + description = "Port for the forward SRS lookup"; + }; + + reversePort = mkOption { + type = types.int; + default = 10002; + description = "Port for the reverse SRS lookup"; + }; + + timeout = mkOption { + type = types.int; + default = 1800; + description = "Timeout for idle client connections in seconds"; + }; + + excludeDomains = mkOption { + type = types.listOf types.str; + default = []; + description = "Origin domains to exclude from rewriting in addition to primary domain"; + }; + + user = mkOption { + type = types.str; + default = "postsrsd"; + description = "User for the daemon"; + }; + + group = mkOption { + type = types.str; + default = "postsrsd"; + description = "Group for the daemon"; + }; + + }; + + }; + + + ###### implementation + + config = mkIf cfg.enable { + + services.postsrsd.domain = mkDefault config.networking.hostName; + + users.users = optionalAttrs (cfg.user == "postsrsd") { + postsrsd = { + group = cfg.group; + uid = config.ids.uids.postsrsd; + }; + }; + + users.groups = optionalAttrs (cfg.group == "postsrsd") { + postsrsd.gid = config.ids.gids.postsrsd; + }; + + systemd.services.postsrsd = { + description = "PostSRSd SRS rewriting server"; + after = [ "network.target" ]; + before = [ "postfix.service" ]; + wantedBy = [ "multi-user.target" ]; + + path = [ pkgs.coreutils ]; + + serviceConfig = { + ExecStart = ''${pkgs.postsrsd}/sbin/postsrsd "-s${cfg.secretsFile}" "-d${cfg.domain}" -a${cfg.separator} -f${toString cfg.forwardPort} -r${toString cfg.reversePort} -t${toString cfg.timeout} "-X${concatStringsSep "," cfg.excludeDomains}"''; + User = cfg.user; + Group = cfg.group; + PermissionsStartOnly = true; + }; + + preStart = '' + if [ ! -e "${cfg.secretsFile}" ]; then + echo "WARNING: secrets file not found, autogenerating!" + DIR="$(dirname "${cfg.secretsFile}")" + if [ ! -d "$DIR" ]; then + mkdir -p -m750 "$DIR" + chown "${cfg.user}:${cfg.group}" "$DIR" + fi + dd if=/dev/random bs=18 count=1 | base64 > "${cfg.secretsFile}" + chmod 600 "${cfg.secretsFile}" + fi + chown "${cfg.user}:${cfg.group}" "${cfg.secretsFile}" + ''; + }; + + }; +} diff --git a/nixos/modules/services/mail/roundcube.nix b/nixos/modules/services/mail/roundcube.nix new file mode 100644 index 00000000000..1dd393da882 --- /dev/null +++ b/nixos/modules/services/mail/roundcube.nix @@ -0,0 +1,249 @@ +{ lib, config, pkgs, ... }: + +with lib; + +let + cfg = config.services.roundcube; + fpm = config.services.phpfpm.pools.roundcube; + localDB = cfg.database.host == "localhost"; + user = cfg.database.username; + phpWithPspell = pkgs.php80.withExtensions ({ enabled, all }: [ all.pspell ] ++ enabled); +in +{ + options.services.roundcube = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable roundcube. + + Also enables nginx virtual host management. + Further nginx configuration can be done by adapting <literal>services.nginx.virtualHosts.<name></literal>. + See <xref linkend="opt-services.nginx.virtualHosts"/> for further information. + ''; + }; + + hostName = mkOption { + type = types.str; + example = "webmail.example.com"; + description = "Hostname to use for the nginx vhost"; + }; + + package = mkOption { + type = types.package; + default = pkgs.roundcube; + defaultText = literalExpression "pkgs.roundcube"; + + example = literalExpression '' + roundcube.withPlugins (plugins: [ plugins.persistent_login ]) + ''; + + description = '' + The package which contains roundcube's sources. Can be overriden to create + an environment which contains roundcube and third-party plugins. + ''; + }; + + database = { + username = mkOption { + type = types.str; + default = "roundcube"; + description = '' + Username for the postgresql connection. + If <literal>database.host</literal> is set to <literal>localhost</literal>, a unix user and group of the same name will be created as well. + ''; + }; + host = mkOption { + type = types.str; + default = "localhost"; + description = '' + Host of the postgresql server. If this is not set to + <literal>localhost</literal>, you have to create the + postgresql user and database yourself, with appropriate + permissions. + ''; + }; + password = mkOption { + type = types.str; + description = "Password for the postgresql connection. Do not use: the password will be stored world readable in the store; use <literal>passwordFile</literal> instead."; + default = ""; + }; + passwordFile = mkOption { + type = types.str; + description = "Password file for the postgresql connection. Must be readable by user <literal>nginx</literal>. Ignored if <literal>database.host</literal> is set to <literal>localhost</literal>, as peer authentication will be used."; + }; + dbname = mkOption { + type = types.str; + default = "roundcube"; + description = "Name of the postgresql database"; + }; + }; + + plugins = mkOption { + type = types.listOf types.str; + default = []; + description = '' + List of roundcube plugins to enable. Currently, only those directly shipped with Roundcube are supported. + ''; + }; + + dicts = mkOption { + type = types.listOf types.package; + default = []; + example = literalExpression "with pkgs.aspellDicts; [ en fr de ]"; + description = '' + List of aspell dictionnaries for spell checking. If empty, spell checking is disabled. + ''; + }; + + maxAttachmentSize = mkOption { + type = types.int; + default = 18; + description = '' + The maximum attachment size in MB. + + Note: Since roundcube only uses 70% of max upload values configured in php + 30% is added automatically to <xref linkend="opt-services.roundcube.maxAttachmentSize"/>. + ''; + apply = configuredMaxAttachmentSize: "${toString (configuredMaxAttachmentSize * 1.3)}M"; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = "Extra configuration for roundcube webmail instance"; + }; + }; + + config = mkIf cfg.enable { + # backward compatibility: if password is set but not passwordFile, make one. + services.roundcube.database.passwordFile = mkIf (!localDB && cfg.database.password != "") (mkDefault ("${pkgs.writeText "roundcube-password" cfg.database.password}")); + warnings = lib.optional (!localDB && cfg.database.password != "") "services.roundcube.database.password is deprecated and insecure; use services.roundcube.database.passwordFile instead"; + + environment.etc."roundcube/config.inc.php".text = '' + <?php + + ${lib.optionalString (!localDB) "$password = file_get_contents('${cfg.database.passwordFile}');"} + + $config = array(); + $config['db_dsnw'] = 'pgsql://${cfg.database.username}${lib.optionalString (!localDB) ":' . $password . '"}@${if localDB then "unix(/run/postgresql)" else cfg.database.host}/${cfg.database.dbname}'; + $config['log_driver'] = 'syslog'; + $config['max_message_size'] = '${cfg.maxAttachmentSize}'; + $config['plugins'] = [${concatMapStringsSep "," (p: "'${p}'") cfg.plugins}]; + $config['des_key'] = file_get_contents('/var/lib/roundcube/des_key'); + $config['mime_types'] = '${pkgs.nginx}/conf/mime.types'; + $config['enable_spellcheck'] = ${if cfg.dicts == [] then "false" else "true"}; + # by default, spellchecking uses a third-party cloud services + $config['spellcheck_engine'] = 'pspell'; + $config['spellcheck_languages'] = array(${lib.concatMapStringsSep ", " (dict: let p = builtins.parseDrvName dict.shortName; in "'${p.name}' => '${dict.fullName}'") cfg.dicts}); + + ${cfg.extraConfig} + ''; + + services.nginx = { + enable = true; + virtualHosts = { + ${cfg.hostName} = { + forceSSL = mkDefault true; + enableACME = mkDefault true; + locations."/" = { + root = cfg.package; + index = "index.php"; + extraConfig = '' + location ~* \.php$ { + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:${fpm.socket}; + include ${config.services.nginx.package}/conf/fastcgi_params; + include ${pkgs.nginx}/conf/fastcgi.conf; + } + ''; + }; + }; + }; + }; + + services.postgresql = mkIf localDB { + enable = true; + ensureDatabases = [ cfg.database.dbname ]; + ensureUsers = [ { + name = cfg.database.username; + ensurePermissions = { + "DATABASE ${cfg.database.username}" = "ALL PRIVILEGES"; + }; + } ]; + }; + + users.users.${user} = mkIf localDB { + group = user; + isSystemUser = true; + createHome = false; + }; + users.groups.${user} = mkIf localDB {}; + + services.phpfpm.pools.roundcube = { + user = if localDB then user else "nginx"; + phpOptions = '' + error_log = 'stderr' + log_errors = on + post_max_size = ${cfg.maxAttachmentSize} + upload_max_filesize = ${cfg.maxAttachmentSize} + ''; + settings = mapAttrs (name: mkDefault) { + "listen.owner" = "nginx"; + "listen.group" = "nginx"; + "listen.mode" = "0660"; + "pm" = "dynamic"; + "pm.max_children" = 75; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 1; + "pm.max_spare_servers" = 20; + "pm.max_requests" = 500; + "catch_workers_output" = true; + }; + phpPackage = phpWithPspell; + phpEnv.ASPELL_CONF = "dict-dir ${pkgs.aspellWithDicts (_: cfg.dicts)}/lib/aspell"; + }; + systemd.services.phpfpm-roundcube.after = [ "roundcube-setup.service" ]; + + # Restart on config changes. + systemd.services.phpfpm-roundcube.restartTriggers = [ + config.environment.etc."roundcube/config.inc.php".source + ]; + + systemd.services.roundcube-setup = mkMerge [ + (mkIf (cfg.database.host == "localhost") { + requires = [ "postgresql.service" ]; + after = [ "postgresql.service" ]; + path = [ config.services.postgresql.package ]; + }) + { + wantedBy = [ "multi-user.target" ]; + script = let + psql = "${lib.optionalString (!localDB) "PGPASSFILE=${cfg.database.passwordFile}"} ${pkgs.postgresql}/bin/psql ${lib.optionalString (!localDB) "-h ${cfg.database.host} -U ${cfg.database.username} "} ${cfg.database.dbname}"; + in + '' + version="$(${psql} -t <<< "select value from system where name = 'roundcube-version';" || true)" + if ! (grep -E '[a-zA-Z0-9]' <<< "$version"); then + ${psql} -f ${cfg.package}/SQL/postgres.initial.sql + fi + + if [ ! -f /var/lib/roundcube/des_key ]; then + base64 /dev/urandom | head -c 24 > /var/lib/roundcube/des_key; + # we need to log out everyone in case change the des_key + # from the default when upgrading from nixos 19.09 + ${psql} <<< 'TRUNCATE TABLE session;' + fi + + ${phpWithPspell}/bin/php ${cfg.package}/bin/update.sh + ''; + serviceConfig = { + Type = "oneshot"; + StateDirectory = "roundcube"; + User = if localDB then user else "nginx"; + # so that the des_key is not world readable + StateDirectoryMode = "0700"; + }; + } + ]; + }; +} diff --git a/nixos/modules/services/mail/rspamd.nix b/nixos/modules/services/mail/rspamd.nix new file mode 100644 index 00000000000..a570e137a55 --- /dev/null +++ b/nixos/modules/services/mail/rspamd.nix @@ -0,0 +1,446 @@ +{ config, options, pkgs, lib, ... }: + +with lib; + +let + + cfg = config.services.rspamd; + opt = options.services.rspamd; + postfixCfg = config.services.postfix; + + bindSocketOpts = {options, config, ... }: { + options = { + socket = mkOption { + type = types.str; + example = "localhost:11333"; + description = '' + Socket for this worker to listen on in a format acceptable by rspamd. + ''; + }; + mode = mkOption { + type = types.str; + default = "0644"; + description = "Mode to set on unix socket"; + }; + owner = mkOption { + type = types.str; + default = "${cfg.user}"; + description = "Owner to set on unix socket"; + }; + group = mkOption { + type = types.str; + default = "${cfg.group}"; + description = "Group to set on unix socket"; + }; + rawEntry = mkOption { + type = types.str; + internal = true; + }; + }; + config.rawEntry = let + maybeOption = option: + optionalString options.${option}.isDefined " ${option}=${config.${option}}"; + in + if (!(hasPrefix "/" config.socket)) then "${config.socket}" + else "${config.socket}${maybeOption "mode"}${maybeOption "owner"}${maybeOption "group"}"; + }; + + traceWarning = w: x: builtins.trace "[1;31mwarning: ${w}[0m" x; + + workerOpts = { name, options, ... }: { + options = { + enable = mkOption { + type = types.nullOr types.bool; + default = null; + description = "Whether to run the rspamd worker."; + }; + name = mkOption { + type = types.nullOr types.str; + default = name; + description = "Name of the worker"; + }; + type = mkOption { + type = types.nullOr (types.enum [ + "normal" "controller" "fuzzy" "rspamd_proxy" "lua" "proxy" + ]); + description = '' + The type of this worker. The type <literal>proxy</literal> is + deprecated and only kept for backwards compatibility and should be + replaced with <literal>rspamd_proxy</literal>. + ''; + apply = let + from = "services.rspamd.workers.\"${name}\".type"; + files = options.type.files; + warning = "The option `${from}` defined in ${showFiles files} has enum value `proxy` which has been renamed to `rspamd_proxy`"; + in x: if x == "proxy" then traceWarning warning "rspamd_proxy" else x; + }; + bindSockets = mkOption { + type = types.listOf (types.either types.str (types.submodule bindSocketOpts)); + default = []; + description = '' + List of sockets to listen, in format acceptable by rspamd + ''; + example = [{ + socket = "/run/rspamd.sock"; + mode = "0666"; + owner = "rspamd"; + } "*:11333"]; + apply = value: map (each: if (isString each) + then if (isUnixSocket each) + then {socket = each; owner = cfg.user; group = cfg.group; mode = "0644"; rawEntry = "${each}";} + else {socket = each; rawEntry = "${each}";} + else each) value; + }; + count = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + Number of worker instances to run + ''; + }; + includes = mkOption { + type = types.listOf types.str; + default = []; + description = '' + List of files to include in configuration + ''; + }; + extraConfig = mkOption { + type = types.lines; + default = ""; + description = "Additional entries to put verbatim into worker section of rspamd config file."; + }; + }; + config = mkIf (name == "normal" || name == "controller" || name == "fuzzy" || name == "rspamd_proxy") { + type = mkDefault name; + includes = mkDefault [ "$CONFDIR/worker-${if name == "rspamd_proxy" then "proxy" else name}.inc" ]; + bindSockets = + let + unixSocket = name: { + mode = "0660"; + socket = "/run/rspamd/${name}.sock"; + owner = cfg.user; + group = cfg.group; + }; + in mkDefault (if name == "normal" then [(unixSocket "rspamd")] + else if name == "controller" then [ "localhost:11334" ] + else if name == "rspamd_proxy" then [ (unixSocket "proxy") ] + else [] ); + }; + }; + + isUnixSocket = socket: hasPrefix "/" (if (isString socket) then socket else socket.socket); + + mkBindSockets = enabled: socks: concatStringsSep "\n " + (flatten (map (each: "bind_socket = \"${each.rawEntry}\";") socks)); + + rspamdConfFile = pkgs.writeText "rspamd.conf" + '' + .include "$CONFDIR/common.conf" + + options { + pidfile = "$RUNDIR/rspamd.pid"; + .include "$CONFDIR/options.inc" + .include(try=true; priority=1,duplicate=merge) "$LOCAL_CONFDIR/local.d/options.inc" + .include(try=true; priority=10) "$LOCAL_CONFDIR/override.d/options.inc" + } + + logging { + type = "syslog"; + .include "$CONFDIR/logging.inc" + .include(try=true; priority=1,duplicate=merge) "$LOCAL_CONFDIR/local.d/logging.inc" + .include(try=true; priority=10) "$LOCAL_CONFDIR/override.d/logging.inc" + } + + ${concatStringsSep "\n" (mapAttrsToList (name: value: let + includeName = if name == "rspamd_proxy" then "proxy" else name; + tryOverride = boolToString (value.extraConfig == ""); + in '' + worker "${value.type}" { + type = "${value.type}"; + ${optionalString (value.enable != null) + "enabled = ${if value.enable != false then "yes" else "no"};"} + ${mkBindSockets value.enable value.bindSockets} + ${optionalString (value.count != null) "count = ${toString value.count};"} + ${concatStringsSep "\n " (map (each: ".include \"${each}\"") value.includes)} + .include(try=true; priority=1,duplicate=merge) "$LOCAL_CONFDIR/local.d/worker-${includeName}.inc" + .include(try=${tryOverride}; priority=10) "$LOCAL_CONFDIR/override.d/worker-${includeName}.inc" + } + '') cfg.workers)} + + ${optionalString (cfg.extraConfig != "") '' + .include(priority=10) "$LOCAL_CONFDIR/override.d/extra-config.inc" + ''} + ''; + + filterFiles = files: filterAttrs (n: v: v.enable) files; + rspamdDir = pkgs.linkFarm "etc-rspamd-dir" ( + (mapAttrsToList (name: file: { name = "local.d/${name}"; path = file.source; }) (filterFiles cfg.locals)) ++ + (mapAttrsToList (name: file: { name = "override.d/${name}"; path = file.source; }) (filterFiles cfg.overrides)) ++ + (optional (cfg.localLuaRules != null) { name = "rspamd.local.lua"; path = cfg.localLuaRules; }) ++ + [ { name = "rspamd.conf"; path = rspamdConfFile; } ] + ); + + configFileModule = prefix: { name, config, ... }: { + options = { + enable = mkOption { + type = types.bool; + default = true; + description = '' + Whether this file ${prefix} should be generated. This + option allows specific ${prefix} files to be disabled. + ''; + }; + + text = mkOption { + default = null; + type = types.nullOr types.lines; + description = "Text of the file."; + }; + + source = mkOption { + type = types.path; + description = "Path of the source file."; + }; + }; + config = { + source = mkIf (config.text != null) ( + let name' = "rspamd-${prefix}-" + baseNameOf name; + in mkDefault (pkgs.writeText name' config.text)); + }; + }; + + configOverrides = + (mapAttrs' (n: v: nameValuePair "worker-${if n == "rspamd_proxy" then "proxy" else n}.inc" { + text = v.extraConfig; + }) + (filterAttrs (n: v: v.extraConfig != "") cfg.workers)) + // (if cfg.extraConfig == "" then {} else { + "extra-config.inc".text = cfg.extraConfig; + }); +in + +{ + ###### interface + + options = { + + services.rspamd = { + + enable = mkEnableOption "rspamd, the Rapid spam filtering system"; + + debug = mkOption { + type = types.bool; + default = false; + description = "Whether to run the rspamd daemon in debug mode."; + }; + + locals = mkOption { + type = with types; attrsOf (submodule (configFileModule "locals")); + default = {}; + description = '' + Local configuration files, written into <filename>/etc/rspamd/local.d/{name}</filename>. + ''; + example = literalExpression '' + { "redis.conf".source = "/nix/store/.../etc/dir/redis.conf"; + "arc.conf".text = "allow_envfrom_empty = true;"; + } + ''; + }; + + overrides = mkOption { + type = with types; attrsOf (submodule (configFileModule "overrides")); + default = {}; + description = '' + Overridden configuration files, written into <filename>/etc/rspamd/override.d/{name}</filename>. + ''; + example = literalExpression '' + { "redis.conf".source = "/nix/store/.../etc/dir/redis.conf"; + "arc.conf".text = "allow_envfrom_empty = true;"; + } + ''; + }; + + localLuaRules = mkOption { + default = null; + type = types.nullOr types.path; + description = '' + Path of file to link to <filename>/etc/rspamd/rspamd.local.lua</filename> for local + rules written in Lua + ''; + }; + + workers = mkOption { + type = with types; attrsOf (submodule workerOpts); + description = '' + Attribute set of workers to start. + ''; + default = { + normal = {}; + controller = {}; + }; + example = literalExpression '' + { + normal = { + includes = [ "$CONFDIR/worker-normal.inc" ]; + bindSockets = [{ + socket = "/run/rspamd/rspamd.sock"; + mode = "0660"; + owner = "''${config.${opt.user}}"; + group = "''${config.${opt.group}}"; + }]; + }; + controller = { + includes = [ "$CONFDIR/worker-controller.inc" ]; + bindSockets = [ "[::1]:11334" ]; + }; + } + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = '' + Extra configuration to add at the end of the rspamd configuration + file. + ''; + }; + + user = mkOption { + type = types.str; + default = "rspamd"; + description = '' + User to use when no root privileges are required. + ''; + }; + + group = mkOption { + type = types.str; + default = "rspamd"; + description = '' + Group to use when no root privileges are required. + ''; + }; + + postfix = { + enable = mkOption { + type = types.bool; + default = false; + description = "Add rspamd milter to postfix main.conf"; + }; + + config = mkOption { + type = with types; attrsOf (oneOf [ bool str (listOf str) ]); + description = '' + Addon to postfix configuration + ''; + default = { + smtpd_milters = ["unix:/run/rspamd/rspamd-milter.sock"]; + non_smtpd_milters = ["unix:/run/rspamd/rspamd-milter.sock"]; + }; + }; + }; + }; + }; + + + ###### implementation + + config = mkIf cfg.enable { + services.rspamd.overrides = configOverrides; + services.rspamd.workers = mkIf cfg.postfix.enable { + controller = {}; + rspamd_proxy = { + bindSockets = [ { + mode = "0660"; + socket = "/run/rspamd/rspamd-milter.sock"; + owner = cfg.user; + group = postfixCfg.group; + } ]; + extraConfig = '' + upstream "local" { + default = yes; # Self-scan upstreams are always default + self_scan = yes; # Enable self-scan + } + ''; + }; + }; + services.postfix.config = mkIf cfg.postfix.enable cfg.postfix.config; + + systemd.services.postfix = mkIf cfg.postfix.enable { + serviceConfig.SupplementaryGroups = [ postfixCfg.group ]; + }; + + # Allow users to run 'rspamc' and 'rspamadm'. + environment.systemPackages = [ pkgs.rspamd ]; + + users.users.${cfg.user} = { + description = "rspamd daemon"; + uid = config.ids.uids.rspamd; + group = cfg.group; + }; + + users.groups.${cfg.group} = { + gid = config.ids.gids.rspamd; + }; + + environment.etc.rspamd.source = rspamdDir; + + systemd.services.rspamd = { + description = "Rspamd Service"; + + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + restartTriggers = [ rspamdDir ]; + + serviceConfig = { + ExecStart = "${pkgs.rspamd}/bin/rspamd ${optionalString cfg.debug "-d"} -c /etc/rspamd/rspamd.conf -f"; + Restart = "always"; + + User = "${cfg.user}"; + Group = "${cfg.group}"; + SupplementaryGroups = mkIf cfg.postfix.enable [ postfixCfg.group ]; + + RuntimeDirectory = "rspamd"; + RuntimeDirectoryMode = "0755"; + StateDirectory = "rspamd"; + StateDirectoryMode = "0700"; + + AmbientCapabilities = []; + CapabilityBoundingSet = ""; + DevicePolicy = "closed"; + LockPersonality = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateTmp = true; + # we need to chown socket to rspamd-milter + PrivateUsers = !cfg.postfix.enable; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = "@system-service"; + UMask = "0077"; + }; + }; + }; + imports = [ + (mkRemovedOptionModule [ "services" "rspamd" "socketActivation" ] + "Socket activation never worked correctly and could at this time not be fixed and so was removed") + (mkRenamedOptionModule [ "services" "rspamd" "bindSocket" ] [ "services" "rspamd" "workers" "normal" "bindSockets" ]) + (mkRenamedOptionModule [ "services" "rspamd" "bindUISocket" ] [ "services" "rspamd" "workers" "controller" "bindSockets" ]) + (mkRemovedOptionModule [ "services" "rmilter" ] "Use services.rspamd.* instead to set up milter service") + ]; +} diff --git a/nixos/modules/services/mail/rss2email.nix b/nixos/modules/services/mail/rss2email.nix new file mode 100644 index 00000000000..7f8d2adac64 --- /dev/null +++ b/nixos/modules/services/mail/rss2email.nix @@ -0,0 +1,135 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.rss2email; +in { + + ###### interface + + options = { + + services.rss2email = { + + enable = mkOption { + type = types.bool; + default = false; + description = "Whether to enable rss2email."; + }; + + to = mkOption { + type = types.str; + description = "Mail address to which to send emails"; + }; + + interval = mkOption { + type = types.str; + default = "12h"; + description = "How often to check the feeds, in systemd interval format"; + }; + + config = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = {}; + description = '' + The configuration to give rss2email. + + Default will use system-wide <literal>sendmail</literal> to send the + email. This is rss2email's default when running + <literal>r2e new</literal>. + + This set contains key-value associations that will be set in the + <literal>[DEFAULT]</literal> block along with the + <literal>to</literal> parameter. + + See <literal>man r2e</literal> for more information on which + parameters are accepted. + ''; + }; + + feeds = mkOption { + description = "The feeds to watch."; + type = types.attrsOf (types.submodule { + options = { + url = mkOption { + type = types.str; + description = "The URL at which to fetch the feed."; + }; + + to = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Email address to which to send feed items. + + If <literal>null</literal>, this will not be set in the + configuration file, and rss2email will make it default to + <literal>rss2email.to</literal>. + ''; + }; + }; + }); + }; + }; + + }; + + + ###### implementation + + config = mkIf cfg.enable { + users.groups = { + rss2email.gid = config.ids.gids.rss2email; + }; + + users.users = { + rss2email = { + description = "rss2email user"; + uid = config.ids.uids.rss2email; + group = "rss2email"; + }; + }; + + environment.systemPackages = with pkgs; [ rss2email ]; + + services.rss2email.config.to = cfg.to; + + systemd.tmpfiles.rules = [ + "d /var/rss2email 0700 rss2email rss2email - -" + ]; + + systemd.services.rss2email = let + conf = pkgs.writeText "rss2email.cfg" (lib.generators.toINI {} ({ + DEFAULT = cfg.config; + } // lib.mapAttrs' (name: feed: nameValuePair "feed.${name}" ( + { inherit (feed) url; } // + lib.optionalAttrs (feed.to != null) { inherit (feed) to; } + )) cfg.feeds + )); + in + { + preStart = '' + cp ${conf} /var/rss2email/conf.cfg + if [ ! -f /var/rss2email/db.json ]; then + echo '{"version":2,"feeds":[]}' > /var/rss2email/db.json + fi + ''; + path = [ pkgs.system-sendmail ]; + serviceConfig = { + ExecStart = + "${pkgs.rss2email}/bin/r2e -c /var/rss2email/conf.cfg -d /var/rss2email/db.json run"; + User = "rss2email"; + }; + }; + + systemd.timers.rss2email = { + partOf = [ "rss2email.service" ]; + wantedBy = [ "timers.target" ]; + timerConfig.OnBootSec = "0"; + timerConfig.OnUnitActiveSec = cfg.interval; + }; + }; + + meta.maintainers = with lib.maintainers; [ ekleog ]; +} diff --git a/nixos/modules/services/mail/spamassassin.nix b/nixos/modules/services/mail/spamassassin.nix new file mode 100644 index 00000000000..ac878222b26 --- /dev/null +++ b/nixos/modules/services/mail/spamassassin.nix @@ -0,0 +1,191 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.spamassassin; + spamassassin-local-cf = pkgs.writeText "local.cf" cfg.config; + +in + +{ + options = { + + services.spamassassin = { + enable = mkEnableOption "the SpamAssassin daemon"; + + debug = mkOption { + type = types.bool; + default = false; + description = "Whether to run the SpamAssassin daemon in debug mode"; + }; + + config = mkOption { + type = types.lines; + description = '' + The SpamAssassin local.cf config + + If you are using this configuration: + add_header all Status _YESNO_, score=_SCORE_ required=_REQD_ tests=_TESTS_ autolearn=_AUTOLEARN_ version=_VERSION_ + + Then you can Use this sieve filter: + require ["fileinto", "reject", "envelope"]; + + if header :contains "X-Spam-Flag" "YES" { + fileinto "spam"; + } + + Or this procmail filter: + :0: + * ^X-Spam-Flag: YES + /var/vpopmail/domains/lastlog.de/js/.maildir/.spam/new + + To filter your messages based on the additional mail headers added by spamassassin. + ''; + example = '' + #rewrite_header Subject [***** SPAM _SCORE_ *****] + required_score 5.0 + use_bayes 1 + bayes_auto_learn 1 + add_header all Status _YESNO_, score=_SCORE_ required=_REQD_ tests=_TESTS_ autolearn=_AUTOLEARN_ version=_VERSION_ + ''; + default = ""; + }; + + initPreConf = mkOption { + type = with types; either str path; + description = "The SpamAssassin init.pre config."; + apply = val: if builtins.isPath val then val else pkgs.writeText "init.pre" val; + default = + '' + # + # to update this list, run this command in the rules directory: + # grep 'loadplugin.*Mail::SpamAssassin::Plugin::.*' -o -h * | sort | uniq + # + + #loadplugin Mail::SpamAssassin::Plugin::AccessDB + #loadplugin Mail::SpamAssassin::Plugin::AntiVirus + loadplugin Mail::SpamAssassin::Plugin::AskDNS + # loadplugin Mail::SpamAssassin::Plugin::ASN + loadplugin Mail::SpamAssassin::Plugin::AutoLearnThreshold + #loadplugin Mail::SpamAssassin::Plugin::AWL + loadplugin Mail::SpamAssassin::Plugin::Bayes + loadplugin Mail::SpamAssassin::Plugin::BodyEval + loadplugin Mail::SpamAssassin::Plugin::Check + #loadplugin Mail::SpamAssassin::Plugin::DCC + loadplugin Mail::SpamAssassin::Plugin::DKIM + loadplugin Mail::SpamAssassin::Plugin::DNSEval + loadplugin Mail::SpamAssassin::Plugin::FreeMail + loadplugin Mail::SpamAssassin::Plugin::Hashcash + loadplugin Mail::SpamAssassin::Plugin::HeaderEval + loadplugin Mail::SpamAssassin::Plugin::HTMLEval + loadplugin Mail::SpamAssassin::Plugin::HTTPSMismatch + loadplugin Mail::SpamAssassin::Plugin::ImageInfo + loadplugin Mail::SpamAssassin::Plugin::MIMEEval + loadplugin Mail::SpamAssassin::Plugin::MIMEHeader + # loadplugin Mail::SpamAssassin::Plugin::PDFInfo + #loadplugin Mail::SpamAssassin::Plugin::PhishTag + loadplugin Mail::SpamAssassin::Plugin::Pyzor + loadplugin Mail::SpamAssassin::Plugin::Razor2 + # loadplugin Mail::SpamAssassin::Plugin::RelayCountry + loadplugin Mail::SpamAssassin::Plugin::RelayEval + loadplugin Mail::SpamAssassin::Plugin::ReplaceTags + # loadplugin Mail::SpamAssassin::Plugin::Rule2XSBody + # loadplugin Mail::SpamAssassin::Plugin::Shortcircuit + loadplugin Mail::SpamAssassin::Plugin::SpamCop + loadplugin Mail::SpamAssassin::Plugin::SPF + #loadplugin Mail::SpamAssassin::Plugin::TextCat + # loadplugin Mail::SpamAssassin::Plugin::TxRep + loadplugin Mail::SpamAssassin::Plugin::URIDetail + loadplugin Mail::SpamAssassin::Plugin::URIDNSBL + loadplugin Mail::SpamAssassin::Plugin::URIEval + # loadplugin Mail::SpamAssassin::Plugin::URILocalBL + loadplugin Mail::SpamAssassin::Plugin::VBounce + loadplugin Mail::SpamAssassin::Plugin::WhiteListSubject + loadplugin Mail::SpamAssassin::Plugin::WLBLEval + ''; + }; + }; + }; + + config = mkIf cfg.enable { + environment.etc."mail/spamassassin/init.pre".source = cfg.initPreConf; + environment.etc."mail/spamassassin/local.cf".source = spamassassin-local-cf; + + # Allow users to run 'spamc'. + environment.systemPackages = [ pkgs.spamassassin ]; + + users.users.spamd = { + description = "Spam Assassin Daemon"; + uid = config.ids.uids.spamd; + group = "spamd"; + }; + + users.groups.spamd = { + gid = config.ids.gids.spamd; + }; + + systemd.services.sa-update = { + # Needs to be able to contact the update server. + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + serviceConfig = { + Type = "oneshot"; + User = "spamd"; + Group = "spamd"; + StateDirectory = "spamassassin"; + ExecStartPost = "+${pkgs.systemd}/bin/systemctl -q --no-block try-reload-or-restart spamd.service"; + }; + + script = '' + set +e + ${pkgs.spamassassin}/bin/sa-update --verbose --gpghomedir=/var/lib/spamassassin/sa-update-keys/ + rc=$? + set -e + + if [[ $rc -gt 1 ]]; then + # sa-update failed. + exit $rc + fi + + if [[ $rc -eq 1 ]]; then + # No update was available, exit successfully. + exit 0 + fi + + # An update was available and installed. Compile the rules. + ${pkgs.spamassassin}/bin/sa-compile + ''; + }; + + systemd.timers.sa-update = { + description = "sa-update-service"; + partOf = [ "sa-update.service" ]; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "1:*"; + Persistent = true; + }; + }; + + systemd.services.spamd = { + description = "SpamAssassin Server"; + + wantedBy = [ "multi-user.target" ]; + wants = [ "sa-update.service" ]; + after = [ + "network.target" + "sa-update.service" + ]; + + serviceConfig = { + User = "spamd"; + Group = "spamd"; + ExecStart = "+${pkgs.spamassassin}/bin/spamd ${optionalString cfg.debug "-D"} --username=spamd --groupname=spamd --virtual-config-dir=%S/spamassassin/user-%u --allow-tell --pidfile=/run/spamd.pid"; + ExecReload = "+${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + StateDirectory = "spamassassin"; + }; + }; + }; +} diff --git a/nixos/modules/services/mail/sympa.nix b/nixos/modules/services/mail/sympa.nix new file mode 100644 index 00000000000..f3578bef96e --- /dev/null +++ b/nixos/modules/services/mail/sympa.nix @@ -0,0 +1,590 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.sympa; + dataDir = "/var/lib/sympa"; + user = "sympa"; + group = "sympa"; + pkg = pkgs.sympa; + fqdns = attrNames cfg.domains; + usingNginx = cfg.web.enable && cfg.web.server == "nginx"; + mysqlLocal = cfg.database.createLocally && cfg.database.type == "MySQL"; + pgsqlLocal = cfg.database.createLocally && cfg.database.type == "PostgreSQL"; + + sympaSubServices = [ + "sympa-archive.service" + "sympa-bounce.service" + "sympa-bulk.service" + "sympa-task.service" + ]; + + # common for all services including wwsympa + commonServiceConfig = { + StateDirectory = "sympa"; + ProtectHome = true; + ProtectSystem = "full"; + ProtectControlGroups = true; + }; + + # wwsympa has its own service config + sympaServiceConfig = srv: { + Type = "simple"; + Restart = "always"; + ExecStart = "${pkg}/bin/${srv}.pl --foreground"; + PIDFile = "/run/sympa/${srv}.pid"; + User = user; + Group = group; + + # avoid duplicating log messageges in journal + StandardError = "null"; + } // commonServiceConfig; + + configVal = value: + if isBool value then + if value then "on" else "off" + else toString value; + configGenerator = c: concatStrings (flip mapAttrsToList c (key: val: "${key}\t${configVal val}\n")); + + mainConfig = pkgs.writeText "sympa.conf" (configGenerator cfg.settings); + robotConfig = fqdn: domain: pkgs.writeText "${fqdn}-robot.conf" (configGenerator domain.settings); + + transport = pkgs.writeText "transport.sympa" (concatStringsSep "\n" (flip map fqdns (domain: '' + ${domain} error:User unknown in recipient table + sympa@${domain} sympa:sympa@${domain} + listmaster@${domain} sympa:listmaster@${domain} + bounce@${domain} sympabounce:sympa@${domain} + abuse-feedback-report@${domain} sympabounce:sympa@${domain} + ''))); + + virtual = pkgs.writeText "virtual.sympa" (concatStringsSep "\n" (flip map fqdns (domain: '' + sympa-request@${domain} postmaster@localhost + sympa-owner@${domain} postmaster@localhost + ''))); + + listAliases = pkgs.writeText "list_aliases.tt2" '' + #--- [% list.name %]@[% list.domain %]: list transport map created at [% date %] + [% list.name %]@[% list.domain %] sympa:[% list.name %]@[% list.domain %] + [% list.name %]-request@[% list.domain %] sympa:[% list.name %]-request@[% list.domain %] + [% list.name %]-editor@[% list.domain %] sympa:[% list.name %]-editor@[% list.domain %] + #[% list.name %]-subscribe@[% list.domain %] sympa:[% list.name %]-subscribe@[%list.domain %] + [% list.name %]-unsubscribe@[% list.domain %] sympa:[% list.name %]-unsubscribe@[% list.domain %] + [% list.name %][% return_path_suffix %]@[% list.domain %] sympabounce:[% list.name %]@[% list.domain %] + ''; + + enabledFiles = filterAttrs (n: v: v.enable) cfg.settingsFile; +in +{ + + ###### interface + options.services.sympa = with types; { + + enable = mkEnableOption "Sympa mailing list manager"; + + lang = mkOption { + type = str; + default = "en_US"; + example = "cs"; + description = '' + Default Sympa language. + See <link xlink:href='https://github.com/sympa-community/sympa/tree/sympa-6.2/po/sympa' /> + for available options. + ''; + }; + + listMasters = mkOption { + type = listOf str; + example = [ "postmaster@sympa.example.org" ]; + description = '' + The list of the email addresses of the listmasters + (users authorized to perform global server commands). + ''; + }; + + mainDomain = mkOption { + type = nullOr str; + default = null; + example = "lists.example.org"; + description = '' + Main domain to be used in <filename>sympa.conf</filename>. + If <literal>null</literal>, one of the <option>services.sympa.domains</option> is chosen for you. + ''; + }; + + domains = mkOption { + type = attrsOf (submodule ({ name, config, ... }: { + options = { + webHost = mkOption { + type = nullOr str; + default = null; + example = "archive.example.org"; + description = '' + Domain part of the web interface URL (no web interface for this domain if <literal>null</literal>). + DNS record of type A (or AAAA or CNAME) has to exist with this value. + ''; + }; + webLocation = mkOption { + type = str; + default = "/"; + example = "/sympa"; + description = "URL path part of the web interface."; + }; + settings = mkOption { + type = attrsOf (oneOf [ str int bool ]); + default = {}; + example = { + default_max_list_members = 3; + }; + description = '' + The <filename>robot.conf</filename> configuration file as key value set. + See <link xlink:href='https://sympa-community.github.io/gpldoc/man/sympa.conf.5.html' /> + for list of configuration parameters. + ''; + }; + }; + + config.settings = mkIf (cfg.web.enable && config.webHost != null) { + wwsympa_url = mkDefault "https://${config.webHost}${strings.removeSuffix "/" config.webLocation}"; + }; + })); + + description = '' + Email domains handled by this instance. There have + to be MX records for keys of this attribute set. + ''; + example = literalExpression '' + { + "lists.example.org" = { + webHost = "lists.example.org"; + webLocation = "/"; + }; + "sympa.example.com" = { + webHost = "example.com"; + webLocation = "/sympa"; + }; + } + ''; + }; + + database = { + type = mkOption { + type = enum [ "SQLite" "PostgreSQL" "MySQL" ]; + default = "SQLite"; + example = "MySQL"; + description = "Database engine to use."; + }; + + host = mkOption { + type = nullOr str; + default = null; + description = '' + Database host address. + + For MySQL, use <literal>localhost</literal> to connect using Unix domain socket. + + For PostgreSQL, use path to directory (e.g. <filename>/run/postgresql</filename>) + to connect using Unix domain socket located in this directory. + + Use <literal>null</literal> to fall back on Sympa default, or when using + <option>services.sympa.database.createLocally</option>. + ''; + }; + + port = mkOption { + type = nullOr port; + default = null; + description = "Database port. Use <literal>null</literal> for default port."; + }; + + name = mkOption { + type = str; + default = if cfg.database.type == "SQLite" then "${dataDir}/sympa.sqlite" else "sympa"; + defaultText = literalExpression ''if database.type == "SQLite" then "${dataDir}/sympa.sqlite" else "sympa"''; + description = '' + Database name. When using SQLite this must be an absolute + path to the database file. + ''; + }; + + user = mkOption { + type = nullOr str; + default = user; + description = "Database user. The system user name is used as a default."; + }; + + passwordFile = mkOption { + type = nullOr path; + default = null; + example = "/run/keys/sympa-dbpassword"; + description = '' + A file containing the password for <option>services.sympa.database.user</option>. + ''; + }; + + createLocally = mkOption { + type = bool; + default = true; + description = "Whether to create a local database automatically."; + }; + }; + + web = { + enable = mkOption { + type = bool; + default = true; + description = "Whether to enable Sympa web interface."; + }; + + server = mkOption { + type = enum [ "nginx" "none" ]; + default = "nginx"; + description = '' + The webserver used for the Sympa web interface. Set it to `none` if you want to configure it yourself. + Further nginx configuration can be done by adapting + <option>services.nginx.virtualHosts.<replaceable>name</replaceable></option>. + ''; + }; + + https = mkOption { + type = bool; + default = true; + description = '' + Whether to use HTTPS. When nginx integration is enabled, this option forces SSL and enables ACME. + Please note that Sympa web interface always uses https links even when this option is disabled. + ''; + }; + + fcgiProcs = mkOption { + type = ints.positive; + default = 2; + description = "Number of FastCGI processes to fork."; + }; + }; + + mta = { + type = mkOption { + type = enum [ "postfix" "none" ]; + default = "postfix"; + description = '' + Mail transfer agent (MTA) integration. Use <literal>none</literal> if you want to configure it yourself. + + The <literal>postfix</literal> integration sets up local Postfix instance that will pass incoming + messages from configured domains to Sympa. You still need to configure at least outgoing message + handling using e.g. <option>services.postfix.relayHost</option>. + ''; + }; + }; + + settings = mkOption { + type = attrsOf (oneOf [ str int bool ]); + default = {}; + example = literalExpression '' + { + default_home = "lists"; + viewlogs_page_size = 50; + } + ''; + description = '' + The <filename>sympa.conf</filename> configuration file as key value set. + See <link xlink:href='https://sympa-community.github.io/gpldoc/man/sympa.conf.5.html' /> + for list of configuration parameters. + ''; + }; + + settingsFile = mkOption { + type = attrsOf (submodule ({ name, config, ... }: { + options = { + enable = mkOption { + type = bool; + default = true; + description = "Whether this file should be generated. This option allows specific files to be disabled."; + }; + text = mkOption { + default = null; + type = nullOr lines; + description = "Text of the file."; + }; + source = mkOption { + type = path; + description = "Path of the source file."; + }; + }; + + config.source = mkIf (config.text != null) (mkDefault (pkgs.writeText "sympa-${baseNameOf name}" config.text)); + })); + default = {}; + example = literalExpression '' + { + "list_data/lists.example.org/help" = { + text = "subject This list provides help to users"; + }; + } + ''; + description = "Set of files to be linked in <filename>${dataDir}</filename>."; + }; + }; + + ###### implementation + + config = mkIf cfg.enable { + + services.sympa.settings = (mapAttrs (_: v: mkDefault v) { + domain = if cfg.mainDomain != null then cfg.mainDomain else head fqdns; + listmaster = concatStringsSep "," cfg.listMasters; + lang = cfg.lang; + + home = "${dataDir}/list_data"; + arc_path = "${dataDir}/arc"; + bounce_path = "${dataDir}/bounce"; + + sendmail = "${pkgs.system-sendmail}/bin/sendmail"; + + db_type = cfg.database.type; + db_name = cfg.database.name; + } + // (optionalAttrs (cfg.database.host != null) { + db_host = cfg.database.host; + }) + // (optionalAttrs mysqlLocal { + db_host = "localhost"; # use unix domain socket + }) + // (optionalAttrs pgsqlLocal { + db_host = "/run/postgresql"; # use unix domain socket + }) + // (optionalAttrs (cfg.database.port != null) { + db_port = cfg.database.port; + }) + // (optionalAttrs (cfg.database.user != null) { + db_user = cfg.database.user; + }) + // (optionalAttrs (cfg.mta.type == "postfix") { + sendmail_aliases = "${dataDir}/sympa_transport"; + aliases_program = "${pkgs.postfix}/bin/postmap"; + aliases_db_type = "hash"; + }) + // (optionalAttrs cfg.web.enable { + static_content_path = "${dataDir}/static_content"; + css_path = "${dataDir}/static_content/css"; + pictures_path = "${dataDir}/static_content/pictures"; + mhonarc = "${pkgs.perlPackages.MHonArc}/bin/mhonarc"; + })); + + services.sympa.settingsFile = { + "virtual.sympa" = mkDefault { source = virtual; }; + "transport.sympa" = mkDefault { source = transport; }; + "etc/list_aliases.tt2" = mkDefault { source = listAliases; }; + } + // (flip mapAttrs' cfg.domains (fqdn: domain: + nameValuePair "etc/${fqdn}/robot.conf" (mkDefault { source = robotConfig fqdn domain; }))); + + environment = { + systemPackages = [ pkg ]; + }; + + users.users.${user} = { + description = "Sympa mailing list manager user"; + group = group; + home = dataDir; + createHome = false; + isSystemUser = true; + }; + + users.groups.${group} = {}; + + assertions = [ + { assertion = cfg.database.createLocally -> cfg.database.user == user; + message = "services.sympa.database.user must be set to ${user} if services.sympa.database.createLocally is set to true"; + } + { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; + message = "a password cannot be specified if services.sympa.database.createLocally is set to true"; + } + ]; + + systemd.tmpfiles.rules = [ + "d ${dataDir} 0711 ${user} ${group} - -" + "d ${dataDir}/etc 0700 ${user} ${group} - -" + "d ${dataDir}/spool 0700 ${user} ${group} - -" + "d ${dataDir}/list_data 0700 ${user} ${group} - -" + "d ${dataDir}/arc 0700 ${user} ${group} - -" + "d ${dataDir}/bounce 0700 ${user} ${group} - -" + "f ${dataDir}/sympa_transport 0600 ${user} ${group} - -" + + # force-copy static_content so it's up to date with package + # set permissions for wwsympa which needs write access (...) + "R ${dataDir}/static_content - - - - -" + "C ${dataDir}/static_content 0711 ${user} ${group} - ${pkg}/var/lib/sympa/static_content" + "e ${dataDir}/static_content/* 0711 ${user} ${group} - -" + + "d /run/sympa 0755 ${user} ${group} - -" + ] + ++ (flip concatMap fqdns (fqdn: [ + "d ${dataDir}/etc/${fqdn} 0700 ${user} ${group} - -" + "d ${dataDir}/list_data/${fqdn} 0700 ${user} ${group} - -" + ])) + #++ (flip mapAttrsToList enabledFiles (k: v: + # "L+ ${dataDir}/${k} - - - - ${v.source}" + #)) + ++ (concatLists (flip mapAttrsToList enabledFiles (k: v: [ + # sympa doesn't handle symlinks well (e.g. fails to create locks) + # force-copy instead + "R ${dataDir}/${k} - - - - -" + "C ${dataDir}/${k} 0700 ${user} ${group} - ${v.source}" + ]))); + + systemd.services.sympa = { + description = "Sympa mailing list manager"; + + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = sympaSubServices; + before = sympaSubServices; + serviceConfig = sympaServiceConfig "sympa_msg"; + + preStart = '' + umask 0077 + + cp -f ${mainConfig} ${dataDir}/etc/sympa.conf + ${optionalString (cfg.database.passwordFile != null) '' + chmod u+w ${dataDir}/etc/sympa.conf + echo -n "db_passwd " >> ${dataDir}/etc/sympa.conf + cat ${cfg.database.passwordFile} >> ${dataDir}/etc/sympa.conf + ''} + + ${optionalString (cfg.mta.type == "postfix") '' + ${pkgs.postfix}/bin/postmap hash:${dataDir}/virtual.sympa + ${pkgs.postfix}/bin/postmap hash:${dataDir}/transport.sympa + ''} + ${pkg}/bin/sympa_newaliases.pl + ${pkg}/bin/sympa.pl --health_check + ''; + }; + systemd.services.sympa-archive = { + description = "Sympa mailing list manager (archiving)"; + bindsTo = [ "sympa.service" ]; + serviceConfig = sympaServiceConfig "archived"; + }; + systemd.services.sympa-bounce = { + description = "Sympa mailing list manager (bounce processing)"; + bindsTo = [ "sympa.service" ]; + serviceConfig = sympaServiceConfig "bounced"; + }; + systemd.services.sympa-bulk = { + description = "Sympa mailing list manager (message distribution)"; + bindsTo = [ "sympa.service" ]; + serviceConfig = sympaServiceConfig "bulk"; + }; + systemd.services.sympa-task = { + description = "Sympa mailing list manager (task management)"; + bindsTo = [ "sympa.service" ]; + serviceConfig = sympaServiceConfig "task_manager"; + }; + + systemd.services.wwsympa = mkIf usingNginx { + wantedBy = [ "multi-user.target" ]; + after = [ "sympa.service" ]; + serviceConfig = { + Type = "forking"; + PIDFile = "/run/sympa/wwsympa.pid"; + Restart = "always"; + ExecStart = ''${pkgs.spawn_fcgi}/bin/spawn-fcgi \ + -u ${user} \ + -g ${group} \ + -U nginx \ + -M 0600 \ + -F ${toString cfg.web.fcgiProcs} \ + -P /run/sympa/wwsympa.pid \ + -s /run/sympa/wwsympa.socket \ + -- ${pkg}/lib/sympa/cgi/wwsympa.fcgi + ''; + + } // commonServiceConfig; + }; + + services.nginx.enable = mkIf usingNginx true; + services.nginx.virtualHosts = mkIf usingNginx (let + vHosts = unique (remove null (mapAttrsToList (_k: v: v.webHost) cfg.domains)); + hostLocations = host: map (v: v.webLocation) (filter (v: v.webHost == host) (attrValues cfg.domains)); + httpsOpts = optionalAttrs cfg.web.https { forceSSL = mkDefault true; enableACME = mkDefault true; }; + in + genAttrs vHosts (host: { + locations = genAttrs (hostLocations host) (loc: { + extraConfig = '' + include ${config.services.nginx.package}/conf/fastcgi_params; + + fastcgi_pass unix:/run/sympa/wwsympa.socket; + ''; + }) // { + "/static-sympa/".alias = "${dataDir}/static_content/"; + }; + } // httpsOpts)); + + services.postfix = mkIf (cfg.mta.type == "postfix") { + enable = true; + recipientDelimiter = "+"; + config = { + virtual_alias_maps = [ "hash:${dataDir}/virtual.sympa" ]; + virtual_mailbox_maps = [ + "hash:${dataDir}/transport.sympa" + "hash:${dataDir}/sympa_transport" + "hash:${dataDir}/virtual.sympa" + ]; + virtual_mailbox_domains = [ "hash:${dataDir}/transport.sympa" ]; + transport_maps = [ + "hash:${dataDir}/transport.sympa" + "hash:${dataDir}/sympa_transport" + ]; + }; + masterConfig = { + "sympa" = { + type = "unix"; + privileged = true; + chroot = false; + command = "pipe"; + args = [ + "flags=hqRu" + "user=${user}" + "argv=${pkg}/libexec/queue" + "\${nexthop}" + ]; + }; + "sympabounce" = { + type = "unix"; + privileged = true; + chroot = false; + command = "pipe"; + args = [ + "flags=hqRu" + "user=${user}" + "argv=${pkg}/libexec/bouncequeue" + "\${nexthop}" + ]; + }; + }; + }; + + services.mysql = optionalAttrs mysqlLocal { + enable = true; + package = mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.database.user; + ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; + } + ]; + }; + + services.postgresql = optionalAttrs pgsqlLocal { + enable = true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { name = cfg.database.user; + ensurePermissions = { "DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; }; + } + ]; + }; + + }; + + meta.maintainers = with maintainers; [ mmilata sorki ]; +} |