1 Commits

Author SHA1 Message Date
Jakub Skokan
284a1e4041 Allow TLSv1 for compatibility with older devices 2025-05-25 21:06:21 +02:00
39 changed files with 1716 additions and 2921 deletions

View File

@@ -1,11 +1,11 @@
{ nixpkgs, pulls, ... }:
let
pkgs = import nixpkgs { };
pkgs = import nixpkgs {};
prs = builtins.fromJSON (builtins.readFile pulls);
prJobsets = pkgs.lib.mapAttrs (num: info: {
enabled = 1;
prJobsets = pkgs.lib.mapAttrs (num: info:
{ enabled = 1;
hidden = false;
description = "PR ${num}: ${info.title}";
checkinterval = 300;
@@ -15,7 +15,8 @@ let
keepnr = 1;
type = 1;
flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head";
}) prs;
}
) prs;
mkFlakeJobset = branch: {
description = "Build ${branch} branch of Simple NixOS MailServer";
checkinterval = 300;
@@ -31,8 +32,8 @@ let
desc = prJobsets // {
"master" = mkFlakeJobset "master";
"nixos-24.11" = mkFlakeJobset "nixos-24.11";
"nixos-25.05" = mkFlakeJobset "nixos-25.05";
"nixos-25.11" = mkFlakeJobset "nixos-25.11";
};
log = {
@@ -40,14 +41,13 @@ let
jobsets = desc;
};
in
{
jobsets = pkgs.runCommand "spec-jobsets.json" { } ''
cat >$out <<'EOF'
in {
jobsets = pkgs.runCommand "spec-jobsets.json" {} ''
cat >$out <<EOF
${builtins.toJSON desc}
EOF
# This is to get nice .jobsets build logs on Hydra
cat >tmp <<'EOF'
cat >tmp <<EOF
${builtins.toJSON log}
EOF
${pkgs.jq}/bin/jq . tmp

View File

@@ -5,18 +5,17 @@
version: 2
build:
os: ubuntu-24.04
os: ubuntu-22.04
tools:
python: "3"
apt_packages:
- curl
- nix
- proot
jobs:
pre_install:
- curl -L https://github.com/DavHau/nix-portable/releases/latest/download/nix-portable-$(uname -m) > ./nix-portable
- chmod +x ./nix-portable
- ./nix-portable nix build --print-build-logs .#optionsDoc
- ./nix-portable nix store cat $(readlink result) > docs/options.md
- mkdir -p ~/.nix ~/.config/nix
- echo "experimental-features = nix-command flakes" > ~/.config/nix/nix.conf
- proot -b ~/.nix:/nix /bin/sh -c "nix build -L .#optionsDoc && cp -v result docs/options.md"
sphinx:
configuration: docs/conf.py

View File

@@ -8,14 +8,14 @@
For each NixOS release, we publish a branch. You then have to use the
SNM branch corresponding to your NixOS version.
* For NixOS 25.11
* Use the [SNM branch `nixos-25.11`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.11)
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-25.11/)
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-25.11/release-notes.html#nixos-25-11)
* For NixOS 25.05
* Use the [SNM branch `nixos-25.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.05)
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-25.05/)
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-25.05/release-notes.html#nixos-25-05)
* For NixOS 24.11
* Use the [SNM branch `nixos-24.11`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-24.11)
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-24.11/)
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-24.11/release-notes.html#nixos-24-11)
* For NixOS unstable
* Use the [SNM branch `master`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/master)
* [Documentation](https://nixos-mailserver.readthedocs.io/en/latest/)
@@ -29,8 +29,6 @@ SNM branch corresponding to your NixOS version.
* [x] Submission TLS on port 465
* [x] Submission StartTLS on port 587
* [x] LMTP with Dovecot
* [x] DANE and MTA-STS validation
* [x] SMTP TLS Reports ([RFC 8460](https://www.rfc-editor.org/rfc/rfc8460))
* Dovecot
* [x] Maildir folders
* [x] IMAP with TLS on port 993
@@ -57,8 +55,6 @@ SNM branch corresponding to your NixOS version.
* User Aliases
* [x] Regular aliases
* [x] Catch all aliases
* Improve the Forwarding Experience
* [x] [Sender Rewriting Scheme](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme)
### In the future
@@ -71,6 +67,7 @@ SNM branch corresponding to your NixOS version.
* [ ] Allow passing DKIM signing keys
* Improve the Forwarding Experience
* [ ] Support [ARC](https://en.wikipedia.org/wiki/Authenticated_Received_Chain) signing with [Rspamd](https://rspamd.com/doc/modules/arc.html)
* [ ] Support [SRS](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme) with [postsrsd](https://github.com/roehling/postsrsd)
* User management
* [ ] Allow local and LDAP user to coexist
* OpenID Connect

View File

@@ -14,64 +14,17 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
lib,
pkgs,
...
}:
{ config, lib, pkgs, ... }:
with lib;
let
inherit (lib)
literalExpression
literalMD
mkDefault
mkEnableOption
mkOption
mkOptionType
mkRemovedOptionModule
mkRenamedOptionModule
types
warn
;
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;
@@ -84,60 +37,17 @@ in
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 = [ ];
default = [];
description = "The domains that this mail server serves.";
};
certificateDomains = mkOption {
type = types.listOf types.str;
example = [
"imap.example.com"
"pop3.example.com"
];
default = [ ];
example = [ "imap.example.com" "pop3.example.com" ];
default = [];
description = ''
({option}`mailserver.certificateScheme` == `acme-nginx`)
@@ -153,10 +63,7 @@ in
};
loginAccounts = mkOption {
type = types.attrsOf (
types.submodule (
{ name, ... }:
{
type = types.attrsOf (types.submodule ({ name, ... }: {
options = {
name = mkOption {
type = types.str;
@@ -195,11 +102,8 @@ in
aliases = mkOption {
type = with types; listOf types.str;
example = [
"abuse@example.com"
"postmaster@example.com"
];
default = [ ];
example = ["abuse@example.com" "postmaster@example.com"];
default = [];
description = ''
A list of aliases of this login account.
Note: Use list entries like "@example.com" to create a catchAll
@@ -209,8 +113,8 @@ in
aliasesRegexp = mkOption {
type = with types; listOf types.str;
example = [ ''/^tom\..*@domain\.com$/'' ];
default = [ ];
example = [''/^tom\..*@domain\.com$/''];
default = [];
description = ''
Same as {option}`mailserver.aliases` but using PCRE (Perl compatible regex).
'';
@@ -218,11 +122,8 @@ in
catchAll = mkOption {
type = with types; listOf (enum cfg.domains);
example = [
"example.com"
"example2.com"
];
default = [ ];
example = ["example.com" "example2.com"];
default = [];
description = ''
For which domains should this account act as a catch all?
Note: Does not allow sending from all addresses of these domains.
@@ -285,9 +186,7 @@ in
};
config.name = mkDefault name;
}
)
);
}));
example = {
user1 = {
hashedPassword = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/";
@@ -305,7 +204,7 @@ in
nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt'
```
'';
default = { };
default = {};
};
ldap = {
@@ -335,7 +234,7 @@ in
tlsCAFile = mkOption {
type = types.path;
default = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
defaultText = literalMD "see [source](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/default.nix)";
defaultText = lib.literalMD "see [source](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/default.nix)";
description = ''
Certifificate trust anchors used to verify the LDAP server certificate.
'';
@@ -369,11 +268,7 @@ in
};
searchScope = mkOption {
type = types.enum [
"sub"
"base"
"one"
];
type = types.enum [ "sub" "base" "one" ];
default = "sub";
description = ''
Search scope below which users accounts are looked for.
@@ -388,7 +283,7 @@ in
LDAP attributes to be retrieved during userdb lookups.
See the users_attrs reference at
https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#user-attrs
https://doc.dovecot.org/configuration_manual/authentication/ldap_settings_auth/#user-attrs
in the Dovecot manual.
'';
};
@@ -401,7 +296,7 @@ in
Filter for user lookups in Dovecot.
See the user_filter reference at
https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#user-filter
https://doc.dovecot.org/configuration_manual/authentication/ldap_settings_auth/#user-filter
in the Dovecot manual.
'';
};
@@ -413,7 +308,7 @@ in
LDAP attributes to be retrieved during passdb lookups.
See the pass_attrs reference at
https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#pass-attrs
https://doc.dovecot.org/configuration_manual/authentication/ldap_settings_auth/#pass-attrs
in the Dovecot manual.
'';
};
@@ -426,7 +321,7 @@ in
Filter for password lookups in Dovecot.
See the pass_filter reference for
https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#pass-filter
https://doc.dovecot.org/configuration_manual/authentication/ldap_settings_auth/#pass-filter
in the Dovecot manual.
'';
};
@@ -478,7 +373,7 @@ in
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
https://doc.dovecot.org/configuration_manual/mail_location/#variables
for details.
'';
example = "/var/lib/dovecot/indices";
@@ -508,22 +403,14 @@ in
autoIndexExclude = mkOption {
type = types.listOf types.str;
default = [ ];
example = [
"\\Trash"
"SomeFolder"
"Other/*"
];
example = [ "\\Trash" "SomeFolder" "Other/*" ];
description = ''
Mailboxes to exclude from automatic indexing.
'';
};
enforced = mkOption {
type = types.enum [
"yes"
"no"
"body"
];
type = types.enum [ "yes" "no" "body" ];
default = "no";
description = ''
Fail searches when no index is available. If set to
@@ -536,10 +423,7 @@ in
languages = mkOption {
type = types.nonEmptyListOf types.str;
default = [ "en" ];
example = [
"en"
"de"
];
example = [ "en" "de" ];
description = ''
A list of languages that the full text search should detect.
At least one language must be specified.
@@ -588,10 +472,7 @@ in
};
lmtpSaveToDetailMailbox = mkOption {
type = types.enum [
"yes"
"no"
];
type = types.enum ["yes" "no"];
default = "yes";
description = ''
If an email address is delimited by a "+", should it be filed into a
@@ -617,23 +498,17 @@ in
};
extraVirtualAliases = mkOption {
type =
let
type = let
loginAccount = mkOptionType {
name = "Login Account";
check = account: builtins.elem account (builtins.attrNames cfg.loginAccounts);
check = (account: builtins.elem account (builtins.attrNames cfg.loginAccounts));
};
in
with types;
attrsOf (either loginAccount (nonEmptyListOf loginAccount));
in with types; attrsOf (either loginAccount (nonEmptyListOf loginAccount));
example = {
"info@example.com" = "user1@example.com";
"postmaster@example.com" = "user1@example.com";
"abuse@example.com" = "user1@example.com";
"multi@example.com" = [
"user1@example.com"
"user2@example.com"
];
"multi@example.com" = [ "user1@example.com" "user2@example.com" ];
};
description = ''
Virtual Aliases. A virtual alias `"info@example.com" = "user1@example.com"` means that
@@ -646,7 +521,7 @@ in
example all mails for `multi@example.com` will be forwarded to both
`user1@example.com` and `user2@example.com`.
'';
default = { };
default = {};
};
forwards = mkOption {
@@ -663,34 +538,28 @@ in
can't send mail as `user@example.com`. Also, this option
allows to forward mails to external addresses.
'';
default = { };
default = {};
};
rejectSender = mkOption {
type = types.listOf types.str;
example = [
"example.com"
"spammer@example.net"
];
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 = [ ];
default = [];
};
rejectRecipients = mkOption {
type = types.listOf types.str;
example = [
"sales@example.com"
"info@example.com"
];
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 = [ ];
default = [];
};
vmailUID = mkOption {
@@ -788,30 +657,12 @@ in
};
};
certificateScheme =
let
schemes = [
"manual"
"selfsigned"
"acme-nginx"
"acme"
];
translate =
i:
warn
"Setting mailserver.certificateScheme by number is deprecated, please use names instead: 'mailserver.certificateScheme = ${builtins.toString i}' can be replaced by 'mailserver.certificateScheme = \"${
(builtins.elemAt schemes (i - 1))
}\"'."
certificateScheme = let
schemes = [ "manual" "selfsigned" "acme-nginx" "acme" ];
translate = i: warn "Setting mailserver.certificateScheme by number is deprecated, please use names instead: 'mailserver.certificateScheme = ${builtins.toString i}' can be replaced by 'mailserver.certificateScheme = \"${(builtins.elemAt schemes (i - 1))}\"'."
(builtins.elemAt schemes (i - 1));
in
mkOption {
type =
with types;
coercedTo (enum [
1
2
3
]) translate (enum schemes);
in mkOption {
type = with types; coercedTo (enum [ 1 2 3 ]) translate (enum schemes);
default = "selfsigned";
description = ''
The scheme to use for managing TLS certificates:
@@ -864,7 +715,6 @@ in
acmeCertificateName = mkOption {
type = types.str;
default = cfg.fqdn;
defaultText = literalExpression "config.mailserver.fqdn";
example = "example.com";
description = ''
({option}`mailserver.certificateScheme` == `acme`)
@@ -877,11 +727,9 @@ in
enableImap = mkOption {
type = types.bool;
default = false;
default = true;
description = ''
Whether to enable IMAP with STARTTLS on port 143.
The use of this port is deprecated per RFC 8314 4.1.
'';
};
@@ -903,11 +751,9 @@ in
enableSubmission = mkOption {
type = types.bool;
default = false;
default = true;
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.
'';
};
@@ -924,8 +770,6 @@ in
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.
'';
};
@@ -991,10 +835,7 @@ in
};
dkimKeyType = mkOption {
type = types.enum [
"rsa"
"ed25519"
];
type = types.enum [ "rsa" "ed25519" ];
default = "rsa";
description = ''
The key type used for generating DKIM keys. ED25519 was introduced in RFC6376 (2018).
@@ -1008,9 +849,9 @@ in
dkimKeyBits = mkOption {
type = types.int;
default = 2048;
default = 1024;
description = ''
How many bits in generated DKIM keys. RFC8301 suggests a minimum RSA key length of 2048 bit.
How many bits in generated DKIM keys. RFC6376 advises minimum 1024-bit keys.
If you have already deployed a key with a different number of bits than specified
here, then you should use a different selector ({option}`mailserver.dkimSelector`). In order to get
@@ -1034,47 +875,70 @@ in
'';
};
localpart = mkOption {
type = types.str;
default = "dmarc-noreply";
example = "dmarc-report";
description = ''
The local part of the email address used for outgoing DMARC reports.
'';
};
domain = mkOption {
type = types.enum (cfg.domains);
example = "example.com";
description = ''
The domain from which outgoing DMARC reports are served.
'';
};
email = mkOption {
type = types.str;
default = with cfg.dmarcReporting; "${localpart}@${domain}";
defaultText = literalExpression ''"''${localpart}@''${domain}"'';
readOnly = true;
description = ''
The email address used for outgoing DMARC reports. Read-only.
'';
};
organizationName = mkOption {
type = types.str;
example = "ACME Corp.";
description = ''
The name of your organization used in the `org_name` attribute in
DMARC reports.
'';
};
fromName = mkOption {
type = types.str;
default = cfg.dmarcReporting.organizationName;
defaultText = literalMD "{option}`mailserver.dmarcReporting.organizationName`";
description = ''
The sender name for DMARC reports. Defaults to the organization name.
'';
};
excludeDomains = mkOption {
type = types.listOf types.str;
default = [ ];
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 {
debug = 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.
Whether to enable verbose logging for 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;
@@ -1101,42 +965,12 @@ in
'';
};
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";
defaultText = lib.literalExpression "config.services.redis.servers.rspamd.unixSocket";
description = ''
Path, IP address or hostname that Rspamd should use to contact Redis.
'';
@@ -1145,7 +979,7 @@ in
port = mkOption {
type = with types; nullOr port;
default = null;
example = literalExpression "config.services.redis.servers.rspamd.port";
example = lib.literalExpression "config.services.redis.servers.rspamd.port";
description = ''
Port that Rspamd should use to contact Redis.
'';
@@ -1154,7 +988,7 @@ in
password = mkOption {
type = types.nullOr types.str;
default = config.services.redis.servers.rspamd.requirePass;
defaultText = literalExpression "config.services.redis.servers.rspamd.requirePass";
defaultText = lib.literalExpression "config.services.redis.servers.rspamd.requirePass";
description = ''
Password that rspamd should use to contact redis, or null if not required.
'';
@@ -1171,10 +1005,25 @@ in
'';
};
smtpdForbidBareNewline = mkOption {
type = types.bool;
default = true;
description = ''
With "smtpd_forbid_bare_newline = yes", the Postfix SMTP server
disconnects a remote SMTP client that sends a line ending in a 'bare
newline'.
This feature was added in Postfix 3.8.4 against SMTP Smuggling and will
default to "yes" in Postfix 3.9.
https://www.postfix.org/smtp-smuggling.html
'';
};
sendingFqdn = mkOption {
type = types.str;
default = cfg.fqdn;
defaultText = literalMD "{option}`mailserver.fqdn`";
defaultText = lib.literalMD "{option}`mailserver.fqdn`";
example = "myserver.example.com";
description = ''
The fully qualified domain name of the mail server used to
@@ -1250,7 +1099,7 @@ in
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)";
defaultText = lib.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 ...'.
@@ -1292,15 +1141,7 @@ in
compression = {
method = mkOption {
type = types.nullOr (
types.enum [
"none"
"lz4"
"zstd"
"zlib"
"lzma"
]
);
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.";
};
@@ -1358,14 +1199,14 @@ in
locations = mkOption {
type = types.listOf types.path;
default = [ cfg.mailDirectory ];
defaultText = literalExpression "[ config.mailserver.mailDirectory ]";
default = [cfg.mailDirectory];
defaultText = lib.literalExpression "[ config.mailserver.mailDirectory ]";
description = "The locations that are to be backed up by borg.";
};
extraArgumentsForInit = mkOption {
type = types.listOf types.str;
default = [ "--critical" ];
default = ["--critical"];
description = "Additional arguments to add to the borg init command line.";
};
@@ -1460,29 +1301,29 @@ in
};
imports = [
(mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "enable" ] ''
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "enable" ] ''
This option is not needed for fts-flatcurve
'')
(mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "onCalendar" ] ''
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "onCalendar" ] ''
This option is not needed for fts-flatcurve
'')
(mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "randomizedDelaySec" ] ''
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "randomizedDelaySec" ] ''
This option is not needed for fts-flatcurve
'')
(mkRemovedOptionModule [ "mailserver" "fullTextSearch" "minSize" ] ''
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "minSize" ] ''
This option is not supported by fts-flatcurve
'')
(mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maxSize" ] ''
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maxSize" ] ''
This option is not needed since fts-xapian 1.8.3
'')
(mkRemovedOptionModule [ "mailserver" "fullTextSearch" "indexAttachments" ] ''
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "indexAttachments" ] ''
Text attachments are always indexed since fts-xapian 1.4.8
'')
(mkRenamedOptionModule
(lib.mkRenamedOptionModule
[ "mailserver" "rebootAfterKernelUpgrade" "enable" ]
[ "system" "autoUpgrade" "allowReboot" ]
)
(mkRemovedOptionModule [ "mailserver" "rebootAfterKernelUpgrade" "method" ] ''
(lib.mkRemovedOptionModule [ "mailserver" "rebootAfterKernelUpgrade" "method" ] ''
Use `system.autoUpgrade` instead.
'')
./mail-server/assertions.nix
@@ -1499,32 +1340,15 @@ in
./mail-server/rspamd.nix
./mail-server/nginx.nix
./mail-server/kresd.nix
(mkRemovedOptionModule [ "mailserver" "policydSPFExtraConfig" ] ''
(lib.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" ] ''
(lib.mkRemovedOptionModule [ "mailserver" "dkimHeaderCanonicalization" ] ''
DKIM signing has been migrated to Rspamd, which always uses relaxed canonicalization.
'')
(mkRemovedOptionModule [ "mailserver" "dkimBodyCanonicalization" ] ''
(lib.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.
'')
(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`.
'')
];
}

View File

@@ -1,14 +0,0 @@
Advanced Configurations
=======================
Congratulations on completing the `Setup Guide <setup-guide.html>`_!
If you're an experienced mailserver admin, then you probably know what you want
to do next. Our How-to guides (accessible in the navigation sidebar)
might help you accomplish your goals. If not, consider contributing a guide!
If this is your first mailserver, consider the following:
- Set up `backups <backup-guide.html>`_.
- Enable `DMARC reporting <options.html#mailserver-dmarcreporting>`_ to be a
good citizen in the mail ecosystem.

View File

@@ -14,13 +14,6 @@ forget to ``chown`` them to ``virtualMail:virtualMail`` if you copy them
back (or whatever you specified as ``vmailUserName``, and
``vmailGoupName``).
If you enabled ``enableManageSieve`` then you also may want to backup
``/var/sieve`` or whatever you have specified as ``sieveDirectory``.
The same considerations regarding file ownership apply as for the
Maildir.
To backup spam and ham training data, backup ``/var/lib/redis-rspamd``.
Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever
you specified as ``dkimKeyDirectory``). If you should lose those dont
worry, new ones will be created on the fly. But you will need to repeat

View File

@@ -64,44 +64,3 @@ To build the documentation, you need to enable `Nix Flakes
$ nix build .#documentation
$ xdg-open result/index.html
Manual migrations
-----------------
We need to take great care around providing a migration story around breaking
changes. If manual intervention becomes necessary we provide the `stateVersion`
option to notify the user that they need to complete a migration before
they can deploy an update.
If that is the case for your change, find the highest `stateVersion` that is
being asserted on in `mail-server/assertions.nix`. Then pick the next number
and add a new assertion, write a good summary describing the issue and what
remediation steps are necessary. Finally reference the URL to the specific
section on the migration page in the documentation.
.. code-block:: nix
{
assertions = [
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 1;
message = ''
Problem: The home directory for the foobar service is snafu.
Remediation:
- Stop the `foobar.service`
- Rename `/var/lib/foobaz` to `/var/lib/foobar`
- Increase the `mailserver.stateVersion` to 1.
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#specific-anchor-here for further details.
'';
}
];
}
The setup guide should always reference the latest `stateVersion`, since we
don't require any migration steps for new setups.
The migration documentation should paint a more complete picture about the steps
that need to be carried out and why this has become necessary. Make sure to
reference the correct anchor in the URL you put into the assertion message.

View File

@@ -14,31 +14,23 @@ Welcome to NixOS Mailserver's documentation!
:maxdepth: 2
setup-guide
advanced-configurations
howto-develop
faq
release-notes
options
migrations
.. toctree::
:maxdepth: 1
:caption: Features
fts
ldap
srs
.. toctree::
:maxdepth: 0
:caption: How-to
backup-guide
add-radicale
add-roundcube
rspamd-tuning
fts
flakes
autodiscovery
ldap
Indices and tables
==================

View File

@@ -1,117 +0,0 @@
Migrations
==========
With mail server configuration best practices changing over time we might need
to make changes that require you to complete manual migration steps before you
can deploy a new version of NixOS mailserver.
The initial `mailserver.stateVersion` value should be copied from the setup
guide that you used to initially set up your mail server. If in doubt you can
always initialize it at `1` and walk through all assertions, that might apply
to your setup.
NixOS 25.11
-----------
#3 Dovecot mail directory migration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The way the Dovecot home directory for login accounts were previously set up
resulted in shared home directories for all those users. This is not a
supported Dovecot configuration.
To resolve this we migrated the home directory into the individual
`domain/localpart` subdirectory below the `mailserver.mailDirectory`.
But since this now overlaps with the location of the Maildir, it must be
migrated into the `mail/` directory below the home directory.
And while the LDAP home directory is not affected we use this migration to
keep the Maildir configurations of LDAP users in sync with those of local
accounts.
This is a big step forward, since we can now more cleanly colocate other
data directories, like sieve in the home directory, which in turn simplifies
backups.
This migration is required for every configuration.
For remediating this issue the following steps are required:
1. Copy the `migration script <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/migrations/nixos-mailserver-migration-03.py>`_ script to your mailserver
and make it executable:
.. code-block:: bash
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/master/migrations/nixos-mailserver-migration-03.py
chmod +x nixos-mailserver-migration-03.py
2. Stop the ``dovecot2.service``.
.. code-block:: bash
systemctl stop dovecot2.service
3. Create a backup or snapshot of your ``mailserver.mailDirectory``, so you can restore
should anything go wrong.
4. Run the migration script under your virtual mail user with the following arguments:
- ``--layout default`` unless ``useFSLayout`` is enabled, then ``--layout folder``
- The value of ``mailserver.mailDirectory``, which defaults to ``/var/vmail``
The script should be run under the user who owns the ``mailDirectory``.
If run as root it will try to switch into the appropriate user by itself.
The script will not modify your data unless called with ``--execute``.
Example:
.. code-block:: bash
./nixos-mailserver-migration-03.py --layout default /var/vmail
5. Review the commands. They should be
- create a ``mail`` directory for each accounnt,
- move maildir contents from the parent directory into it,
- suggest removal of files that do not belong to the maildir
- their removal is not mandatory and the script **will not** remove them when called with ``--execute``
- review these items carefully if you want to remove them yourself
- remove obsolete files from the old home directory location
6. Rerun the command with ``--execute`` or run the commands manually.
7. Update the ``mailserver.stateVersion`` to ``3``.
#2 Dovecot LDAP home directory migration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The Dovecot configuration for LDAP home directories previously did not respect
the ``mailserver.mailDirectory`` setting.
This means that home directories were unconditionally located at
``/var/vmail/ldap/%{user}``.
This migration is required if you both:
* enabled the LDAP integration (``mailserver.ldap.enable``)
* and customized the default mail directory (``mailserver.mailDirectory != "/var/vmail"``)
For remediating this issue the following steps are required:
1. Stop ``dovecot2.service``.
2. Move ``/var/vmail/ldap`` below your ``m̀ailserver.mailDirectory``.
3. Update the ``mailserver.stateVersion`` to ``2``.
#1 Initialization
^^^^^^^^^^^^^^^^^
This option was introduced in the NixOS 25.11 release cycle, in which case you
can safely initialize its value at `1`.
.. code-block:: nix
mailserver.stateVersion = 1;

View File

@@ -1,44 +1,6 @@
Release Notes
=============
NixOS 25.11
-----------
- The ``systemName`` and ``systemDomain`` options have been introduced to have
reusable configurations for automated reports (DMARC, TLSRPT). They come with
reasonable defaults, but it is suggested to check and change them as needed.
- Support for the `Sender Rewriting Scheme`_ has been added, which allows
forwarding mail without breaking SPF by rewriting the envelope address.
- The default key length for new DKIM RSA keys was increased to 2048 bits as
recommended in `RFC 8301 3.2`_.
We recommend rotating existing keys, as the RFC advises that signatures from
1024 bit keys should not be considered valid any longer.
- IMAP access over port ``143/tcp`` is now default disabled in line
with `RFC 8314 4.1`_. Use IMAP over implicit TLS on port ``993/tcp``
instead. If you still require this feature you can reenable it using
``mailserver.enableImap``, but it is scheduled for removal after the 25.11
release.
- SMTP server and client now support and prefer a hybrid key exchange
(X25519MLKEM768)
- SMTP access over STARTTLS on port ``587/tcp`` is now default disabled in line
with `RFC 8314 3.3`_. If you still require this feature you can renable it using
``mailserver.enableSubmission``.
- DMARC reports are now sent with the ``noreply-dmarc`` localpart from the
system domain.
- DANE and MTA-STS are now validated for outgoing SMTP connections using
`postfix-tlspol`_.
- SMTP TLS connection reports (`RFC 8460`_) are now supported using
`tlsrpt-reporter`_. They can be enabled with the ``mailserver.tlsrpt.enable``
option.
.. _Sender Rewriting Scheme: srs.html
.. _RFC 8301 3.2: https://www.rfc-editor.org/rfc/rfc8301#section-3.2
.. _RFC 8314 3.3: https://www.rfc-editor.org/rfc/rfc8314#section-3.3
.. _RFC 8314 4.1: https://www.rfc-editor.org/rfc/rfc8314#section-4.1
.. _RFC 8460: https://www.rfc-editor.org/rfc/rfc8460
.. _postfix-tlspol: https://github.com/Zuplu/postfix-tlspol
.. _tlsrpt-reporter: https://github.com/sys4/tlsrpt-reporter
NixOS 25.05
-----------

View File

@@ -63,16 +63,15 @@ common ones.
imports = [
(builtins.fetchTarball {
# Pick a release version you are interested in and set its hash, e.g.
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-25.11/nixos-mailserver-nixos-25.11.tar.gz";
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-25.05/nixos-mailserver-nixos-25.05.tar.gz";
# To get the sha256 of the nixos-mailserver tarball, we can use the nix-prefetch-url command:
# release="nixos-25.11"; nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack
# release="nixos-25.05"; nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack
sha256 = "0000000000000000000000000000000000000000000000000000";
})
];
mailserver = {
enable = true;
stateVersion = 3;
fqdn = "mail.example.com";
domains = [ "example.com" ];
@@ -238,8 +237,3 @@ Besides that, you can send an email to
score, and let `mxtoolbox.com <http://mxtoolbox.com/>`__ take a look at
your setup, but if you followed the steps closely then everything should
be awesome!
Next steps (optional)
~~~~~~~~~~~~~~~~~~~~~
Take a look through our `Advanced Configurations <advanced-configurations.html>`_.

View File

@@ -1,102 +0,0 @@
Sender Rewriting Scheme
=======================
The Sender Rewriting Scheme (SRS) allows mail servers to forward emails without
breaking SPF checks. By rewriting the envelope sender to an address within the
forwarders domain, SRS ensures that forwarded messages pass SPF validation,
preventing them from being rejected as spoofed or unauthorized.
How SRS works in practice
~~~~~~~~~~~~~~~~~~~~~~~~~
1. ``alice@foo.example`` receives an E-Mail from ``bob@bar.example``. Both the
envelope sender as well as the ``From`` header show ``bob@bar.example``. This
results in strict SPF alignment, because ``bar.example`` is the domain used in
both the ``Return-Path`` and ``FROM`` headers.
2. ``alice@foo.example`` forwards the mail to ``charlie@moo.example`` and
uses SRS to rewrite the envelope sender to originate from the local SRS domain
(e.g. `SRS0=HHH=TT=bar.example=alice@foo.example`). The ``FROM`` header remains
unchanged. This ensures that the forwarded mail succeeds SPF checks.
3. The email reaches ``charlie@moo.example``. SPF passes because the sender
domain in the envelope has been rewritten. The mismatch between envelope sender
domain and ``FROM`` domain does however break strict SPF alignment.
Enabling SRS
~~~~~~~~~~~~
In a simple setup just enabling SRS will use your ``mailserver.systemDomain``
when rewriting the envelope sender domain.
.. code:: nix
{
mailserver = {
srs = {
enable = true;
#domain = "srs.example.com";
};
};
};
..
While you can reuse an existing email domain for SRS, it is recommended to
configure a dedicated SRS domain. This is particularly important under the
following conditions:
* Multiple unrelated mail domains are hosted on the mailserver
* The mail domain requires strict SPF alignment in its DMARC policy
Required DNS changes
~~~~~~~~~~~~~~~~~~~~
.. note::
In the following example we assume that you want to set up a dedicated SRS
domain. If that is not the case you already have SPF and DKIM set up for the
system domain. If you have a DMARC record on the system domain, make sure it
uses a relaxed SPF alignment policy (``aspf=r``).
First we set up an MX record. This is so that we can receive and route bounces
that can result from forwards.
======================== ===== ==== ======== =====================
Name (Subdomain) TTL Type Priority Value
======================== ===== ==== ======== =====================
srs.example.com 10800 MX 10 ``mail.example.com``
======================== ===== ==== ==============================
Next up is the SPF record on the SRS domain to allow SPF authentication.
======================== ===== ==== ===================
Name (Subdomain) TTL Type Value
======================== ===== ==== ===================
srs.example.com 10800 TXT ``v=spf1 mx -all``
======================== ===== ==== ===================
Then we deploy the DKIM record with the `p=<value>` taken from
``/var/dkim/srs.example.com.mail.txt``, that appears after deploying with SRS
enabled.
=============================== ===== ==== ========================================
Name (Subdomain) TTL Type Value
=============================== ===== ==== ========================================
mail._domainkey.srs.example.com 10800 TXT ``v=DKIM1; k=rsa; p=<really-long-key>``
=============================== ===== ==== ========================================
Finally we can tie this together in the DMARC record to require receivers to
verify the requested SPF/DKIM alignment.
.. note::
The SRS domain can only support relaxed SPF alignment due to the envelope
sender and ``FROM`` header mismatch.
======================== ===== ==== =========================================
Name (Subdomain) TTL Type Value
======================== ===== ==== =========================================
_dmarc.srs.example.com 10800 TXT ``v=DMARC1; p=reject; aspf=r; adkim=s;``
======================== ===== ==== =========================================
We can safely configure a ``reject`` policy on the SRS domain, to enforce the
SPF and DKIM alignment as configured above.

39
flake.lock generated
View File

@@ -19,11 +19,11 @@
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"lastModified": 1747046372,
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"type": "github"
},
"original": {
@@ -43,11 +43,11 @@
]
},
"locked": {
"lastModified": 1763319842,
"narHash": "sha256-YG19IyrTdnVn0l3DvcUYm85u3PaqBt6tI6VvolcuHnA=",
"lastModified": 1742649964,
"narHash": "sha256-DwOTp7nvfi8mRfuL1escHDXabVXFGT1VlPD1JHrtrco=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "7275fa67fbbb75891c16d9dee7d88e58aea2d761",
"rev": "dcf5072734cb576d2b0c59b2ac44f5050b5eac82",
"type": "github"
},
"original": {
@@ -79,16 +79,32 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1764020296,
"narHash": "sha256-6zddwDs2n+n01l+1TG6PlyokDdXzu/oBmEejcH5L5+A=",
"lastModified": 1747179050,
"narHash": "sha256-qhFMmDkeJX9KJwr5H32f1r7Prs7XbQWtO0h3V0a0rFY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a320ce8e6e2cc6b4397eef214d202a50a4583829",
"rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11-small",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-25_05": {
"locked": {
"lastModified": 1747610100,
"narHash": "sha256-rpR5ZPMkWzcnCcYYo3lScqfuzEw5Uyfh+R0EKZfroAc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ca49c4304acf0973078db0a9d200fd2bae75676d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
@@ -98,7 +114,8 @@
"blobs": "blobs",
"flake-compat": "flake-compat",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"nixpkgs-25_05": "nixpkgs-25_05"
}
}
},

View File

@@ -12,31 +12,29 @@
inputs.flake-compat.follows = "flake-compat";
inputs.nixpkgs.follows = "nixpkgs";
};
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11-small";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs-25_05.url = "github:NixOS/nixpkgs/nixos-25.05";
blobs = {
url = "gitlab:simple-nixos-mailserver/blobs";
flake = false;
};
};
outputs =
{
self,
blobs,
git-hooks,
nixpkgs,
...
}:
let
outputs = { self, blobs, git-hooks, nixpkgs, nixpkgs-25_05, ... }: let
lib = nixpkgs.lib;
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
releases = [
{
name = "nixos-25.11";
name = "unstable";
nixpkgs = nixpkgs;
pkgs = nixpkgs.legacyPackages.${system};
}
{
name = "25.05";
nixpkgs = nixpkgs-25_05;
pkgs = nixpkgs-25_05.legacyPackages.${system};
}
];
testNames = [
"clamav"
@@ -46,16 +44,13 @@
"multiple"
];
genTest =
testName: release:
let
genTest = testName: release: let
pkgs = release.pkgs;
nixos-lib = import (release.nixpkgs + "/nixos/lib") {
inherit (pkgs) lib;
};
in
{
name = "${testName}-${builtins.replaceStrings [ "." ] [ "_" ] release.name}";
in {
name = "${testName}-${builtins.replaceStrings ["."] ["_"] release.name}";
value = nixos-lib.runTest {
hostPkgs = pkgs;
imports = [ ./tests/${testName}.nix ];
@@ -70,13 +65,13 @@
# external-21_05 = <derivation>;
# ...
# }
allTests = lib.listToAttrs (lib.flatten (map (t: map (r: genTest t r) releases) testNames));
allTests = lib.listToAttrs (
lib.flatten (map (t: map (r: genTest t r) releases) testNames));
mailserverModule = import ./.;
# Generate a MarkDown file describing the options of the NixOS mailserver module
optionsDoc =
let
optionsDoc = let
eval = lib.evalModules {
modules = [
mailserverModule
@@ -84,23 +79,21 @@
_module.check = false;
mailserver = {
fqdn = "mx.example.com";
systemDomain = "example.com";
domains = [
"example.com"
];
dmarcReporting = {
organizationName = "Example Corp";
domain = "example.com";
};
};
}
];
};
options = builtins.toFile "options.json" (
builtins.toJSON (
lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver") (
lib.optionAttrSetToDocList eval.options
)
)
);
in
pkgs.runCommand "options.md" { buildInputs = [ pkgs.python3Minimal ]; } ''
options = builtins.toFile "options.json" (builtins.toJSON
(lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver")
(lib.optionAttrSetToDocList eval.options)));
in pkgs.runCommand "options.md" { buildInputs = [pkgs.python3Minimal]; } ''
echo "Generating options.md from ${options}"
python ${./scripts/generate-options.py} ${options} > $out
echo $out
@@ -108,22 +101,15 @@
documentation = pkgs.stdenv.mkDerivation {
name = "documentation";
src = lib.sourceByRegex ./docs [
"logo\\.png"
"conf\\.py"
"Makefile"
".*\\.rst"
];
buildInputs = [
(pkgs.python3.withPackages (
p: with p; [
src = lib.sourceByRegex ./docs ["logo\\.png" "conf\\.py" "Makefile" ".*\\.rst"];
buildInputs = [(
pkgs.python3.withPackages (p: with p; [
sphinx
sphinx-rtd-theme
sphinx_rtd_theme
myst-parser
linkify-it-py
]
))
];
])
)];
buildPhase = ''
cp ${optionsDoc} options.md
# Workaround for https://github.com/sphinx-doc/sphinx/issues/3451
@@ -135,8 +121,7 @@
'';
};
in
{
in {
nixosModules = rec {
mailserver = mailserverModule;
default = mailserver;
@@ -168,7 +153,6 @@
# nix
deadnix.enable = true;
nixfmt-rfc-style.enable = true;
# python
pyright.enable = true;
@@ -199,16 +183,11 @@
};
devShells.${system}.default = pkgs.mkShellNoCC {
inputsFrom = [ documentation ];
packages =
with pkgs;
[
packages = with pkgs; [
glab
]
++ self.checks.${system}.pre-commit.enabledPackages;
] ++ self.checks.${system}.pre-commit.enabledPackages;
shellHook = self.checks.${system}.pre-commit.shellHook;
};
devShell.${system} = self.devShells.${system}.default; # compatibility
formatter.${system} = pkgs.nixfmt-tree;
};
}

View File

@@ -1,85 +1,18 @@
{ config, lib, ... }:
{
config,
lib,
...
}:
let
mailserverRelease = "25.11";
nixpkgsRelease = lib.trivial.release;
releaseMismatch =
config.mailserver.enableNixpkgsReleaseCheck && mailserverRelease != nixpkgsRelease;
in
{
warnings = lib.optional releaseMismatch ''
You are using
NixOS Mailserver version ${mailserverRelease} and
Nixpkgs version ${nixpkgsRelease}.
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.
If you insist then you can disable this warning by adding
mailserver.enableNixpkgsReleaseCheck = false;
to your configuration.
'';
# We guard all assertions by requiring mailserver to be actually enabled
assertions = lib.optionals config.mailserver.enable (
[
assertions = lib.optionals config.mailserver.ldap.enable [
{
assertion = config.mailserver.stateVersion != null;
message = "The `mailserver.stateVersion` option is not set. Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html to determine the proper value to initialize it at.";
}
]
++ lib.optionals config.mailserver.ldap.enable [
{
assertion = config.mailserver.loginAccounts == { };
assertion = config.mailserver.loginAccounts == {};
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.loginAccounts";
}
{
assertion = config.mailserver.extraVirtualAliases == { };
assertion = config.mailserver.extraVirtualAliases == {};
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases";
}
]
++
lib.optionals (config.mailserver.ldap.enable && config.mailserver.mailDirectory != "/var/vmail")
[
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 2;
message = ''
Issue: The dovecot homedir for LDAP users was previously not respecting `mailserver.mailDirectory`.
Remediation:
- Stop the `dovecot2.service`
- Move `/var/vmail/ldap` below your `mailserver.mailDirectory`
- Increase the `stateVersion` to 2.
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-home-directory-migration for more information.
'';
}
]
++ [
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 3;
message = ''
Issue: The dovecot mail location for all users has changed and need to be migrated.
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-mail-directory-migration for the required remediation steps.
'';
}
]
++ lib.optionals (config.mailserver.certificateScheme != "acme") [
] ++ lib.optionals (config.mailserver.enable && config.mailserver.certificateScheme != "acme") [
{
assertion = config.mailserver.acmeCertificateName == config.mailserver.fqdn;
message = "When the certificate scheme is not 'acme' (mailserver.certificateScheme != \"acme\"), it is not possible to define mailserver.acmeCertificateName";
}
]
);
];
}

View File

@@ -14,44 +14,28 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
pkgs,
lib,
...
}:
{ config, pkgs, lib, ... }:
let
cfg = config.mailserver.borgbackup;
methodFragment = lib.optional (cfg.compression.method != null) cfg.compression.method;
autoFragment =
if cfg.compression.auto && cfg.compression.method == null then
throw "compression.method must be set when using auto."
else
lib.optional cfg.compression.auto "auto";
if cfg.compression.auto && cfg.compression.method == null
then throw "compression.method must be set when using auto."
else lib.optional cfg.compression.auto "auto";
levelFragment =
if cfg.compression.level != null && cfg.compression.method == null then
throw "compression.method must be set when using compression.level."
else
lib.optional (cfg.compression.level != null) (toString cfg.compression.level);
compressionFragment = lib.concatStringsSep "," (
lib.flatten [
autoFragment
methodFragment
levelFragment
]
);
if cfg.compression.level != null && cfg.compression.method == null
then throw "compression.method must be set when using compression.level."
else lib.optional (cfg.compression.level != null) (toString cfg.compression.level);
compressionFragment = lib.concatStringsSep "," (lib.flatten [autoFragment methodFragment levelFragment]);
compression = lib.optionalString (compressionFragment != "") "--compression ${compressionFragment}";
encryptionFragment = cfg.encryption.method;
passphraseFile = lib.escapeShellArg cfg.encryption.passphraseFile;
passphraseFragment = lib.optionalString (cfg.encryption.method != "none") (
if cfg.encryption.passphraseFile != null then
''env BORG_PASSPHRASE="$(cat ${passphraseFile})"''
else
throw "passphraseFile must be set when using encryption."
);
passphraseFragment = lib.optionalString (cfg.encryption.method != "none")
(if cfg.encryption.passphraseFile != null then ''env BORG_PASSPHRASE="$(cat ${passphraseFile})"''
else throw "passphraseFile must be set when using encryption.");
locations = lib.escapeShellArgs cfg.locations;
name = lib.escapeShellArg cfg.name;
@@ -71,8 +55,7 @@ let
${passphraseFragment} ${pkgs.borgbackup}/bin/borg create ${extraCreateArgs} ${compression} ::${name} ${locations}
${cmdPostexec}
'';
in
{
in {
config = lib.mkIf (config.mailserver.enable && cfg.enable) {
environment.systemPackages = with pkgs; [
borgbackup

View File

@@ -14,63 +14,43 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
pkgs,
lib,
...
}:
{ config, pkgs, lib }:
let
cfg = config.mailserver;
in
{
# cert :: PATH
certificatePath =
if cfg.certificateScheme == "manual" then
cfg.certificateFile
else if cfg.certificateScheme == "selfsigned" then
"${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem"
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then
"${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/fullchain.pem"
else
throw "unknown certificate scheme";
certificatePath = if cfg.certificateScheme == "manual"
then cfg.certificateFile
else if cfg.certificateScheme == "selfsigned"
then "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem"
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx"
then "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/fullchain.pem"
else throw "unknown certificate scheme";
# key :: PATH
keyPath =
if cfg.certificateScheme == "manual" then
cfg.keyFile
else if cfg.certificateScheme == "selfsigned" then
"${cfg.certificateDirectory}/key-${cfg.fqdn}.pem"
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then
"${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/key.pem"
else
throw "unknown certificate scheme";
keyPath = if cfg.certificateScheme == "manual"
then cfg.keyFile
else if cfg.certificateScheme == "selfsigned"
then "${cfg.certificateDirectory}/key-${cfg.fqdn}.pem"
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx"
then "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/key.pem"
else throw "unknown certificate scheme";
passwordFiles =
let
passwordFiles = let
mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash;
in
lib.mapAttrs (
name: value:
lib.mapAttrs (name: value:
if value.hashedPasswordFile == null then
builtins.toString (mkHashFile name value.hashedPassword)
else
value.hashedPasswordFile
) cfg.loginAccounts;
else value.hashedPasswordFile) cfg.loginAccounts;
# Appends the LDAP bind password to files to avoid writing this
# password into the Nix store.
appendLdapBindPwd =
{
name,
file,
prefix,
suffix ? "",
passwordFile,
destination,
}:
pkgs.writeScript "append-ldap-bind-pwd-in-${name}" ''
appendLdapBindPwd = {
name, file, prefix, suffix ? "", passwordFile, destination
}: pkgs.writeScript "append-ldap-bind-pwd-in-${name}" ''
#!${pkgs.stdenv.shell}
set -euo pipefail

View File

@@ -14,22 +14,9 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
{ options, config, pkgs, lib, ... }:
with (import ./common.nix {
inherit
config
options
pkgs
lib
;
});
with (import ./common.nix { inherit config pkgs lib; });
let
cfg = config.mailserver;
@@ -41,23 +28,20 @@ let
ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext";
boolToYesNo = x: if x then "yes" else "no";
listToLine = lib.concatStringsSep " ";
listToMultiAttrs =
keyPrefix: attrs:
lib.listToAttrs (
lib.imap1 (n: x: {
name = "${keyPrefix}${if n == 1 then "" else toString n}";
listToMultiAttrs = keyPrefix: attrs: lib.listToAttrs (lib.imap1 (n: x: {
name = "${keyPrefix}${if n==1 then "" else toString n}";
value = x;
}) attrs
);
}) attrs);
maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs";
maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8";
# https://doc.dovecot.org/2.3/configuration_manual/home_directories_for_virtual_users/#ways-to-set-up-home-directory
# Mail directory below the home directory
# maildir in format "/${domain}/${user}"
dovecotMaildir =
"maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}"
+ (lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/%{domain}/%{username}");
"maildir:${cfg.mailDirectory}/%{domain}/%{username}${maildirLayoutAppendix}${maildirUTF8FolderNames}"
+ (lib.optionalString (cfg.indexDir != null)
":INDEX=${cfg.indexDir}/%{domain}/%{username}"
);
postfixCfg = config.services.postfix;
@@ -109,9 +93,7 @@ let
# Prevent world-readable password files, even temporarily.
umask 077
for f in ${
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts)
}; do
for f in ${builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts)}; do
if [ ! -f "$f" ]; then
echo "Expected password hash file $f does not exist!"
exit 1
@@ -119,61 +101,51 @@ let
done
cat <<EOF > ${passwdFile}
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: _: "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
) cfg.loginAccounts
)}
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _:
"${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
) cfg.loginAccounts)}
EOF
cat <<EOF > ${userdbFile}
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: value:
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value:
"${name}:::::::"
+ lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}"
) cfg.loginAccounts
)}
) cfg.loginAccounts)}
EOF
'';
junkMailboxes = builtins.attrNames (
lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes
);
junkMailboxes = builtins.attrNames (lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes);
junkMailboxNumber = builtins.length junkMailboxes;
# The assertion garantees there is exactly one Junk mailbox.
junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else "";
mkLdapSearchScope =
scope:
(
if scope == "sub" then
"subtree"
else if scope == "one" then
"onelevel"
else
scope
mkLdapSearchScope = scope: (
if scope == "sub" then "subtree"
else if scope == "one" then "onelevel"
else scope
);
dovecotModules = [
pkgs.dovecot_pigeonhole
] ++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve;
# Remove and assume `false` after NixOS 25.05
haveDovecotModulesOption = options.services.dovecot2 ? "modules" && (options.services.dovecot2.modules.visible or true);
ftsPluginSettings = {
fts = "flatcurve";
fts_languages = listToLine cfg.fullTextSearch.languages;
fts_tokenizers = listToLine [
"generic"
"email-address"
];
fts_tokenizers = listToLine [ "generic" "email-address" ];
fts_tokenizer_email_address = "maxlen=100"; # default 254 too large for Xapian
fts_flatcurve_substring_search = boolToYesNo cfg.fullTextSearch.substringSearch;
fts_filters = listToLine cfg.fullTextSearch.filters;
fts_header_excludes = listToLine cfg.fullTextSearch.headerExcludes;
fts_autoindex = boolToYesNo cfg.fullTextSearch.autoIndex;
fts_enforced = cfg.fullTextSearch.enforced;
}
// (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude);
} // (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude);
in
{
config = lib.mkIf cfg.enable {
config = with cfg; lib.mkIf enable {
assertions = [
{
assertion = junkMailboxNumber == 1;
@@ -182,43 +154,42 @@ in
];
warnings =
lib.optional
(
(builtins.length cfg.fullTextSearch.languages > 1)
&& (builtins.elem "stopwords" cfg.fullTextSearch.filters)
)
''
(lib.optional (
(builtins.length cfg.fullTextSearch.languages > 1) &&
(builtins.elem "stopwords" cfg.fullTextSearch.filters)
) ''
Using stopwords in `mailserver.fullTextSearch.filters` with multiple
languages in `mailserver.fullTextSearch.languages` configured WILL
cause some searches to fail.
The recommended solution is to NOT use the stopword filter when
multiple languages are present in the configuration.
'';
'')
;
# for sieve-test. Shelling it in on demand usually doesnt' work, as it reads
# the global config and tries to open shared libraries configured in there,
# which are usually not compatible.
environment.systemPackages = [
pkgs.dovecot_pigeonhole
]
++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve;
] ++ lib.optionals (!haveDovecotModulesOption) dovecotModules;
# For compatibility with python imaplib
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
environment.etc = lib.mkIf (!haveDovecotModulesOption) {
"dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
};
services.dovecot2 = {
services.dovecot2 = lib.mkMerge [{
enable = true;
enableImap = cfg.enableImap || cfg.enableImapSsl;
enablePop3 = cfg.enablePop3 || cfg.enablePop3Ssl;
enableImap = enableImap || enableImapSsl;
enablePop3 = enablePop3 || enablePop3Ssl;
enablePAM = false;
enableQuota = true;
mailGroup = cfg.vmailGroupName;
mailUser = cfg.vmailUserName;
mailGroup = vmailGroupName;
mailUser = vmailUserName;
mailLocation = dovecotMaildir;
sslServerCert = certificatePath;
sslServerKey = keyPath;
enableDHE = lib.mkDefault false;
enableLmtp = true;
mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [
"fts"
@@ -230,8 +201,7 @@ in
sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve";
sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve";
sieve_default_name = "default";
}
// (lib.optionalAttrs cfg.fullTextSearch.enable ftsPluginSettings);
} // (lib.optionalAttrs cfg.fullTextSearch.enable ftsPluginSettings);
sieve = {
extensions = [
@@ -248,18 +218,17 @@ in
'';
pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_ham")
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_spam")
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh"
"exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_ham")
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh"
"exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_spam")
];
};
imapsieve.mailbox = [
{
name = junkMailboxName;
causes = [
"COPY"
"APPEND"
];
causes = [ "COPY" "APPEND" ];
before = ./dovecot/imap_sieve/report-spam.sieve;
}
{
@@ -274,7 +243,7 @@ in
extraConfig = ''
#Extra Config
${lib.optionalString cfg.debug.dovecot ''
${lib.optionalString debug ''
mail_debug = yes
auth_debug = yes
verbose_ssl = yes
@@ -283,62 +252,42 @@ in
${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) ''
service imap-login {
inet_listener imap {
${
if cfg.enableImap then
''
${if cfg.enableImap then ''
port = 143
''
else
''
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
''}
}
inet_listener imaps {
${
if cfg.enableImapSsl then
''
${if cfg.enableImapSsl then ''
port = 993
ssl = yes
''
else
''
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
''}
}
}
''}
${lib.optionalString (cfg.enablePop3 || cfg.enablePop3Ssl) ''
service pop3-login {
inet_listener pop3 {
${
if cfg.enablePop3 then
''
${if cfg.enablePop3 then ''
port = 110
''
else
''
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
''}
}
inet_listener pop3s {
${
if cfg.enablePop3Ssl then
''
${if cfg.enablePop3Ssl then ''
port = 995
ssl = yes
''
else
''
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
''}
}
}
''}
@@ -356,13 +305,10 @@ in
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
}
mail_access_groups = ${cfg.vmailGroupName}
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7
mail_access_groups = ${vmailGroupName}
ssl = required
ssl_min_protocol = TLSv1
ssl_prefer_server_ciphers = no
ssl_curve_list = X25519MLKEM768:X25519:prime256v1:secp384r1
service lmtp {
unix_listener dovecot-lmtp {
@@ -398,10 +344,7 @@ in
userdb {
driver = passwd-file
args = ${userdbFile}
default_fields = \
home=${cfg.mailDirectory}/%{domain}/%{username} \
uid=${builtins.toString cfg.vmailUID} \
gid=${builtins.toString cfg.vmailUID}
default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}
}
${lib.optionalString cfg.ldap.enable ''
@@ -413,14 +356,7 @@ in
userdb {
driver = ldap
args = ${ldapConfFile}
default_fields = \
home=${cfg.mailDirectory}/ldap/%{user} \
uid=${toString cfg.vmailUID} \
gid=${toString cfg.vmailUID} \
mail=maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}${
lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/ldap/%{user}"
}
default_fields = home=/var/vmail/ldap/%{user} uid=${toString cfg.vmailUID} gid=${toString cfg.vmailUID}
}
''}
@@ -441,25 +377,25 @@ in
service indexer-worker {
${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) ''
vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit * 1024 * 1024)}
vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit*1024*1024)}
''}
}
lda_mailbox_autosubscribe = yes
lda_mailbox_autocreate = yes
'';
};
}
(lib.mkIf haveDovecotModulesOption {
modules = dovecotModules;
})
];
systemd.services.dovecot = {
systemd.services.dovecot2 = {
preStart = ''
${genPasswdScript}
''
+ (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
'' + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
};
systemd.services.postfix.restartTriggers = [
genPasswdScript
]
++ (lib.optional cfg.ldap.enable [ setPwdInLdapConfFile ]);
systemd.services.postfix.restartTriggers = [ genPasswdScript ] ++ (lib.optional cfg.ldap.enable [setPwdInLdapConfFile]);
};
}

View File

@@ -14,26 +14,15 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
pkgs,
lib,
...
}:
{ config, pkgs, lib, ... }:
let
cfg = config.mailserver;
in
{
config = lib.mkIf cfg.enable {
environment.systemPackages =
with pkgs;
[
dovecot
openssh
postfix
rspamd
]
++ (if cfg.certificateScheme == "selfsigned" then [ openssl ] else [ ]);
config = with cfg; lib.mkIf enable {
environment.systemPackages = with pkgs; [
dovecot openssh postfix rspamd
] ++ (if certificateScheme == "selfsigned" then [ openssl ] else []);
};
}

View File

@@ -24,3 +24,4 @@ in
services.kresd.enable = true;
};
}

View File

@@ -20,20 +20,18 @@ let
cfg = config.mailserver;
in
{
config = lib.mkIf (cfg.enable && cfg.openFirewall) {
config = with cfg; lib.mkIf (enable && openFirewall) {
networking.firewall = {
allowedTCPPorts = [
25
]
++ lib.optional cfg.enableSubmission 587
++ lib.optional cfg.enableSubmissionSsl 465
++ lib.optional cfg.enableImap 143
++ lib.optional cfg.enableImapSsl 993
++ lib.optional cfg.enablePop3 110
++ lib.optional cfg.enablePop3Ssl 995
++ lib.optional cfg.enableManageSieve 4190
++ lib.optional (cfg.certificateScheme == "acme-nginx") 80;
allowedTCPPorts = [ 25 ]
++ lib.optional enableSubmission 587
++ lib.optional enableSubmissionSsl 465
++ lib.optional enableImap 143
++ lib.optional enableImapSsl 993
++ lib.optional enablePop3 110
++ lib.optional enablePop3Ssl 995
++ lib.optional enableManageSieve 4190
++ lib.optional (certificateScheme == "acme-nginx") 80;
};
};
}

View File

@@ -14,30 +14,16 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
{ config, pkgs, lib, ... }:
with (import ./common.nix { inherit config lib pkgs; });
let
cfg = config.mailserver;
in
{
config =
lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx"))
{
config = lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx")) {
services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") {
enable = true;
virtualHosts."${cfg.fqdn}" = {
@@ -48,12 +34,9 @@ in
};
};
security.acme.certs."${cfg.acmeCertificateName}" = {
extraDomainNames = lib.mkIf (cfg.certificateScheme == "acme") cfg.certificateDomains;
reloadServices = [
security.acme.certs."${cfg.acmeCertificateName}".reloadServices = [
"postfix.service"
"dovecot.service"
"dovecot2.service"
];
};
};
}

View File

@@ -14,84 +14,45 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
{ config, pkgs, lib, ... }:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
with (import ./common.nix { inherit config pkgs lib; });
let
inherit (lib.strings) concatStringsSep;
cfg = config.mailserver;
iniFormat = pkgs.formats.iniWithGlobalSection { };
# Merge several lookup tables. A lookup table is a attribute set where
# - the key is an address (user@example.com) or a domain (@example.com)
# - the value is a list of addresses
mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables;
# valiases_postfix :: Map String [String]
valiases_postfix = mergeLookupTables (
lib.flatten (
lib.mapAttrsToList (
name: value:
let
to = name;
in
map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name)
) cfg.loginAccounts
)
);
regex_valiases_postfix = mergeLookupTables (
lib.flatten (
lib.mapAttrsToList (
name: value:
let
to = name;
in
map (from: { "${from}" = to; }) value.aliasesRegexp
) cfg.loginAccounts
)
);
valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
(name: value:
let to = name;
in map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name))
cfg.loginAccounts));
regex_valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
(name: value:
let to = name;
in map (from: {"${from}" = to;}) value.aliasesRegexp)
cfg.loginAccounts));
# catchAllPostfix :: Map String [String]
catchAllPostfix = mergeLookupTables (
lib.flatten (
lib.mapAttrsToList (
name: value:
let
to = name;
in
map (from: { "@${from}" = to; }) value.catchAll
) cfg.loginAccounts
)
);
catchAllPostfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
(name: value:
let to = name;
in map (from: {"@${from}" = to;}) value.catchAll)
cfg.loginAccounts));
# all_valiases_postfix :: Map String [String]
all_valiases_postfix = mergeLookupTables [
valiases_postfix
extra_valiases_postfix
];
all_valiases_postfix = mergeLookupTables [valiases_postfix extra_valiases_postfix];
# attrsToLookupTable :: Map String (Either String [ String ]) -> Map String [String]
attrsToLookupTable =
aliases:
let
lookupTables = lib.mapAttrsToList (from: to: { "${from}" = to; }) aliases;
in
mergeLookupTables lookupTables;
attrsToLookupTable = aliases: let
lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases;
in mergeLookupTables lookupTables;
# extra_valiases_postfix :: Map String [String]
extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases;
@@ -100,49 +61,37 @@ let
forwards = attrsToLookupTable cfg.forwards;
# lookupTableToString :: Map String [String] -> String
lookupTableToString =
attrs:
let
lookupTableToString = attrs: let
valueToString = value: lib.concatStringsSep ", " value;
in
lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs
);
in lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs);
# valiases_file :: Path
valiases_file =
let
content = lookupTableToString (mergeLookupTables [
all_valiases_postfix
catchAllPostfix
]);
in
builtins.toFile "valias" content;
valiases_file = let
content = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix]);
in builtins.toFile "valias" content;
regex_valiases_file =
let
regex_valiases_file = let
content = lookupTableToString regex_valiases_postfix;
in
builtins.toFile "regex_valias" content;
in builtins.toFile "regex_valias" content;
# denied_recipients_postfix :: [ String ]
denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") (
lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts)
);
denied_recipients_file = builtins.toFile "denied_recipients" (
lib.concatStringsSep "\n" denied_recipients_postfix
);
denied_recipients_postfix = (map
(acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}")
(lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts)));
denied_recipients_file = builtins.toFile "denied_recipients" (lib.concatStringsSep "\n" denied_recipients_postfix);
reject_senders_postfix = map (sender: "${sender} REJECT") cfg.rejectSender;
reject_senders_file = builtins.toFile "reject_senders" (
lib.concatStringsSep "\n" reject_senders_postfix
);
reject_senders_postfix = (map
(sender:
"${sender} REJECT")
(cfg.rejectSender));
reject_senders_file = builtins.toFile "reject_senders" (lib.concatStringsSep "\n" (reject_senders_postfix)) ;
reject_recipients_postfix = map (recipient: "${recipient} REJECT") cfg.rejectRecipients;
reject_recipients_postfix = (map
(recipient:
"${recipient} REJECT")
(cfg.rejectRecipients));
# rejectRecipients :: [ Path ]
reject_recipients_file = builtins.toFile "reject_recipients" (
lib.concatStringsSep "\n" reject_recipients_postfix
);
reject_recipients_file = builtins.toFile "reject_recipients" (lib.concatStringsSep "\n" (reject_recipients_postfix)) ;
# vhosts_file :: Path
vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains);
@@ -154,12 +103,9 @@ let
# every alias is owned (uniquely) by its user.
# The user's own address is already in all_valiases_postfix.
vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix);
regex_vaccounts_file = builtins.toFile "regex_vaccounts" (
lookupTableToString regex_valiases_postfix
);
regex_vaccounts_file = builtins.toFile "regex_vaccounts" (lookupTableToString regex_valiases_postfix);
submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" (
''
submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" (''
# Removes sensitive headers from mails handed in via the submission port.
# See https://thomas-leister.de/mailserver-debian-stretch/
# Uses "pcre" style regex.
@@ -169,22 +115,21 @@ let
/^X-Mailer:/ IGNORE
/^User-Agent:/ IGNORE
/^X-Enigmail:/ IGNORE
''
+ lib.optionalString cfg.rewriteMessageId ''
'' + lib.optionalString cfg.rewriteMessageId ''
# Replaces the user submitted hostname with the server's FQDN to hide the
# user's host or network.
/^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
''
);
'');
smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ];
mappedFile = name: "hash:/var/lib/postfix/conf/${name}";
mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}";
submissionOptions = {
submissionOptions =
{
smtpd_tls_security_level = "encrypt";
smtpd_sasl_auth_enable = "yes";
smtpd_sasl_type = "dovecot";
@@ -192,9 +137,7 @@ let
smtpd_sasl_security_options = "noanonymous";
smtpd_sasl_local_domain = "$myhostname";
smtpd_client_restrictions = "permit_sasl_authenticated,reject";
smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${
lib.optionalString (regex_valiases_postfix != { }) ",pcre:/etc/postfix/regex_vaccounts"
}";
smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${lib.optionalString (regex_valiases_postfix != {}) ",pcre:/etc/postfix/regex_vaccounts"}";
smtpd_sender_restrictions = "reject_sender_login_mismatch";
smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject";
cleanup_service_name = "submission-header-cleanup";
@@ -243,55 +186,20 @@ let
};
in
{
config = lib.mkIf cfg.enable {
# SMTP TLS error reporting (RFC 8460)
services.tlsrpt = {
inherit (cfg.tlsrpt) enable;
configurePostfix = true;
reportd.settings = {
organization_name = cfg.systemName;
contact_info = "${cfg.systemContact}";
sender_address = "noreply-tlsrpt@${cfg.systemDomain}";
};
};
# SMTP client policy mapping for DANE (RFC 6698) and MTA-STS (RFC 8461)
services.postfix-tlspol = {
enable = true;
configurePostfix = true;
};
# Sender Rewriting Scheme (https://www.libsrs2.net/srs/srs.pdf)
services.postsrsd = {
inherit (cfg.srs) enable;
configurePostfix = true;
settings = {
domains = lib.unique (
[
cfg.fqdn
cfg.sendingFqdn
cfg.systemDomain
]
++ cfg.domains
);
separator = "=";
srs-domain = cfg.srs.domain;
};
};
config = with cfg; lib.mkIf enable {
systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable {
preStart = ''
${appendPwdInVirtualMailboxMap}
${appendPwdInSenderLoginMap}
'';
restartTriggers = [
appendPwdInVirtualMailboxMap
appendPwdInSenderLoginMap
];
restartTriggers = [ appendPwdInVirtualMailboxMap appendPwdInSenderLoginMap ];
};
services.postfix = {
enable = true;
hostname = "${sendingFqdn}";
networksStyle = "host";
mapFiles."valias" = valiases_file;
mapFiles."regex_valias" = regex_valiases_file;
mapFiles."vaccounts" = vaccounts_file;
@@ -299,54 +207,50 @@ in
mapFiles."denied_recipients" = denied_recipients_file;
mapFiles."reject_senders" = reject_senders_file;
mapFiles."reject_recipients" = reject_recipients_file;
sslCert = certificatePath;
sslKey = keyPath;
enableSubmission = cfg.enableSubmission;
enableSubmissions = cfg.enableSubmissionSsl;
virtual = lookupTableToString (mergeLookupTables [
all_valiases_postfix
catchAllPostfix
forwards
]);
virtual = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix forwards]);
settings.main = {
myhostname = cfg.sendingFqdn;
mydestination = ""; # disable local mail delivery
config = {
# Extra Config
mydestination = "";
recipient_delimiter = cfg.recipientDelimiter;
smtpd_banner = "${cfg.fqdn} ESMTP NO UCE";
smtpd_banner = "${fqdn} ESMTP NO UCE";
disable_vrfy_command = true;
message_size_limit = cfg.messageSizeLimit;
message_size_limit = toString cfg.messageSizeLimit;
# virtual mail system
virtual_uid_maps = "static:5000";
virtual_gid_maps = "static:5000";
virtual_mailbox_base = cfg.mailDirectory;
virtual_mailbox_base = mailDirectory;
virtual_mailbox_domains = vhosts_file;
virtual_mailbox_maps = [
(mappedFile "valias")
]
++ lib.optionals cfg.ldap.enable [
] ++ lib.optionals (cfg.ldap.enable) [
"ldap:${ldapVirtualMailboxMapFile}"
]
++ lib.optionals (regex_valiases_postfix != { }) [
] ++ lib.optionals (regex_valiases_postfix != {}) [
(mappedRegexFile "regex_valias")
];
virtual_alias_maps = lib.mkAfter (
lib.optionals (regex_valiases_postfix != { }) [
virtual_alias_maps = lib.mkAfter (lib.optionals (regex_valiases_postfix != {}) [
(mappedRegexFile "regex_valias")
]
);
]);
virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp";
# Avoid leakage of X-Original-To, X-Delivered-To headers between recipients
lmtp_destination_recipient_limit = "1";
# Opportunistic DANE support
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level
smtp_dns_support_level = "dnssec";
smtp_tls_security_level = "dane";
# sasl with dovecot
smtpd_sasl_type = "dovecot";
smtpd_sasl_path = "/run/dovecot2/auth";
smtpd_sasl_auth_enable = true;
smtpd_relay_restrictions = [
"permit_mynetworks"
"permit_sasl_authenticated"
"reject_unauth_destination"
"permit_mynetworks" "permit_sasl_authenticated" "reject_unauth_destination"
];
# reject selected senders
@@ -362,92 +266,52 @@ in
"check_policy_service unix:/run/dovecot2/quota-status"
];
# The X509 private key followed by the corresponding certificate
smtpd_tls_chain_files = [
"${keyPath}"
"${certificatePath}"
];
# TLS for incoming mail is optional
# TLS settings, inspired by https://github.com/jeaye/nix-files
# Submission by mail clients is handled in submissionOptions
smtpd_tls_security_level = "may";
# But required for authentication attempts
smtpd_tls_auth_only = true;
# Disable obselete protocols
smtpd_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3";
smtp_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3";
smtpd_tls_mandatory_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3";
smtp_tls_mandatory_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3";
# TLS versions supported for the SMTP server
smtpd_tls_protocols = ">=TLSv1";
smtpd_tls_mandatory_protocols = ">=TLSv1";
# Require ciphersuites that OpenSSL classifies as "High"
smtp_tls_ciphers = "high";
smtpd_tls_ciphers = "high";
smtp_tls_mandatory_ciphers = "high";
smtpd_tls_mandatory_ciphers = "high";
# Exclude cipher suites with undesirable properties
smtpd_tls_exclude_ciphers = "SHA1, eNULL, aNULL";
smtpd_tls_mandatory_exclude_ciphers = "SHA1, eNULL, aNULL";
# Disable deprecated ciphers
smtpd_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
smtpd_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
smtp_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
smtp_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
# Enable DNSSEC/DANE support for outgoing SMTP connections
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level
smtp_dns_support_level = "dnssec";
smtp_tls_security_level = "dane";
# TLS versions supported for the SMTP client
smtp_tls_protocols = ">=TLSv1.2";
smtp_tls_mandatory_protocols = ">=TLSv1.2";
# Require ciphersuites that OpenSSL classifies as "High"
smtp_tls_ciphers = "high";
smtp_tls_mandatory_ciphers = "high";
# Exclude ciphersuites with undesirable properties
smtp_tls_exclude_ciphers = "SHA1, eNULL, aNULL";
smtp_tls_mandatory_exclude_ciphers = "SHA1, eNULL, aNULL";
# Restrict and prioritize the following curves in the given order
# Excludes curves that have no widespread support, so we don't bloat the handshake needlessly.
# https://www.postfix.org/postconf.5.html#tls_eecdh_auto_curves
tls_config_file =
let
mkGroupString = groups: concatStringsSep " / " (map (concatStringsSep ":") groups);
in
iniFormat.generate "postfix-openssl.cnf" {
globalSection.postfix = "postfix_settings";
sections = {
postfix_settings.ssl_conf = "postfix_ssl_settings";
postfix_ssl_settings.system_default = "baseline_postfix_settings";
baseline_postfix_settings.Groups = mkGroupString [
[ "*X25519MLKEM768" ]
[ "*X25519" ]
[
"P-256"
"P-384"
]
];
};
};
tls_config_name = "postfix";
# Algorithm selection happens through `tls_config_file` instead.
tls_eecdh_auto_curves = [ ];
tls_ffdhe_auto_groups = [ ];
# As long as all cipher suites are considered safe, let the client use its preferred cipher
tls_preempt_cipherlist = false;
tls_preempt_cipherlist = true;
# Allowing AUTH on a non encrypted connection poses a security risk
smtpd_tls_auth_only = true;
# Log only a summary message on TLS handshake completion
smtp_tls_loglevel = "1";
smtpd_tls_loglevel = "1";
# Configure a non blocking source of randomness
tls_random_source = "dev:/dev/urandom";
smtpd_milters = smtpdMilters;
non_smtpd_milters = lib.mkIf cfg.dkimSigning [ "unix:/run/rspamd/rspamd-milter.sock" ];
milter_protocol = "6";
milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}";
# Fix for https://www.postfix.org/smtp-smuggling.html
smtpd_forbid_bare_newline = cfg.smtpdForbidBareNewline;
smtpd_forbid_bare_newline_exclusions = "$mynetworks";
};
submissionOptions = submissionOptions;
submissionsOptions = submissionOptions;
settings.master = {
masterConfig = {
"lmtp" = {
# Add headers when delivering, see http://www.postfix.org/smtp.8.html
# D => Delivered-To, O => X-Original-To, R => Return-Path
@@ -459,10 +323,7 @@ in
chroot = false;
maxproc = 0;
command = "cleanup";
args = [
"-o"
"header_checks=pcre:${submissionHeaderCleanupRules}"
];
args = ["-o" "header_checks=pcre:${submissionHeaderCleanupRules}"];
};
};
};

View File

@@ -14,19 +14,11 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
pkgs,
lib,
...
}:
{ config, pkgs, lib, ... }:
with lib;
let
inherit (lib)
optionalString
mkIf
;
cfg = config.mailserver;
preexecDefined = cfg.backup.cmdPreexec != null;
@@ -46,8 +38,7 @@ let
${cfg.backup.cmdPostexec}
'';
postexecString = optionalString postexecDefined "cmd_postexec ${postexecWrapped}";
in
{
in {
config = mkIf (cfg.enable && cfg.backup.enable) {
services.rsnapshot = {
enable = true;

View File

@@ -14,12 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
pkgs,
lib,
...
}:
{ config, pkgs, lib, ... }:
let
cfg = config.mailserver;
@@ -31,13 +26,10 @@ let
rspamdUser = config.services.rspamd.user;
rspamdGroup = config.services.rspamd.group;
createDkimKeypair =
domain:
let
createDkimKeypair = domain: let
privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key";
publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt";
in
pkgs.writeShellScript "dkim-keygen-${domain}" ''
in pkgs.writeShellScript "dkim-keygen-${domain}" ''
if [ ! -f "${privateKey}" ]
then
${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \
@@ -50,54 +42,40 @@ let
echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}"
fi
'';
dkimDomains = lib.unique (cfg.domains ++ (lib.optionals cfg.srs.enable [ cfg.srs.domain ]));
in
{
config = lib.mkIf cfg.enable {
config = with cfg; lib.mkIf enable {
environment.systemPackages = lib.mkBefore [
(pkgs.runCommand "rspamc-wrapped"
{
(pkgs.runCommand "rspamc-wrapped" {
nativeBuildInputs = with pkgs; [ makeWrapper ];
}
''
}''
makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \
--add-flags "-h /run/rspamd/worker-controller.sock"
''
)
'')
];
services.rspamd = {
enable = true;
debug = cfg.debug.rspamd;
inherit debug;
locals = {
"milter_headers.conf" = {
text = ''
"milter_headers.conf" = { text = ''
extended_spam_headers = true;
'';
};
"redis.conf" = {
text = ''
servers = "${
if cfg.redis.port == null then
''; };
"redis.conf" = { text = ''
servers = "${if cfg.redis.port == null
then
cfg.redis.address
else
"${cfg.redis.address}:${toString cfg.redis.port}"
}";
''
+ (lib.optionalString (cfg.redis.password != null) ''
"${cfg.redis.address}:${toString cfg.redis.port}"}";
'' + (lib.optionalString (cfg.redis.password != null) ''
password = "${cfg.redis.password}";
'');
};
"classifier-bayes.conf" = {
text = ''
''); };
"classifier-bayes.conf" = { text = ''
cache {
backend = "redis";
}
'';
};
"antivirus.conf" = lib.mkIf cfg.virusScanning {
text = ''
''; };
"antivirus.conf" = lib.mkIf cfg.virusScanning { text = ''
clamav {
action = "reject";
symbol = "CLAM_VIRUS";
@@ -106,52 +84,36 @@ in
servers = "/run/clamav/clamd.ctl";
scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all
}
'';
};
"dkim_signing.conf" = {
text = ''
''; };
"dkim_signing.conf" = { text = ''
enabled = ${lib.boolToString cfg.dkimSigning};
path = "${cfg.dkimKeyDirectory}/$domain.$selector.key";
selector = "${cfg.dkimSelector}";
# Allow for usernames w/o domain part
allow_username_mismatch = true;
# Don't normalize DKIM key selection for subdomains
use_esld = false;
'';
};
"dmarc.conf" = {
text = ''
allow_username_mismatch = true
''; };
"dmarc.conf" = { text = ''
${lib.optionalString cfg.dmarcReporting.enable ''
reporting {
enabled = true;
email = "noreply-dmarc@${cfg.systemDomain}";
domain = "${cfg.systemDomain}";
org_name = "${cfg.systemName}";
from_name = "${cfg.systemName}";
msgid_from = "${cfg.systemDomain}";
${lib.optionalString (cfg.dmarcReporting.excludeDomains != [ ]) ''
email = "${cfg.dmarcReporting.email}";
domain = "${cfg.dmarcReporting.domain}";
org_name = "${cfg.dmarcReporting.organizationName}";
from_name = "${cfg.dmarcReporting.fromName}";
msgid_from = "${cfg.dmarcReporting.domain}";
${lib.optionalString (cfg.dmarcReporting.excludeDomains != []) ''
exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains};
''}
}''}
'';
};
};
overrides = {
"options.inc" = {
text = ''
local_addrs = [::1/128, 127.0.0.0/8]
'';
};
''; };
};
workers.rspamd_proxy = {
type = "rspamd_proxy";
bindSockets = [
{
bindSockets = [{
socket = "/run/rspamd/rspamd-milter.sock";
mode = "0664";
}
];
}];
count = 1; # Do not spawn too many processes of this type
extraConfig = ''
milter = yes; # Enable milter mode
@@ -166,13 +128,11 @@ in
workers.controller = {
type = "controller";
count = 1;
bindSockets = [
{
bindSockets = [{
socket = "/run/rspamd/worker-controller.sock";
mode = "0666";
}
];
includes = [ ];
}];
includes = [];
extraConfig = ''
static_dir = "''${WWWDIR}"; # Serve the web UI static assets
'';
@@ -180,7 +140,7 @@ in
};
services.redis.servers.rspamd.enable = lib.mkDefault cfg.redis.configureLocally;
services.redis.servers.rspamd.enable = lib.mkDefault true;
systemd.tmpfiles.settings."10-rspamd.conf" = {
"${cfg.dkimKeyDirectory}" = {
@@ -205,26 +165,24 @@ in
SupplementaryGroups = [ config.services.redis.servers.rspamd.group ];
}
(lib.optionalAttrs cfg.dkimSigning {
ExecStartPre = map createDkimKeypair dkimDomains;
ExecStartPre = map createDkimKeypair cfg.domains;
ReadWritePaths = [ cfg.dkimKeyDirectory ];
})
];
};
systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable {
systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) {
# Explicitly select yesterday's date to work around broken
# default behaviour when called without a date.
# https://github.com/rspamd/rspamd/issues/4062
script = toString [
(lib.getExe' pkgs.rspamd "rspamadm")
"dmarc_report"
"$(date -d 'yesterday' '+%Y%m%d')"
];
script = ''
${pkgs.rspamd}/bin/rspamadm dmarc_report $(date -d "yesterday" "+%Y%m%d")
'';
serviceConfig = {
User = "${config.services.rspamd.user}";
Group = "${config.services.rspamd.group}";
AmbientCapabilities = [ ];
AmbientCapabilities = [];
CapabilityBoundingSet = "";
DevicePolicy = "closed";
IPAddressAllow = "localhost";
@@ -245,17 +203,10 @@ in
ProcSubset = "pid";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SupplementaryGroups = lib.optionals cfg.redis.configureLocally [
config.services.redis.servers.rspamd.group
];
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
@@ -265,7 +216,7 @@ in
};
};
systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable {
systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) {
description = "Daily delivery of aggregated DMARC reports";
wantedBy = [
"timers.target"
@@ -286,3 +237,4 @@ in
users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ];
};
}

View File

@@ -14,40 +14,22 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
{ config, pkgs, lib, ... }:
let
cfg = config.mailserver;
certificatesDeps =
if cfg.certificateScheme == "manual" then
[ ]
[]
else if cfg.certificateScheme == "selfsigned" then
[ "mailserver-selfsigned-certificate.service" ]
else
[ "acme-finished-${cfg.fqdn}.target" ];
in
{
config = lib.mkIf cfg.enable {
config = with cfg; lib.mkIf enable {
# Create self signed certificate
systemd.services.mailserver-selfsigned-certificate =
lib.mkIf (cfg.certificateScheme == "selfsigned")
{
systemd.services.mailserver-selfsigned-certificate = lib.mkIf (cfg.certificateScheme == "selfsigned") {
after = [ "local-fs.target" ];
script = ''
# Create certificates if they do not exist yet
@@ -71,22 +53,21 @@ in
};
# Create maildir folder before dovecot startup
systemd.services.dovecot = {
systemd.services.dovecot2 = {
wants = certificatesDeps;
after = certificatesDeps;
preStart =
let
preStart = let
directories = lib.strings.escapeShellArgs (
[ cfg.mailDirectory ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir
[ mailDirectory ]
++ lib.optional (cfg.indexDir != null) cfg.indexDir
);
in
''
in ''
# Create mail directory and set permissions. See
# <https://doc.dovecot.org/main/core/config/shared_mailboxes.html#filesystem-permissions-1>.
# Prevent world-readable paths, even temporarily.
umask 007
mkdir -p ${directories}
chgrp "${cfg.vmailGroupName}" ${directories}
chgrp "${vmailGroupName}" ${directories}
chmod 02770 ${directories}
'';
};
@@ -94,12 +75,11 @@ in
# Postfix requires dovecot lmtp socket, dovecot auth socket and certificate to work
systemd.services.postfix = {
wants = certificatesDeps;
after = [
"dovecot.service"
]
after = [ "dovecot2.service" ]
++ lib.optional cfg.dkimSigning "rspamd.service"
++ certificatesDeps;
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service";
requires = [ "dovecot2.service" ]
++ lib.optional cfg.dkimSigning "rspamd.service";
};
};
}

View File

@@ -14,22 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
{ config, pkgs, lib, ... }:
with config.mailserver;
@@ -43,6 +28,7 @@ let
group = vmailGroupName;
};
virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" ''
#!${pkgs.stdenv.shell}
@@ -60,10 +46,8 @@ let
# Copy user's sieve script to the correct location (if it exists). If it
# is null, remove the file.
${lib.concatMapStringsSep "\n" (
{ name, sieveScript }:
if lib.isString sieveScript then
''
${lib.concatMapStringsSep "\n" ({ name, sieveScript }:
if lib.isString sieveScript then ''
if (! test -d "${sieveDirectory}/${name}"); then
mkdir -p "${sieveDirectory}/${name}"
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}"
@@ -73,41 +57,34 @@ let
${sieveScript}
EOF
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve"
''
else
''
'' else ''
if (test -f "${sieveDirectory}/${name}/default.sieve"); then
rm "${sieveDirectory}/${name}/default.sieve"
fi
if (test -f "${sieveDirectory}/${name}.svbin"); then
rm "${sieveDirectory}/${name}/default.svbin"
fi
''
) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues loginAccounts))}
'') (map (user: { inherit (user) name sieveScript; })
(lib.attrValues loginAccounts))}
'';
in
{
in {
config = lib.mkIf enable {
# assert that all accounts provide a password
assertions = map (acct: {
assertion = acct.hashedPassword != null || acct.hashedPasswordFile != null;
assertions = (map (acct: {
assertion = (acct.hashedPassword != null || acct.hashedPasswordFile != null);
message = "${acct.name} must provide either a hashed password or a password hash file";
}) (lib.attrValues loginAccounts);
}) (lib.attrValues loginAccounts));
# warn for accounts that specify both password and file
warnings =
map (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used")
(
lib.filter (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) (
lib.attrValues loginAccounts
)
);
warnings = (map
(acct: "${acct.name} specifies both a password hash and hash file; hash file will be used")
(lib.filter
(acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null))
(lib.attrValues loginAccounts)));
# set the vmail gid to a specific value
users.groups = {
"${vmailGroupName}" = {
gid = vmailUID;
};
"${vmailGroupName}" = { gid = vmailUID; };
};
# define all users
@@ -117,7 +94,7 @@ in
systemd.services.activate-virtual-mail-users = {
wantedBy = [ "multi-user.target" ];
before = [ "dovecot.service" ];
before = [ "dovecot2.service" ];
serviceConfig = {
ExecStart = virtualMailUsersActivationScript;
};

View File

@@ -1,146 +0,0 @@
#!/usr/bin/env nix-shell
#!nix-shell -i python3 -p python3
import argparse
import os
import shutil
import sys
from enum import Enum
from pathlib import Path
from pwd import getpwnam
class FolderLayout(Enum):
Default = 1
Folder = 2
def check_user(vmail_root: Path):
owner = vmail_root.owner()
owner_uid = getpwnam(owner).pw_uid
if os.geteuid() == owner_uid:
return
try:
print(
f"Trying to switch effective user id to {owner_uid} ({owner})",
file=sys.stderr,
)
os.seteuid(owner_uid)
return
except PermissionError:
print(
f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)",
file=sys.stderr,
)
sys.exit(1)
def is_maildir_related(path: Path, layout: FolderLayout) -> bool:
if path.name in [
"subscriptions",
# https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/maildir/#imap-uid-mapping
"dovecot-uidlist",
# https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/maildir/#imap-keywords
"dovecot-keywords",
]:
return True
if not path.is_dir():
return False
if path.name in ["cur", "new", "tmp"]:
return True
if layout is FolderLayout.Default and path.name.startswith("."):
return True
if layout is FolderLayout.Folder:
if path.name in ["mail"]:
return False
return True
return False
def mkdir(dst: Path, dry_run: bool = True):
print(f'mkdir "{dst}"')
if not dry_run:
# u+rwx, setgid
dst.mkdir(mode=0o2700)
def move(src: Path, dst: Path, dry_run: bool = True):
print(f'mv "{src}" "{dst}"')
if not dry_run:
src.rename(dst)
def delete(dst: Path, dry_run: bool = True):
if not dst.exists():
return
if dst.is_dir():
print(f'rm --recursive "{dst}"')
if not dry_run:
shutil.rmtree(dst)
else:
print(f'rm "{dst}"')
if not dry_run:
dst.unlink()
def main(vmail_root: Path, layout: FolderLayout, dry_run: bool = True):
maildirs = {path.parent for path in vmail_root.glob("*/*/cur")}
maybe_delete = []
# The old maildir will be the new home directory
for homedir in maildirs:
maildir = homedir / "mail"
mkdir(maildir, dry_run)
for path in homedir.iterdir():
if is_maildir_related(path, layout):
move(path, maildir / path.name, dry_run)
else:
maybe_delete.append(path)
# Files that are part of the previous home directory, but now obsolete
for path in [
vmail_root / ".dovecot.lda-dupes",
vmail_root / ".dovecot.lda-dupes.locks",
]:
delete(path, dry_run)
# The remaining files are likely obsolete, but should still be checked with care
for path in maybe_delete:
print(f"# rm {str(path)}")
if dry_run:
print("\nNo changes were made.")
print("Run the script with `--execute` to apply the listed changes.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="""
NixOS Mailserver Migration #3: Dovecot mail directory migration
(https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-mail-directory-migration)
"""
)
parser.add_argument(
"vmail_root", type=Path, help="Path to the `mailserver.mailDirectory`"
)
parser.add_argument(
"--layout",
choices=["default", "folder"],
required=True,
help="Folder layout: 'default' unless `mailserver.useFsLayout` was enabled, then'folder'",
)
parser.add_argument(
"--execute", action="store_true", help="Actually perform changes"
)
args = parser.parse_args()
layout = FolderLayout.Default if args.layout == "default" else FolderLayout.Folder
check_user(args.vmail_root)
main(args.vmail_root, layout, not args.execute)

View File

@@ -1,5 +0,0 @@
[tool.ruff.lint]
extend-select = ["ISC"]
[tool.ruff.lint.flake8-implicit-str-concat]
allow-multiline = false

View File

@@ -27,7 +27,6 @@ groups = [
"mailserver.loginAccounts",
"mailserver.certificate",
"mailserver.dkim",
"mailserver.srs",
"mailserver.dmarcReporting",
"mailserver.fullTextSearch",
"mailserver.redis",
@@ -91,9 +90,7 @@ def print_option(option):
key=option["name"],
description=description or "",
type=f"- type: {md_literal(option['type'])}",
default=render_option_value(option, "defaultText")
if "defaultText" in option
else render_option_value(option, "default"),
default=render_option_value(option, "default"),
example=render_option_value(option, "example"),
)
)

View File

@@ -12,15 +12,7 @@ RETRY = 100
def _send_mail(
smtp_host,
smtp_port,
smtp_username,
from_addr,
from_pwd,
to_addr,
subject,
starttls,
ssl,
smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr, subject, starttls
):
print(f"Sending mail with subject '{subject}'")
message = "\n".join(
@@ -36,10 +28,9 @@ def _send_mail(
)
retry = RETRY
smtp_class = smtplib.SMTP_SSL if ssl else smtplib.SMTP
while True:
try:
with smtp_class(smtp_host, port=smtp_port) as smtp:
with smtplib.SMTP(smtp_host, port=smtp_port) as smtp:
try:
if starttls:
smtp.starttls()
@@ -82,7 +73,7 @@ def _read_mail(
show_body=False,
delete=True,
):
print(f"Reading mail from {imap_username}")
print("Reading mail from {imap_username}")
message = None
@@ -180,7 +171,6 @@ def send_and_read(args):
to_addr=args.to_addr,
subject=subject,
starttls=args.smtp_starttls,
ssl=args.smtp_ssl,
)
_read_mail(
@@ -216,7 +206,6 @@ parser_send_and_read = subparsers.add_parser(
parser_send_and_read.add_argument("--smtp-host", type=str)
parser_send_and_read.add_argument("--smtp-port", type=str, default=25)
parser_send_and_read.add_argument("--smtp-starttls", action="store_true")
parser_send_and_read.add_argument("--smtp-ssl", action="store_true")
parser_send_and_read.add_argument(
"--smtp-username",
type=str,

View File

@@ -1,9 +1,10 @@
(import (
let
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
in
(import
(
let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
fetchTarball {
url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
sha256 = lock.nodes.flake-compat.locked.narHash;
}
) { src = ./.; }).shellNix
)
{ src = ./.; }
).shellNix

View File

@@ -24,8 +24,7 @@
name = "clamav";
nodes = {
server =
{ pkgs, ... }:
server = { pkgs, ... }:
{
imports = [
../default.nix
@@ -71,10 +70,7 @@
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [
"example.com"
"example2.com"
];
domains = [ "example.com" "example2.com" ];
virusScanning = true;
loginAccounts = {
@@ -94,9 +90,7 @@
"root/eicar.com.txt".text = "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
};
};
client =
{ nodes, pkgs, ... }:
let
client = { nodes, pkgs, ... }: let
serverIP = nodes.server.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" ''
@@ -104,18 +98,13 @@
echo grep '${clientIP}' "$@" >&2
exec grep '${clientIP}' "$@"
'';
in
{
in {
imports = [
./lib/config.nix
];
environment.systemPackages = with pkgs; [
fetchmail
msmtp
procmail
findutils
grep-ip
fetchmail msmtp procmail findutils grep-ip
];
environment.etc = {
"root/.fetchmailrc" = {

View File

@@ -18,8 +18,7 @@
name = "external";
nodes = {
server =
{ pkgs, ... }:
server = { pkgs, ... }:
{
imports = [
../default.nix
@@ -37,17 +36,19 @@
'';
};
mailserver = {
enable = true;
debug.dovecot = true; # enabled for sieve script logging
debug = true;
fqdn = "mail.example.com";
domains = [
"example.com"
"example2.com"
];
domains = [ "example.com" "example2.com" ];
rewriteMessageId = true;
dkimKeyBits = 1535;
dmarcReporting.enable = true;
dmarcReporting = {
enable = true;
domain = "example.com";
organizationName = "ACME Corp";
};
loginAccounts = {
"user1@example.com" = {
@@ -70,10 +71,7 @@
extraVirtualAliases = {
"single-alias@example.com" = "user1@example.com";
"multi-alias@example.com" = [
"user1@example.com"
"user2@example.com"
];
"multi-alias@example.com" = [ "user1@example.com" "user2@example.com" ];
};
enableImap = true;
@@ -82,16 +80,12 @@
enable = true;
autoIndex = true;
# special use depends on https://github.com/NixOS/nixpkgs/pull/93201
autoIndexExclude = [
(if (pkgs.lib.versionAtLeast pkgs.lib.version "21") then "\\Junk" else "Junk")
];
autoIndexExclude = [ (if (pkgs.lib.versionAtLeast pkgs.lib.version "21") then "\\Junk" else "Junk") ];
enforced = "yes";
};
};
};
client =
{ nodes, pkgs, ... }:
let
client = { nodes, pkgs, ... }: let
serverIP = nodes.server.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" ''
@@ -178,21 +172,12 @@
assert needle in repr(response)
imap.close()
'';
in
{
in {
imports = [
./lib/config.nix
];
environment.systemPackages = with pkgs; [
fetchmail
msmtp
procmail
findutils
grep-ip
check-mail-id
test-imap-spam
test-imap-ham
search
fetchmail msmtp procmail findutils grep-ip check-mail-id test-imap-spam test-imap-ham search
];
environment.etc = {
"root/.fetchmailrc" = {
@@ -486,9 +471,9 @@
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
client.succeed("imap-mark-spam >&2")
server.wait_until_succeeds("journalctl -u dovecot -u dovecot2 | grep -i rspamd-learn-spam.sh >&2")
server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-spam.sh >&2")
client.succeed("imap-mark-ham >&2")
server.wait_until_succeeds("journalctl -u dovecot -u dovecot2 | grep -i rspamd-learn-ham.sh >&2")
server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-ham.sh >&2")
with subtest("full text search and indexation"):
# send 2 email from user2 to user1
@@ -506,9 +491,9 @@
# should fail because this folder is not indexed
client.fail("search Junk a >&2")
# check that search really goes through the indexer
server.succeed("journalctl -u dovecot -u dovecot2 | grep 'fts-flatcurve(INBOX): Query ' >&2")
server.succeed("journalctl -u dovecot2 | grep 'fts-flatcurve(INBOX): Query ' >&2")
# check that Junk is not indexed
server.fail("journalctl -u dovecot -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2")
server.fail("journalctl -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2")
with subtest("dmarc reporting"):
server.systemctl("start rspamd-dmarc-reporter.service")
@@ -516,10 +501,10 @@
with subtest("no warnings or errors"):
server.fail("journalctl -u postfix | grep -i error >&2")
server.fail("journalctl -u postfix | grep -i warning >&2")
server.fail("journalctl -u dovecot -u dovecot2 | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2")
server.fail("journalctl -u dovecot2 | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2")
# harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html
server.fail(
"journalctl -u dovecot -u dovecot2 | \
"journalctl -u dovecot2 | \
grep -v 'Expunged message reappeared, giving a new UID' | \
grep -v 'Time moved forwards' | \
grep -i warning >&2"

View File

@@ -30,14 +30,9 @@ let
'';
};
hashPassword =
password:
pkgs.runCommand "password-${password}-hashed"
{
buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
''
hashPassword = password: pkgs.runCommand
"password-${password}-hashed"
{ buildInputs = [ pkgs.mkpasswd ]; inherit password; } ''
mkpasswd -sm bcrypt <<<"$password" > $out
'';
@@ -48,9 +43,7 @@ in
name = "internal";
nodes = {
machine =
{ pkgs, ... }:
{
machine = { pkgs, ... }: {
imports = [
./../default.nix
./lib/config.nix
@@ -62,8 +55,7 @@ in
(pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'')
]
++ (with pkgs; [
] ++ (with pkgs; [
curl
openssl
netcat
@@ -72,10 +64,7 @@ in
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [
"example.com"
"domain.com"
];
domains = [ "example.com" "domain.com" ];
localDnsResolver = false;
loginAccounts = {
@@ -84,7 +73,7 @@ in
};
"user2@example.com" = {
hashedPasswordFile = hashedPasswordFile;
aliasesRegexp = [ ''/^user2.*@domain\.com$/'' ];
aliasesRegexp = [''/^user2.*@domain\.com$/''];
};
"send-only@example.com" = {
hashedPasswordFile = hashPassword "send-only";
@@ -99,24 +88,18 @@ in
vmailGroupName = "vmail";
vmailUID = 5000;
indexDir = "/var/lib/dovecot/indices";
enableImap = false;
};
};
};
testScript =
{
nodes,
...
}:
''
testScript = ''
machine.start()
machine.wait_for_unit("multi-user.target")
# Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205
with subtest("mail forwarded can are locally kept"):
# A mail sent to user2@example.com via explicit TLS is in the user1@example.com mailbox
# A mail sent to user2@example.com is in the user1@example.com mailbox
machine.succeed(
" ".join(
[
@@ -134,13 +117,13 @@ in
]
)
)
# A mail sent to user2@example.com via implicit TLS is in the user2@example.com mailbox
# A mail sent to user2@example.com is in the user2@example.com mailbox
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user2@example.com",
@@ -154,7 +137,7 @@ in
)
with subtest("regex email alias are received"):
# A mail sent to user2-regex-alias@domain.com via explicit TLS is in the user2@example.com mailbox
# A mail sent to user2-regex-alias@domain.com is in the user2@example.com mailbox
machine.succeed(
" ".join(
[
@@ -174,14 +157,13 @@ in
)
with subtest("user can send from regex email alias"):
# A mail sent to user1@example.com from user2-regex-alias@domain.com by
# user2@example.com via implicit TLS is in the user1@example.com mailbox
# A mail sent from user2-regex-alias@domain.com, using user2@example.com credentials is received
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--smtp-username user2@example.com",
@@ -197,11 +179,6 @@ in
with subtest("vmail gid is set correctly"):
machine.succeed("getent group vmail | grep 5000")
with subtest("Check dovecot maildir and index locations"):
# If these paths change we need a migration
machine.succeed("doveadm user -f home user1@example.com | grep ${nodes.machine.mailserver.mailDirectory}/example.com/user1")
machine.succeed("doveadm user -f mail user1@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/example.com/user1'")
with subtest("mail to send only accounts is rejected"):
machine.wait_for_open_port(25)
# TODO put this blocking into the systemd units

View File

@@ -7,9 +7,7 @@ in
name = "ldap";
nodes = {
machine =
{ pkgs, ... }:
{
machine = { pkgs, ... }: {
imports = [
./../default.nix
./lib/config.nix
@@ -25,8 +23,7 @@ in
environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'')
];
'')];
environment.etc.bind-password.text = bindPassword;
@@ -90,7 +87,6 @@ in
fqdn = "mail.example.com";
domains = [ "example.com" ];
localDnsResolver = false;
indexDir = "/var/lib/dovecot/indices";
ldap = {
enable = true;
@@ -116,12 +112,7 @@ in
};
};
};
testScript =
{
nodes,
...
}:
''
testScript = ''
import sys
import re
@@ -157,7 +148,7 @@ in
machine.succeed("ls -l /run/postfix/*.cf | grep -e '-rw------- 1 root root'")
machine.succeed("ls -l /run/dovecot2/dovecot-ldap.conf.ext | grep -e '-rw------- 1 root root'")
with subtest("Test account/mail address binding via explicit TLS"):
with subtest("Test account/mail address binding"):
machine.fail(" ".join([
"mail-check send-and-read",
"--smtp-port 587",
@@ -174,11 +165,11 @@ in
]))
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice@example.com'")
with subtest("Test mail delivery via implicit TLS"):
with subtest("Test mail delivery"):
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username alice@example.com",
"--imap-host localhost",
@@ -190,7 +181,7 @@ in
"--ignore-dkim-spf"
]))
with subtest("Test mail forwarding via explicit TLS works"):
with subtest("Test mail forwarding works"):
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 587",
@@ -206,11 +197,11 @@ in
"--ignore-dkim-spf"
]))
with subtest("Test cannot send mail via implicit TLS from forwarded address"):
with subtest("Test cannot send mail from forwarded address"):
machine.fail(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username bob@example.com",
"--imap-host localhost",
@@ -223,9 +214,5 @@ in
]))
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob@example.com'")
with subtest("Check dovecot mail and index locations"):
# If these paths change we need a migration
machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.mailDirectory}/ldap/bob@example.com")
machine.succeed("doveadm user -f mail bob@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/ldap/bob@example.com'")
'';
}

View File

@@ -1,28 +1,3 @@
{
lib,
...
}:
{
# Testing eval failures that result from stateVersion assertion is out of scope
mailserver.stateVersion = 999;
# Keep testing submission with explicit TLS
mailserver.enableSubmission = true;
# Enable second CPU core
virtualisation.cores = lib.mkDefault 2;
services.rspamd = {
# Don't make tests block on DNS requests that will never succeed
locals."options.inc".text = ''
dns {
nameservers = ["127.0.0.1"];
timeout = 0.0s;
retransmits = 0;
}
'';
# Relax `local_addrs` definition to default for tests, so mail doesn't get flagged as spam
overrides."options.inc".enable = false;
};
security.dhparams.defaultBitSize = 2048; # minimum size required by dovecot
}

27
tests/minimal.nix Normal file
View File

@@ -0,0 +1,27 @@
# 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 <http://www.gnu.org/licenses/>
{
name = "minimal";
nodes.machine = {
imports = [ ./../default.nix ];
};
testScript = ''
machine.wait_for_unit("multi-user.target");
'';
}

View File

@@ -1,33 +1,22 @@
# This tests is used to test features requiring several mail domains.
{
lib,
pkgs,
...
}:
let
hashPassword =
password:
pkgs.runCommand "password-${password}-hashed"
{
buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
hashPassword = password: pkgs.runCommand
"password-${password}-hashed"
{ buildInputs = [ pkgs.mkpasswd ]; inherit password; }
''
mkpasswd -sm bcrypt <<<"$password" > $out
'';
password = pkgs.writeText "password" "password";
domainGenerator =
domain:
{ pkgs, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
domainGenerator = domain: { pkgs, ... }: {
imports = [../default.nix];
environment.systemPackages = with pkgs; [ netcat ];
virtualisation.memorySize = 1024;
mailserver = {
@@ -45,14 +34,8 @@ let
};
services.dnsmasq = {
enable = true;
settings.mx-host = [
"domain1.com,domain1,10"
"domain2.com,domain2,10"
];
settings.mx-host = [ "domain1.com,domain1,10" "domain2.com,domain2,10" ];
};
# breaks the test, due to running into DNS timeouts
services.postfix-tlspol.configurePostfix = lib.mkForce false;
};
in
@@ -61,33 +44,22 @@ in
name = "multiple";
nodes = {
domain1 =
{ ... }:
{
domain1 = {...}: {
imports = [
../default.nix
(domainGenerator "domain1.com")
];
mailserver.forwards = {
"non-local@domain1.com" = [
"user@domain2.com"
"user@domain1.com"
];
"non@domain1.com" = [
"user@domain2.com"
"user@domain1.com"
];
"non-local@domain1.com" = ["user@domain2.com" "user@domain1.com"];
"non@domain1.com" = ["user@domain2.com" "user@domain1.com"];
};
};
domain2 = domainGenerator "domain2.com";
client =
{ pkgs, ... }:
{
client = { pkgs, ... }: {
environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'')
];
'')];
};
};
testScript = ''
@@ -104,14 +76,14 @@ in
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
# user@domain1.com sends a mail to user@domain2.com via explicit TLS
# user@domain1.com sends a mail to user@domain2.com
client.succeed(
"mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
)
# Send a mail to the address forwarded via implicit TLS and check it is in the recipient mailbox
# Send a mail to the address forwarded and check it is in the recipient mailbox
client.succeed(
"mail-check send-and-read --smtp-port 465 --smtp-ssl --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
"mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
)
'';
}