{ 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 != ""; 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" "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 null is given it uses the postfix default true. ''; }; 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 and the closure of store paths specified by the option. ''; }; wakeup = mkOption { type = types.int; example = 60; description = '' Automatically wake up the service after the specified number of seconds. If 0 is given, never wake the service up. ''; }; wakeupUnusedComponent = mkOption { type = types.bool; example = false; description = '' If set to false 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 master.cf ''; }; maxproc = mkOption { type = types.int; example = 1; description = '' The maximum number of processes to spawn for this service. If the value is 0 it doesn't have any limit. If null is given it uses the postfix default of 100. ''; }; 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 . ''; }; args = mkOption { type = types.listOf types.str; default = []; example = [ "-o" "smtp_helo_timeout=5" ]; description = '' Arguments to pass to the . 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 master.cf. ''; }; }; 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 fold 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 = fold (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; 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 { default = true; description = "Whether to enable smtp in master.cf."; }; enableSubmission = mkOption { type = types.bool; default = false; description = "Whether to enable smtp submission."; }; submissionOptions = mkOption { type = types.attrs; 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"; }; 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_use_tls = true; }; }; extraConfig = mkOption { type = types.lines; default = ""; description = " Extra lines to be added verbatim to the main.cf configuration file. "; }; sslCert = mkOption { type = types.str; default = ""; description = "SSL certificate to use."; }; sslCACert = mkOption { type = types.str; default = ""; description = "SSL certificate of CA."; }; 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 "; }; virtual = mkOption { type = types.lines; default = ""; description = " Entries for the virtual alias map, cf. man-page virtual(8). "; }; virtualMapType = mkOption { type = types.enum ["hash" "regexp" "pcre"]; default = "hash"; description = '' What type of virtual alias map file to use. Use "regexp" for regular expressions. ''; }; localRecipients = mkOption { type = with types; nullOr (listOf str); default = null; description = '' List of accepted local users. Specify a bare username, an "@domain.tld" wild-card, or a complete "user@domain.tld" 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 = ""; 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 = ""; 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 master.cf 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"; group = setgidGroup; setuid = false; setgid = true; }; security.wrappers.mailq = { program = "mailq"; source = "${pkgs.postfix}/bin/mailq"; group = setgidGroup; setuid = false; setgid = true; }; security.wrappers.postqueue = { program = "postqueue"; source = "${pkgs.postfix}/bin/postqueue"; group = setgidGroup; setuid = false; setgid = true; }; security.wrappers.postdrop = { program = "postdrop"; source = "${pkgs.postfix}/bin/postdrop"; 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 = "9999"; 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.sslCert != "") { smtp_tls_CAfile = cfg.sslCACert; smtp_tls_cert_file = cfg.sslCert; smtp_tls_key_file = cfg.sslKey; smtp_use_tls = true; smtpd_tls_CAfile = cfg.sslCACert; smtpd_tls_cert_file = cfg.sslCert; smtpd_tls_key_file = cfg.sslKey; smtpd_use_tls = true; }; services.postfix.masterConfig = { smtp_inet = { name = "smtp"; type = "inet"; private = false; command = "smtpd"; }; 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 = {}; relay = { command = "smtp"; args = [ "-o" "smtp_fallback_relay=" ]; }; }; } (mkIf haveAliases { services.postfix.aliasFiles.aliases = aliasesFile; }) (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; }) ]); }