# nixos-mailserver: a simple mail server # Copyright (C) 2016-2018 Robin Raymond # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see { config, lib, pkgs, ... }: let inherit (lib) literalExpression literalMD mkChangedOptionModule mkEnableOption mkOption mkOptionType mkRemovedOptionModule mkRenamedOptionModule types ; cfg = config.mailserver; in { options.mailserver = { enable = mkEnableOption "nixos-mailserver"; enableNixpkgsReleaseCheck = mkOption { type = types.bool; default = true; description = '' Whether to check for a release mismatch between NixOS mailserver and Nixpkgs. Using mismatched versions is likely to cause compatibility issues and may require migrations that make an eventual rollback tricky. It is therefore highly recommended to use a release of NixOS mailserver that corresponds with your chosen release of Nixpkgs. ''; }; stateVersion = mkOption { type = types.nullOr types.ints.positive; default = null; description = '' Tracking stateful version changes as an incrementing number. When a new release comes out we may require manual migration steps to be completed, before the new version can be put into production. If your `stateVersion` is too low one or multiple assertions may trigger to give you instructions on what migrations steps are required to continue. Increase the `stateVersion` as instructed by the assertion message. ''; }; openFirewall = mkOption { type = types.bool; default = true; description = "Automatically open ports in the firewall."; }; fqdn = mkOption { type = types.str; example = "mx.example.com"; description = "The fully qualified domain name of the mail server."; }; systemName = mkOption { type = types.str; default = "${cfg.systemDomain} mail system"; defaultText = literalExpression "\${config.mailserver.systemDomain} mail system"; example = "ACME Corp."; description = '' The sender name given in automated reports. ''; }; systemContact = mkOption { type = types.str; example = "postmaster@example.com"; description = '' The email address where the administrative contact for this mail server is reachable. Currently, this is only required when one of the following features is enabled: - SMTP TLS reports (`mailserver.tlsrpt.enable`) ''; }; systemDomain = mkOption { type = types.str; default = if (config.networking.domain != null && lib.elem config.networking.domain cfg.domains) then config.networking.domain else lib.head cfg.domains; defaultText = literalExpression '' if config.networking.domain != null && lib.elem config.networking.domain cfg.domains then config.networking.domain else lib.head cfg.domains ''; example = literalExpression "config.networking.domain"; description = '' The primary domain used for sending automated reports. ''; }; domains = mkOption { type = types.listOf types.str; example = [ "example.com" ]; default = [ ]; description = "The domains that this mail server serves."; }; messageSizeLimit = mkOption { type = types.int; example = 52428800; default = 20971520; description = "Message size limit enforced by Postfix."; }; quota = { enable = mkOption { type = types.bool; default = true; description = '' Whether to enable quota support. When enabled, incoming mail can be rejected if a mailbox exceeds its quota. ''; }; defaults = { perUser = mkOption { type = with types; nullOr str; default = null; example = "10G"; description = '' Default quota applied to all users. The value must use a size format like `500M`, `2G`, `10G`. If set to `null`, no default per user quota is applied and only explicit per user quotas apply, if set. ''; }; }; }; accounts = mkOption { type = types.attrsOf ( types.submodule ( { name, ... }: { options = { name = mkOption { type = types.str; default = name; example = "user1@example.com"; readOnly = true; internal = true; description = '' The login username for this account. ''; }; hashedPassword = mkOption { type = with types; nullOr str; default = null; example = "$y$j9T$vfGrwkAaXCjCEWtVNMQck1$383uIXQmn2z0hnmVAA8kwFQmjNj78.nYbvWeyNLIaP1"; description = '' The hashed login password for this account. Use `mkpasswd` to create password hashes: ``` nix-shell -p mkpasswd --run 'mkpasswd -s' ``` :::{note} This is a convenience option, when your threat model allows storing hashed secrets in the world-readable Nix store. Passing the hash through {option}`mailserver.accounts..hashedPasswordFile` allows relying on filesystem discretionary access control as another security boundary. ::: ''; }; hashedPasswordFile = mkOption { type = with types; nullOr path; default = null; example = "/run/keys/user1-pw-hash"; description = '' The hashed login password for this account read from a file. Use `mkpasswd to create password hashes: ``` nix-shell -p mkpasswd --run 'mkpasswd -s' ``` ''; }; passwordFile = mkOption { type = with types; nullOr (pathWith { inStore = false; }); default = null; example = "/run/keys/user1-pw"; description = '' The plaintext login password for this account read from a file. :::{note} The password is hashed before it is passed on to Dovecot. ::: ''; }; aliases = mkOption { type = with types; listOf types.str; example = [ "abuse@example.com" "postmaster@example.com" ]; default = [ ]; description = '' List of additional mail addresses (aliases) that get routed to this account. :::{admonition} Catch-all with sending permissions :class: tip Configure `@example.com` to create a catch-all for this domain that also allows sending from all addresses. ::: ''; }; aliasesRegexp = mkOption { type = with types; listOf types.str; example = [ ''/^tom\..*@domain\.com$/'' ]; default = [ ]; description = '' Same as {option}`mailserver.accounts..aliases` but using PCRE (Perl compatible regex). ''; }; catchAll = mkOption { type = with types; listOf (enum cfg.domains); example = [ "example.com" "example2.com" ]; default = [ ]; description = '' For which domains should this account act as a catch all? :::{warning} Does not allow sending from all addresses of these domains. Use {option}`mailserver.accounts..aliases` if that is required. ::: ''; }; quota = mkOption { type = with types; nullOr types.str; default = null; example = "2G"; description = '' The quota limit for this user. The value is must use a size format like `500M`, `2G`, `10G`. If unset, will fall back to {option}`mailserver.quota.defaults.perUser` if set. ''; }; sieveScript = mkOption { type = with types; nullOr lines; default = null; example = '' require ["fileinto", "mailbox"]; if address :is "from" "gitlab@mg.gitlab.com" { fileinto :create "GitLab"; stop; } # This must be the last rule, it will check if list-id is set, and # file the message into the Lists folder for further investigation elsif header :matches "list-id" "" { fileinto :create "Lists"; stop; } ''; description = '' Per-user sieve script. ''; }; sendOnly = mkOption { type = types.bool; default = false; description = '' Specifies if the account should be a send-only account. Emails sent to send-only accounts will be rejected with the reason configured in {option}`mailserver.accounts..sendOnlyRejectMessage`. ''; }; sendOnlyRejectMessage = mkOption { type = types.str; default = "This account cannot receive emails."; description = '' The message returned to the sender for a send-only account. See {option}`mailserver.accounts..sendOnly`. ''; }; }; } ) ); example = lib.literalExpression '' { user1 = { # This password hash leaks into the Nix store hashedPassword = "$y$j9T$y6eZ1o.IvVNfdGMAsUEvh1$6K/llP52uw2iDh4iSwtAn54/JYy7FzCcoCHmjmx00H5"; }; user2 = { # Hashed password passed as a file hashedPasswordFile = "/run/keys/user2-pw-hash"; }; user3 = { # Plaintext password file passwordFile = "/run/keys/user3-pw"; }; } ''; description = '' Attribute set of mail accounts. Each entry defines a mailbox and login credentials, where the attribute name is used as the login username and optionally routed mail address. Use `mkpasswd` to generate password hashes. ''; default = { }; }; ldap = { enable = mkEnableOption "LDAP support"; uris = mkOption { type = types.listOf types.str; example = literalExpression '' [ "ldaps://ldap1.example.com" "ldaps://ldap2.example.com" ] ''; description = '' List of LDAP server URIs. Multiple can be specified. Use `ldaps://` for implicit TLS or `ldap://` for a plain connection. See also {option}`mailserver.ldap.startTls` to enable StartTLS on plain connections. ''; }; startTls = mkOption { type = types.bool; default = false; description = '' Whether to enable StartTLS on ``ldap://`` connections. ''; }; caFile = mkOption { type = types.path; default = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; defaultText = lib.literalExpression "\${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; description = '' Bundle of CA certificates used to authenticate the LDAP server certificate. ''; }; bind = { dn = mkOption { type = types.str; example = "cn=mail,ou=accounts,dc=example,dc=com"; description = '' DN used to bind against the LDAP server. The server uses this account to lookup and filter accounts. ''; }; passwordFile = mkOption { type = types.pathWith { inStore = false; }; example = "/run/my-secret"; description = '' File containing the password required to bind against the LDAP server. ''; }; }; base = mkOption { type = types.str; example = "ou=people,ou=accounts,dc=example,dc=com"; description = '' Base DN below which user accounts are searched for. ''; }; scope = mkOption { type = types.enum [ "base" "one" "sub" ]; default = "sub"; description = '' Search scope relative to the {option}`mailserver.ldap.base`. - base: Only the exact Base DN - one: Immediate child entries of the Base DN, but not the Base DN itself. - sub: Base DN and all descendant entries at any depth. In practice only `one` or `sub` are suitable for multiple LDAP users. ''; }; attributes = { uuid = mkOption { type = types.str; default = "entryUUID"; example = "uuid"; description = '' The long-term stable LDAP attribute to reference accounts across username changes. Used to determine a stable Dovecot home and mail directory location. Typically the `entryUUID` attribute as defined by [RFC4530]. [RFC4530]: https://www.rfc-editor.org/rfc/rfc4530.html ''; }; username = mkOption { type = types.str; default = "uid"; example = "name"; description = '' The LDAP attribute referencing the username used to login with. Typically the `uid` attribute which is part of the `inetOrgPerson` schema. ''; }; password = mkOption { type = types.str; default = "userPassword"; example = "unix_password"; description = '' The LDAP attribute referencing the account password used to login with. Typically the `userPassword` attribute which is part of the `inetOrgPerson` schema. ''; }; mail = mkOption { type = types.str; default = "mail"; example = "maildrop"; description = '' The attribute name used for looking up accounts by mail address. Typically this can be the `mail` attribute from the `inetOrgPerson` schema, or the `maildrop` attribute from the unofficial Postfix schema. ''; }; }; dovecot = { userFilter = mkOption { type = types.str; default = with cfg.ldap.attributes; "(|(${mail}=%{user})(${username}=%{user}))"; defaultText = literalExpression '' with config.mailserver.ldap.attributes; "(|(''${mail}=%{user})(''${username}=%{user}))"; ''; example = "(|(mail=%{user})(uid=%{user}))"; description = '' LDAP filter used for LMTP delivery from Postfix and post-login information construction, like the home directory. See the [user_filter] reference at in the Dovecot manual. [user_filter]: https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#user-filter ''; }; passFilter = mkOption { type = types.nullOr types.str; default = with cfg.ldap.attributes; "${username}=%{user}"; defaultText = lib.literalExpression '' with config.mailserver.ldap.attributes; "''${username}=%{user}"; ''; example = with cfg.ldap.attributes; "(&(memberOf=cn=mail_users,ou=groups,dc=example,dc=com)(${username}=%{user}))"; description = '' LDAP filter used to restrict which users are eligible to authenticate against Dovecot. See the [pass_filter] reference in the Dovecot manual. [pass_filter]: https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#pass-filter ''; }; }; postfix = { filter = mkOption { type = types.str; default = with cfg.ldap.attributes; "${mail}=%s"; defaultText = lib.literalExpression '' with config.mailserver.ldap.attributes; "''${mail}=%s"; ''; example = "(mail=%s)"; description = '' LDAP filter used to search for an account by mail, where `%s` is a substitute for the address in question. ''; }; }; }; indexDir = mkOption { type = types.nullOr types.str; default = null; description = '' Folder to store search indices. If null, indices are stored along with email, which could not necessarily be desirable, especially when {option}`mailserver.fullTextSearch.enable` is `true` since indices it creates are voluminous and do not need to be backed up. Be careful when changing this option value since all indices would be recreated at the new location (and clients would need to resynchronize). Note the some variables can be used in the file path. See https://doc.dovecot.org/2.3/configuration_manual/mail_location/#variables for details. ''; example = "/var/lib/dovecot/indices"; }; fullTextSearch = { enable = mkEnableOption '' Full text search indexing with Xapian through the fts_flatcurve plugin. This has significant performance and disk space cost. ''; memoryLimit = mkOption { type = types.nullOr types.int; default = null; example = 1024; description = '' Memory limit for the indexer process, in MiB. When `null` the [`default_vsz_limit`](https://doc.dovecot.org/main/core/plugins/fts.html#fts_search_read_fallback) applies while with `0` no limit is applied. ''; }; autoIndex = mkOption { type = types.bool; default = true; description = '' Enable automatic indexing of messages as they are received or modified. :::{tip} Can be overridden per mailbox by setting `fts_autoindex` for {option}`mailserver.mailboxes`. By default the Junk and Trash folders are already excluded. ::: ''; }; fallback = mkOption { type = types.bool; default = true; description = '' Whether to fallback to slow non-indexed search, if FTS lookup and indexing have failed. See . ''; }; languages = mkOption { type = types.nonEmptyListOf types.str; default = [ "en" ]; example = [ "en" "de" ]; description = '' A list of languages that the full text search should detect. At least one language must be specified. The language listed first is the default and is used when language recognition fails. See . ''; }; substringSearch = mkOption { type = types.bool; default = false; description = '' Whether to allows substring searches. By default only prefix searches are supported. :::{warning} Enabling this significantly increases storage requirements. ::: See . ''; }; headerExcludes = mkOption { type = types.listOf types.str; default = [ "Received" "DKIM-*" "X-*" "Comments" ]; description = '' The list of headers to exclude while indexing. See . ''; }; filters = mkOption { type = types.listOf types.str; default = [ "normalizer-icu" "snowball" "stopwords" ]; description = '' The list of [language filters] to apply. [language filters]: https://doc.dovecot.org/main/core/plugins/fts.html#filter-configuration ''; }; }; lmtpSaveToDetailMailbox = mkOption { type = types.bool; default = true; description = '' If an email address is delimited by a "+", should it be filed into a mailbox matching the string after the "+"? For example, user1+test@example.com would be filed into the mailbox "test". ''; }; lmtpMemoryLimit = mkOption { type = types.int; default = 256; description = '' The memory limit for the LMTP service, in megabytes. ''; }; quotaStatusMemoryLimit = mkOption { type = types.int; default = 256; description = '' The memory limit for the quota-status service, in megabytes. ''; }; aliases = mkOption { type = let account = mkOptionType { name = "Login Account"; check = account: builtins.elem account (builtins.attrNames cfg.accounts); }; in with types; attrsOf (either account (nonEmptyListOf account)); example = { "postmaster@example.com" = "user1@example.com"; "abuse@example.com" = "user1@example.com"; "multi@example.com" = [ "user1@example.com" "user2@example.com" ]; }; description = '' Aliases are additional mail addresses routed to one or more existing local accounts. The target accounts are allowed to use the alias as the sender address. :::{note} This feature is limited to local accounts and does not support LDAP or other external accounts. ::: ''; default = { }; }; forwards = mkOption { type = with types; attrsOf (either (listOf str) str); example = { "user@example.com" = "user@example.edu"; "gamenight@example.com" = [ "bob@example.com" "frank@example.org" "wendy@example.net" ]; }; description = '' Forwards route mail from local addresses to one or more local or external addresses. Unlike {option}`mailserver.aliases`, the target addresses cannot send mail using the forward address. ''; default = { }; }; rejectSender = mkOption { type = types.listOf types.str; example = [ "example.com" "spammer@example.net" ]; description = '' Reject emails from these addresses from unauthorized senders. Use if a spammer is using the same domain or the same sender over and over. ''; default = [ ]; }; rejectSenderMessage = mkOption { type = types.str; default = ""; example = "Your e-mail has not been delivered because we have blocked your e-mail address. If you believe that your e-mail address has been blocked by mistake, or if you have any other legitimate concern, please contact
."; description = '' SMTP message returned to rejected senders. If not set the Postfix default will be used. The message must be a single line and typically much shorter than 512 characters. This could for example be used to provide a contact method (postal address, phone or alternative email) so rejected senders can exercise their [Art. 21 GDPR] right to object. It is good practice to inform senders in advance that their email addresses may be processed for this purpose in accordance with [Art. 13 GDPR]. Storing their mail address for this purpose is generally regarded as a legitimate interest. [Art. 13 GDPR]: https://eur-lex.europa.eu/eli/reg/2016/679/oj/eng#:~:text=Article%2013 [Art. 21 GDPR]: https://eur-lex.europa.eu/eli/reg/2016/679/oj/eng#:~:text=Article%2021 ''; }; rejectRecipients = mkOption { type = types.listOf types.str; example = [ "sales@example.com" "info@example.com" ]; description = '' Reject emails addressed to these local addresses from unauthorized senders. Use if a spammer has found email addresses in a catchall domain but you do not want to disable the catchall. ''; default = [ ]; }; storage = { path = mkOption { type = types.path; default = "/var/vmail"; description = '' Path on disk where mail home directories are stored. ''; }; directoryLayout = mkOption { type = types.enum [ "fs" "Maildir++" ]; default = "Maildir++"; description = '' Sets whether dovecot should organize mail in subdirectories: - /var/vmail/example.com/user/.folder.subfolder/ (Maildir++ layout) - /var/vmail/example.com/user/folder/subfolder/ (FS layout) See for further details. ''; }; uid = mkOption { type = types.ints.positive; default = 5000; description = '' The user id assigned to the vmail user. This user owns the mail storage files and directories and is used by services accessing the mail store. :::{warning} If you change this value you also need to manually adjust the ownership of your {option}`mailserver.storage.path`. ::: ''; }; owner = mkOption { type = types.str; default = "virtualMail"; description = '' The name of the user that owns the {option}`mailserver.storage.path`. ''; }; gid = mkOption { type = types.ints.positive; default = 5000; description = '' The group id of the primary group of the vmail user. This group owns the mail storage directories. Access can be delegated to other users via group membership. :::{warning} If you change this value you also need to manually adjust the ownership of your {option}`mailserver.storage.path`. ::: ''; }; group = mkOption { type = types.str; default = "virtualMail"; description = '' The primary group name of the user that owns the {option}`mailserver.storage.path`. ''; }; }; useUTF8FolderNames = mkOption { type = types.bool; default = false; description = '' Store mailbox names on disk using UTF-8 instead of modified UTF-7 (mUTF-7). ''; }; hierarchySeparator = mkOption { type = types.str; default = "."; description = '' The hierarchy separator for mailboxes used by dovecot for the namespace 'inbox'. Dovecot defaults to "." but recommends "/". This affects how mailboxes appear to mail clients and sieve scripts. For instance when using "." then in a sieve script "example.com" would refer to the mailbox "com" in the parent mailbox "example". This does not determine the way your mails are stored on disk. See https://doc.dovecot.org/main/core/config/namespaces.html#namespaces for details. ''; }; mailboxes = mkOption { description = '' The default mailboxes for Dovecot maildirs. The [`special_use`] option must refer to an [RFC6154 2] attribute and lead with an escaped backslash. Depending on the mail client used it might be necessary to change some mailbox's name. [special_use]: https://doc.dovecot.org/2.3/configuration_manual/namespace/#core_setting-namespace/mailbox/special_use [RFC6154 2]: https://datatracker.ietf.org/doc/html/rfc6154.html#section-2 ''; default = { Trash = { auto = "no"; special_use = "\\Trash"; fts_autoindex = false; }; Junk = { auto = "subscribe"; special_use = "\\Junk"; fts_autoindex = false; }; Drafts = { auto = "subscribe"; special_use = "\\Drafts"; }; Sent = { auto = "subscribe"; special_use = "\\Sent"; }; }; }; x509 = { useACMEHost = mkOption { type = with types; nullOr str; default = null; example = literalExpression "config.mailserver.fqdn"; description = '' Common name used in the relevant `security.acme.certs` configuration. Mutually exclusive with {option}`mailserver.x509.certificateFile` and {option}`mailserver.x509.privateKeyFile`. ''; }; certificateFile = mkOption { type = with types; nullOr path; default = null; example = "/var/keys/certs/fullchain.pem"; description = '' Path to the signed X509 certificate including intermediate certificates. This is commonly referred to as {file}`fullchain.pem`. Mutually exclusive with {option}`mailserver.x509.useACMEHost`. ''; }; privateKeyFile = mkOption { type = with types; nullOr str; default = null; example = "/var/keys/certs/privkey.pem"; description = '' Path to the X509 private key. This is commonly referred to as {file}`privkey.pem`. Mutually exclusive with {option}`mailserver.x509.useACMEHost`. ''; }; }; enableImap = mkOption { type = types.bool; default = false; description = '' Whether to enable IMAP with STARTTLS on port 143. The use of this port is deprecated per RFC 8314 4.1. ''; }; imapMemoryLimit = mkOption { type = types.int; default = 256; description = '' The memory limit for the imap service, in megabytes. ''; }; enableImapSsl = mkOption { type = types.bool; default = true; description = '' Whether to enable IMAP with TLS in wrapper-mode on port 993. ''; }; enableSubmission = mkOption { type = types.bool; default = false; description = '' Whether to enable SMTP with STARTTLS on port 587. The use of this port is discouraged per RFC 8314 3.3, see also Appendix A. ''; }; enableSubmissionSsl = mkOption { type = types.bool; default = true; description = '' Whether to enable SMTP with TLS in wrapper-mode on port 465. ''; }; enablePop3 = mkOption { type = types.bool; default = false; description = '' Whether to enable POP3 with STARTTLS on port on port 110. The use of this port is deprecated per RFC 8314 4.1. ''; }; enablePop3Ssl = mkOption { type = types.bool; default = false; description = '' Whether to enable POP3 with TLS in wrapper-mode on port 995. ''; }; enableManageSieve = mkOption { type = types.bool; default = false; description = '' Whether to enable ManageSieve, setting this option to true will open port 4190 in the firewall. The ManageSieve protocol allows users to manage their Sieve scripts on a remote server with a supported client, including Thunderbird. ''; }; virusScanning = mkOption { type = types.bool; default = false; description = '' Whether to activate virus scanning. Note that virus scanning is _very_ expensive memory wise. ''; }; dkim = { enable = mkEnableOption "DKIM signing" // { default = true; }; keyDirectory = mkOption { type = types.path; default = "/var/dkim"; description = '' The path where DKIM siging keys are stored. ''; }; defaults = { selector = mkOption { type = types.str; default = "mail"; description = '' The default selector used to reference and lookup DKIM keys. This value should most likely not be changed. Instead manage {option}`mailserver.dkim.domains..selectors` to sign with one or multiple DKIM key pairs and manage migrations. ''; }; keyType = mkOption { type = types.enum [ "rsa" "ed25519" ]; default = "rsa"; description = '' The key type used for generating DKIM keys. Ed25519 support was introduced in RFC6376 (2018). :::{warning} Ed25519 DKIM keys are currently not recommended for sole use, as various DKIM validators out there lack support and consider the keypair invalid. ::: This value should most likely not be changed. Once DKIM keys for domain and selector are generated changing this value will not regenerate the keypair. Instead create a new selector and configure {option}`mailserver.dkim.domains..selectors..keyType`. ''; }; keyLength = mkOption { type = types.int; default = 2048; description = '' The default key length used for generating new DKIM keys. Only applies for RSA keys, Ed25519 keys use a fixed key length. Per [RFC8301 3.2] the minimum RSA key length should be at least 2048 bit. This value should most likely not be changed. Once DKIM keys for domain and selector are generated changing this value will not regenerate the keypair. Instead create a new selector and configure {option}`mailserver.dkim.domains..selectors..keyLength`. [RFC8301 3.2]: https://datatracker.ietf.org/doc/html/rfc8301#section-3.2 ''; }; }; domains = mkOption { description = "DKIM configuration per domain."; type = types.attrsOf ( types.submodule ({ options = { selectors = mkOption { description = '' DKIM selectors used for signing outgoing mail for this domain. When no selector is configured a default selector will be created with settings inherited from {option}`mailserver.dkim.defaults `. ''; type = types.attrsOf ( types.submodule ({ options = { keyType = mkOption { type = with types; nullOr (enum [ "rsa" "ed25519" ]); default = null; example = "rsa"; description = '' The key type used for generating this DKIM keypair. :::{warning} Ed25519 DKIM keys are currently not recommended for sole use, as various DKIM validators out there lack support and consider the keypair invalid. ::: This option is mutually exclusive with `keyFile`. ''; }; keyLength = mkOption { type = with types; nullOr int; default = null; example = 2048; description = '' The key length used for generating this DKIM key. Only applies for RSA keys, Ed25519 keys use a fixed key size. This option is mutually exclusive with `keyFile`. ''; }; keyFile = mkOption { type = with types; nullOr (pathWith { inStore = false; }); default = null; example = "/run/keys/example.com-dkim-rsa-2026-03.key"; description = '' Path to an existing DKIM private key file. DKIM keys can be generated using `rspamadm dkim_keygen`. This option is mutually exclusive with `keyType` and `keyLength`. ''; }; }; }) ); default = { }; example = lib.literalExpression '' { "mail" = { # inherit defaults from mailserver.dkim.defaults }; "rsa-2026-03".keyFile = "/run/keys/example.com-dkim-rsa-2026-03.key"; }; ''; }; }; }) ); default = { }; example = lib.literalExpression '' { "example.com".selectors = { "mail" = { # inherit defaults from mailserver.dkim.defaults }; "rsa-2026-03".keyFile = "/run/keys/example.com-dkim-rsa-2026-03.key"; }; }; ''; }; }; dmarcReporting = { enable = mkOption { type = types.bool; default = false; description = '' Whether to send out aggregated, daily DMARC reports in response to incoming mail, when the sender domain defines a DMARC policy including the RUA tag. This is helpful for the mail ecosystem, because it allows third parties to get notified about SPF/DKIM violations originating from their sender domains. See https://rspamd.com/doc/modules/dmarc.html#reporting ''; }; excludeDomains = mkOption { type = types.listOf types.str; default = [ ]; description = '' List of domains or eSLDs to be excluded from DMARC reports. ''; }; }; tlsrpt.enable = mkEnableOption "delivery of SMTP TLS reports according to RFC 8460"; debug = { all = mkOption { type = types.bool; default = false; description = '' Whether to enable verbose logging for all mailserver related services. This intended be used for development purposes only, you probably don't want to enable this unless you're hacking on nixos-mailserver. ''; }; dovecot = mkOption { type = types.bool; default = cfg.debug.all; defaultText = lib.literalExpression "config.mailserver.debug.all"; description = '' Whether to enable verbose logging for Dovecot. ''; }; rspamd = mkOption { type = types.bool; default = cfg.debug.all; defaultText = lib.literalExpression "config.mailserver.debug.all"; description = '' Whether to enable verbose logging for Rspamd. ''; }; }; maxConnectionsPerUser = mkOption { type = types.int; default = 100; description = '' Maximum number of IMAP/POP3 connections allowed for a user from each IP address. E.g. a value of 50 allows for 50 IMAP and 50 POP3 connections at the same time for a single user. ''; }; localDnsResolver = mkOption { type = types.bool; default = true; description = '' Runs a local DNS resolver (kresd) as recommended when running rspamd. This prevents your log file from filling up with rspamd_monitored_dns_mon entries. ''; }; recipientDelimiter = mkOption { type = types.str; default = "+"; description = '' Configure the recipient delimiter. ''; }; srs = { enable = mkEnableOption "Sender Rewrite Scheme"; domain = mkOption { type = with types; nullOr str; default = config.mailserver.systemDomain; defaultText = literalExpression "config.mailserver.systemDomain"; example = "srs.example.com"; description = '' Mail domain used for ephemeral SRS envelope addresses. :::{note} This domain can only support relaxed SPF alignment. ::: :::{important} For privacy reasons you should use a dedicated domain when serving multiple unrelated domains. ::: ''; }; }; redis = { configureLocally = mkOption { type = types.bool; default = true; description = '' Whether to provision a local Redis instance. ''; }; address = mkOption { type = types.str; # read the default from nixos' redis module default = config.services.redis.servers.rspamd.unixSocket; defaultText = literalExpression "config.services.redis.servers.rspamd.unixSocket"; description = '' Path, IP address or hostname that Rspamd should use to contact Redis. ''; }; port = mkOption { type = with types; nullOr port; default = null; example = literalExpression "config.services.redis.servers.rspamd.port"; description = '' Port that Rspamd should use to contact Redis. ''; }; password = mkOption { type = types.nullOr types.str; default = config.services.redis.servers.rspamd.requirePass; defaultText = literalExpression "config.services.redis.servers.rspamd.requirePass"; description = '' Password that rspamd should use to contact redis, or null if not required. ''; }; }; rewriteMessageId = mkOption { type = types.bool; default = false; description = '' Rewrites the Message-ID's hostname-part of outgoing emails to the FQDN. Please be aware that this may cause problems with some mail clients relying on the original Message-ID. ''; }; sendingFqdn = mkOption { type = types.str; default = cfg.fqdn; defaultText = literalMD "{option}`mailserver.fqdn`"; example = "myserver.example.com"; description = '' The fully qualified domain name of the mail server used to identify with remote servers. If this server's IP serves purposes other than a mail server, it may be desirable for the server to have a name other than that to which the user will connect. For example, the user might connect to mx.example.com, but the server's IP has reverse DNS that resolves to myserver.example.com; in this scenario, some mail servers may reject or penalize the message. This setting allows the server to identify as myserver.example.com when forwarding mail, independently of {option}`mailserver.fqdn` (which, for SSL reasons, should generally be the name to which the user connects). Set this to the name to which the sending IP's reverse DNS resolves. ''; }; monitoring = { enable = mkEnableOption "monitoring via monit"; alertAddress = mkOption { type = types.str; description = '' The email address to send alerts to. ''; }; config = mkOption { type = types.str; default = '' set daemon 120 with start delay 60 set mailserver localhost set httpd port 2812 and use address localhost allow localhost allow admin:obwjoawijerfoijsiwfj29jf2f2jd check filesystem root with path / if space usage > 80% then alert if inode usage > 80% then alert check system $HOST if cpu usage > 95% for 10 cycles then alert if memory usage > 75% for 5 cycles then alert if swap usage > 20% for 10 cycles then alert if loadavg (1min) > 90 for 15 cycles then alert if loadavg (5min) > 80 for 10 cycles then alert if loadavg (15min) > 70 for 8 cycles then alert check process sshd with pidfile /var/run/sshd.pid start program "${pkgs.systemd}/bin/systemctl start sshd" stop program "${pkgs.systemd}/bin/systemctl stop sshd" if failed port 22 protocol ssh for 2 cycles then restart check process postfix with pidfile /var/lib/postfix/queue/pid/master.pid start program = "${pkgs.systemd}/bin/systemctl start postfix" stop program = "${pkgs.systemd}/bin/systemctl stop postfix" if failed port 25 protocol smtp for 5 cycles then restart check process dovecot with pidfile /var/run/dovecot2/master.pid start program = "${pkgs.systemd}/bin/systemctl start dovecot" stop program = "${pkgs.systemd}/bin/systemctl stop dovecot" if failed host ${cfg.fqdn} port 993 type tcpssl sslauto protocol imap for 5 cycles then restart check process rspamd with matching "rspamd: main process" start program = "${pkgs.systemd}/bin/systemctl start rspamd" stop program = "${pkgs.systemd}/bin/systemctl stop rspamd" ''; defaultText = literalMD "see [source](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/default.nix)"; description = '' The configuration used for monitoring via monit. Use a mail address that you actively check and set it via 'set alert ...'. ''; }; }; borgbackup = { enable = mkEnableOption "backup via borgbackup"; repoLocation = mkOption { type = types.str; default = "/var/borgbackup"; description = '' The location where borg saves the backups. This can be a local path or a remote location such as user@host:/path/to/repo. It is exported and thus available as an environment variable to {option}`mailserver.borgbackup.cmdPreexec` and {option}`mailserver.borgbackup.cmdPostexec`. ''; }; startAt = mkOption { type = types.str; default = "hourly"; description = "When or how often the backup should run. Must be in the format described in systemd.time 7."; }; user = mkOption { type = types.str; default = "virtualMail"; description = "The user borg and its launch script is run as."; }; group = mkOption { type = types.str; default = "virtualMail"; description = "The group borg and its launch script is run as."; }; compression = { method = mkOption { type = types.nullOr ( types.enum [ "none" "lz4" "zstd" "zlib" "lzma" ] ); default = null; description = "Leaving this unset allows borg to choose. The default for borg 1.1.4 is lz4."; }; level = mkOption { type = types.nullOr types.int; default = null; description = '' Denotes the level of compression used by borg. Most methods accept levels from 0 to 9 but zstd which accepts values from 1 to 22. If null the decision is left up to borg. ''; }; auto = mkOption { type = types.bool; default = false; description = "Leaves it to borg to determine whether an individual file should be compressed."; }; }; encryption = { method = mkOption { type = types.enum [ "none" "authenticated" "authenticated-blake2" "repokey" "keyfile" "repokey-blake2" "keyfile-blake2" ]; default = "none"; description = '' The backup can be encrypted by choosing any other value than 'none'. When using encryption the password/passphrase must be provided in `passphraseFile`. ''; }; passphraseFile = mkOption { type = types.nullOr types.path; default = null; description = "Path to a file containing the encryption password or passphrase."; }; }; name = mkOption { type = types.str; default = "{hostname}-{user}-{now}"; description = '' The name of the individual backups as used by borg. Certain placeholders will be replaced by borg. ''; }; locations = mkOption { type = types.listOf types.path; default = [ cfg.storage.path ]; defaultText = literalExpression "[ config.mailserver.storage.path ]"; description = "The locations that are to be backed up by borg."; }; extraArgumentsForInit = mkOption { type = types.listOf types.str; default = [ "--critical" ]; description = "Additional arguments to add to the borg init command line."; }; extraArgumentsForCreate = mkOption { type = types.listOf types.str; default = [ ]; description = "Additional arguments to add to the borg create command line e.g. '--stats'."; }; cmdPreexec = mkOption { type = types.nullOr types.str; default = null; description = '' The command to be executed before each backup operation. This is called prior to borg init in the same script that runs borg init and create and `cmdPostexec`. ''; example = '' export BORG_RSH="ssh -i /path/to/private/key" ''; }; cmdPostexec = mkOption { type = types.nullOr types.str; default = null; description = '' The command to be executed after each backup operation. This is called after borg create completed successfully and in the same script that runs `cmdPreexec`, borg init and create. ''; }; }; backup = { enable = mkEnableOption "backup via rsnapshot"; snapshotRoot = mkOption { type = types.path; default = "/var/rsnapshot"; description = '' The directory where rsnapshot stores the backup. ''; }; cmdPreexec = mkOption { type = types.nullOr types.str; default = null; description = '' The command to be executed before each backup operation. This is wrapped in a shell script to be called by rsnapshot. ''; }; cmdPostexec = mkOption { type = types.nullOr types.str; default = null; description = "The command to be executed after each backup operation. This is wrapped in a shell script to be called by rsnapshot."; }; retain = { hourly = mkOption { type = types.int; default = 24; description = "How many hourly snapshots are retained."; }; daily = mkOption { type = types.int; default = 7; description = "How many daily snapshots are retained."; }; weekly = mkOption { type = types.int; default = 54; description = "How many weekly snapshots are retained."; }; }; cronIntervals = mkOption { type = types.attrsOf types.str; default = { # minute, hour, day-in-month, month, weekday (0 = sunday) hourly = " 0 * * * *"; # Every full hour daily = "30 3 * * *"; # Every day at 3:30 weekly = " 0 5 * * 0"; # Every sunday at 5:00 AM }; description = '' Periodicity at which intervals should be run by cron. Note that the intervals also have to exist in configuration as retain options. ''; }; }; }; imports = [ ./mail-server # NixOS 25.05 (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "enable" ] '' This option is not needed for fts-flatcurve '') (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "onCalendar" ] '' This option is not needed for fts-flatcurve '') (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "randomizedDelaySec" ] '' This option is not needed for fts-flatcurve '') (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "minSize" ] '' This option is not supported by fts-flatcurve '') (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maxSize" ] '' This option is not needed since fts-xapian 1.8.3 '') (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "indexAttachments" ] '' Text attachments are always indexed since fts-xapian 1.4.8 '') (mkRenamedOptionModule [ "mailserver" "rebootAfterKernelUpgrade" "enable" ] [ "system" "autoUpgrade" "allowReboot" ] ) (mkRemovedOptionModule [ "mailserver" "rebootAfterKernelUpgrade" "method" ] '' Use `system.autoUpgrade` instead. '') (mkRemovedOptionModule [ "mailserver" "policydSPFExtraConfig" ] '' SPF checking has been migrated to Rspamd, which makes this config redundant. Please look into the rspamd config to migrate your settings. It may be that they are redundant and are already configured in rspamd like for skip_addresses. '') (mkRemovedOptionModule [ "mailserver" "dkimHeaderCanonicalization" ] '' DKIM signing has been migrated to Rspamd, which always uses relaxed canonicalization. '') (mkRemovedOptionModule [ "mailserver" "dkimBodyCanonicalization" ] '' DKIM signing has been migrated to Rspamd, which always uses relaxed canonicalization. '') (mkRemovedOptionModule [ "mailserver" "smtpdForbidBareNewline" ] '' The workaround for the SMTP Smuggling attack is default enabled in Postfix >3.9. Use `services.postfix.config.smtpd_forbid_bare_newline` if you need to deviate from its default. '') # NixOS 25.11 (mkRenamedOptionModule [ "mailserver" "dmarcReporting" "domain" ] [ "mailserver" "systemDomain" ]) (mkRenamedOptionModule [ "mailserver" "dmarcReporting" "organizationName" ] [ "mailserver" "systemName" ] ) (mkRemovedOptionModule [ "mailserver" "dmarcReporting" "localpart" ] '' The localpart is now fixed at `noreply-dmarc` to simplify the configuration. '') (mkRemovedOptionModule [ "mailserver" "dmarcReporting" "email" ] '' The address is now fixed at `noreply-dmarc@''${config.mailserver.systemDomain}` to simplify the configuration. '') (mkRemovedOptionModule [ "mailserver" "dmarcReporting" "fromName" ] '' The name in the `FROM` field for DMARC report now uses the `mailserver.systemName`. '') # NixOS 26.05 (mkRemovedOptionModule [ "mailserver" "certificateDomains" ] '' Configure `security.acme.certs.''${config.mailserver.fqdn}.extraDomains` instead. '') (mkRemovedOptionModule [ "mailserver" "certificateScheme" ] "") (mkRemovedOptionModule [ "mailserver" "certificateDirectory" ] '' Automatic creation of self-signed certificates is no longer supported. '') (mkRenamedOptionModule [ "mailserver" "acmeCertificateName" ] [ "mailserver" "x509" "useACMEHost" ]) (mkRenamedOptionModule [ "mailserver" "certificateFile" ] [ "mailserver" "x509" "certificateFile" ]) (mkRenamedOptionModule [ "mailserver" "keyFile" ] [ "mailserver" "x509" "privateKeyFile" ]) (mkRenamedOptionModule [ "mailserver" "dkimSigning" ] [ "mailserver" "dkim" "enable" ]) (mkRenamedOptionModule [ "mailserver" "dkimKeyDirectory" ] [ "mailserver" "dkim" "keyDirectory" ]) (mkRenamedOptionModule [ "mailserver" "dkimSelector" ] [ "mailserver" "dkim" "defaults" "selector" ] ) (mkRenamedOptionModule [ "mailserver" "dkimKeyType" ] [ "mailserver" "dkim" "defaults" "keyType" ]) (mkRenamedOptionModule [ "mailserver" "dkimKeyBits" ] [ "mailserver" "dkim" "defaults" "keyLength" ] ) (mkRemovedOptionModule [ "mailserver" "ldap" "dovecot" "userAttrs" ] '' The user_attrs field is now used internally to map the home and mail directories. '') (mkRemovedOptionModule [ "mailserver" "ldap" "dovecot" "passAttrs" ] '' The pass_attrs field is now used internally. You can customize the `mailserver.ldap.attributes.password` field instead. '') (mkRenamedOptionModule [ "mailserver" "ldap" "tlsCAFile" ] [ "mailserver" "ldap" "caFile" ]) (mkRenamedOptionModule [ "mailserver" "ldap" "searchBase" ] [ "mailserver" "ldap" "base" ]) (mkRenamedOptionModule [ "mailserver" "ldap" "searchScope" ] [ "mailserver" "ldap" "scope" ]) (mkRenamedOptionModule [ "mailserver" "ldap" "postfix" "uidAttribute" ] [ "mailserver" "ldap" "attributes" "username" ] ) (mkRenamedOptionModule [ "mailserver" "ldap" "postfix" "mailAttribute" ] [ "mailserver" "ldap" "attributes" "mail" ] ) (mkRenamedOptionModule [ "mailserver" "extraVirtualAliases" ] [ "mailserver" "aliases" ]) (mkRenamedOptionModule [ "mailserver" "loginAccounts" ] [ "mailserver" "accounts" ]) (mkRenamedOptionModule [ "mailserver" "vmailUID" ] [ "mailserver" "storage" "uid" ]) (mkRenamedOptionModule [ "mailserver" "vmailUserName" ] [ "mailserver" "storage" "owner" ]) (mkRenamedOptionModule [ "mailserver" "vmailGroupName" ] [ "mailserver" "storage" "group" ]) (mkRenamedOptionModule [ "mailserver" "mailDirectory" ] [ "mailserver" "storage" "path" ]) (mkChangedOptionModule [ "mailserver" "useFSLayout" ] [ "mailserver" "storage" "directoryLayout" ] ( config: if config.mailserver.useFSLayout then "fs" else "maildir++" )) (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "enforced" ] '' Whether to fallback to non-indexed search is now controlled via `mailserver.fullTextSearch.fallback`. Missing mails are now always indexed, since flatcurve is very fast. '') (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "autoIndexExclude" ] '' Configure `fts_autoindex` on mail directories in `mailserver.mailboxes` instead. '') (mkRemovedOptionModule [ "mailserver" "sieveDirectory" ] '' The Sieve directory has been moved into the virtual Dovecot home directory of each user and can longer be configured. '') ]; }