Compare commits

..

1 Commits

Author SHA1 Message Date
Jakub Skokan
7b88bf6d0c Allow TLSv1 for compatibility with older devices 2022-06-09 13:48:37 +02:00
57 changed files with 2220 additions and 2113 deletions

3
.envrc
View File

@ -1,3 +0,0 @@
# shellcheck shell=bash
use flake

2
.gitignore vendored
View File

@ -1,3 +1 @@
result result
.direnv
.pre-commit-config.yaml

View File

@ -1,18 +1,13 @@
.hydra-cli:
image: docker.nix-community.org/nixpkgs/nix-flakes
script:
- nix run --inputs-from ./. nixpkgs#hydra-cli -- -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver "${jobset}"
hydra-pr: hydra-pr:
extends: .hydra-cli
only: only:
- merge_requests - merge_requests
variables: image: nixos/nix
jobset: $CI_MERGE_REQUEST_IID script:
- nix --extra-experimental-features nix-command run -f channel:nixos-unstable hydra-cli -- -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver ${CI_MERGE_REQUEST_IID}
hydra-master: hydra-master:
extends: .hydra-cli
only: only:
- master - master
variables: image: nixos/nix
jobset: master script:
- nix --extra-experimental-features nix-command run -f channel:nixos-unstable hydra-cli -- -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver master

View File

@ -8,7 +8,7 @@ let
{ enabled = 1; { enabled = 1;
hidden = false; hidden = false;
description = "PR ${num}: ${info.title}"; description = "PR ${num}: ${info.title}";
checkinterval = 300; checkinterval = 30;
schedulingshares = 20; schedulingshares = 20;
enableemail = false; enableemail = false;
emailoverride = ""; emailoverride = "";
@ -19,7 +19,7 @@ let
) prs; ) prs;
mkFlakeJobset = branch: { mkFlakeJobset = branch: {
description = "Build ${branch} branch of Simple NixOS MailServer"; description = "Build ${branch} branch of Simple NixOS MailServer";
checkinterval = 300; checkinterval = "60";
enabled = "1"; enabled = "1";
schedulingshares = 100; schedulingshares = 100;
enableemail = false; enableemail = false;
@ -32,8 +32,6 @@ let
desc = prJobsets // { desc = prJobsets // {
"master" = mkFlakeJobset "master"; "master" = mkFlakeJobset "master";
"nixos-24.11" = mkFlakeJobset "nixos-24.11";
"nixos-25.05" = mkFlakeJobset "nixos-25.05";
}; };
log = { log = {

View File

@ -5,17 +5,9 @@
version: 2 version: 2
build: build:
os: ubuntu-22.04 os: ubuntu-20.04
tools: tools:
python: "3" python: "3.9"
apt_packages:
- nix
- proot
jobs:
pre_install:
- 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: sphinx:
configuration: docs/conf.py configuration: docs/conf.py

194
README.md
View File

@ -1,106 +1,160 @@
# ![Simple Nixos MailServer][logo] # ![Simple Nixos MailServer][logo]
![license](https://img.shields.io/badge/license-GPL3-brightgreen.svg) ![license](https://img.shields.io/badge/license-GPL3-brightgreen.svg)
[![pipeline status](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/badges/master/pipeline.svg)](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/commits/master) [![pipeline status](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/badges/master/pipeline.svg)](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/commits/master)
## Release branches ## Release branches
For each NixOS release, we publish a branch. You then have to use the For each NixOS release, we publish a branch. You then have to use the
SNM branch corresponding to your NixOS version. SNM branch corresponding to your NixOS version.
* For NixOS 25.05 * For NixOS 21.11
* Use the [SNM branch `nixos-25.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.05) - Use the [SNM branch `nixos-21.11`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-21.11)
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-25.05/) - [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-21.11/)
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-25.05/release-notes.html#nixos-25-05) - [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-21.11/release-notes.html#nixos-21-11)
* For NixOS 24.11 * For NixOS 21.05
* Use the [SNM branch `nixos-24.11`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-24.11) - Use the [SNM branch `nixos-21.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-21.05)
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-24.11/) - [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-21.05/)
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-24.11/release-notes.html#nixos-24-11) - [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-21.05/release-notes.html#nixos-21-05)
* For NixOS unstable * For NixOS unstable
* Use the [SNM branch `master`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/master) - Use the [SNM branch `master`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/master)
* [Documentation](https://nixos-mailserver.readthedocs.io/en/latest/) - [Documentation](https://nixos-mailserver.readthedocs.io/en/latest/)
[Subscribe to SNM Announcement List](https://www.freelists.org/list/snm)
This is a very low volume list where new releases of SNM are announced, so you
can stay up to date with bug fixes and updates. All announcements are signed by
the gpg key with fingerprint
```
D9FE 4119 F082 6F15 93BD BD36 6162 DBA5 635E A16A
```
## Features ## Features
### v2.0
* [x] Continous Integration Testing * [x] Continous Integration Testing
* [x] Multiple Domains * [x] Multiple Domains
* Postfix * Postfix MTA
* [x] SMTP on port 25 - [x] smtp on port 25
* [x] Submission TLS on port 465 - [x] submission tls on port 465
* [x] Submission StartTLS on port 587 - [x] submission starttls on port 587
* [x] LMTP with Dovecot - [x] lmtp with dovecot
* Dovecot * Dovecot
* [x] Maildir folders - [x] maildir folders
* [x] IMAP with TLS on port 993 - [x] imap with tls on port 993
* [x] POP3 with TLS on port 995 - [x] pop3 with tls on port 995
* [x] IMAP with StartTLS on port 143 - [x] imap with starttls on port 143
* [x] POP3 with StartTLS on port 110 - [x] pop3 with starttls on port 110
* Certificates * Certificates
* [x] ACME - [x] manual certificates
* [x] Custom certificates - [x] on the fly creation
* Spam Filtering - [x] Let's Encrypt
* [x] Via Rspamd * Spam Filtering
* Virus Scanning - [x] via rspamd
* [x] Via ClamAV * Virus Scanning
* DKIM Signing - [x] via clamav
* [x] Via Rspamd * DKIM Signing
* User Management - [x] via opendkim
* [x] Declarative user management * User Management
* [x] Declarative password management - [x] declarative user management
* [x] LDAP users - [x] declarative password management
* Sieve * Sieves
* [x] Allow user defined sieve scripts - [x] A simple standard script that moves spam
* [x] Moving mails from/to junk trains the Bayes filter - [x] Allow user defined sieve scripts
* [x] ManageSieve support - [x] ManageSieve support
* User Aliases * User Aliases
* [x] Regular aliases - [x] Regular aliases
* [x] Catch all aliases - [x] Catch all aliases
### In the future ### In the future
* Automatic client configuration * DKIM Signing
* [ ] [Autoconfig](https://web.archive.org/web/20210624004729/https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration) - [ ] Allow a per domain selector
* [ ] [Autodiscovery](https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019)
* [ ] [Mobileconfig](https://support.apple.com/guide/profile-manager/distribute-profiles-manually-pmdbd71ebc9/mac)
* DKIM Signing
* [ ] Allow per domain selectors
* [ ] 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
* Depends on relevant clients adding support, e.g. [Thunderbird](https://bugzilla.mozilla.org/show_bug.cgi?id=1602166)
### Get in touch ### Get in touch
* Matrix: [#nixos-mailserver:nixos.org](https://matrix.to/#/#nixos-mailserver:nixos.org) - Subscribe to the [mailing list](https://www.freelists.org/archive/snm/)
* IRC: `#nixos-mailserver` on [Libera Chat](https://libera.chat/guides/connect) - Join the Libera Chat IRC channel `#nixos-mailserver`
### Quick Start
```nix
{ config, pkgs, ... }:
let release = "nixos-21.11";
in {
imports = [
(builtins.fetchTarball {
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz";
# This hash needs to be updated
sha256 = "0000000000000000000000000000000000000000000000000000";
})
];
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" "example2.com" ];
loginAccounts = {
"user1@example.com" = {
# nix run nixpkgs.apacheHttpd -c htpasswd -nbB "" "super secret password" | cut -d: -f2 > /hashed/password/file/location
hashedPasswordFile = "/hashed/password/file/location";
aliases = [
"info@example.com"
"postmaster@example.com"
"postmaster@example2.com"
];
};
};
};
}
```
For a complete list of options, see `default.nix`.
## How to Set Up a 10/10 Mail Server Guide ## How to Set Up a 10/10 Mail Server Guide
Check out the [Complete Setup Guide](https://nixos-mailserver.readthedocs.io/en/latest/setup-guide.html) in the project's documentation.
Check out the [Setup Guide](https://nixos-mailserver.readthedocs.io/en/latest/setup-guide.html) in the project's documentation. ## How to Backup
For a complete list of options, [see in readthedocs](https://nixos-mailserver.readthedocs.io/en/latest/options.html). Checkout the [Complete Backup Guide](https://nixos-mailserver.readthedocs.io/en/latest/backup-guide.html). Backups are easy with `SNM`.
## Development ## Development
See the [How to Develop SNM](https://nixos-mailserver.readthedocs.io/en/latest/howto-develop.html) documentation page. See the [How to Develop SNM](https://nixos-mailserver.readthedocs.io/en/latest/howto-develop.html) wiki page.
## Release notes
### nixos-20.03
- Rspamd is upgraded to 2.0 which deprecates the SQLite Bayes
backend. We then moved to the Redis backend (the default since
Rspamd 2.0). If you don't want to relearn the Redis backend from the
scratch, we could manually run
rspamadm statconvert --spam-db /var/lib/rspamd/bayes.spam.sqlite --ham-db /var/lib/rspamd/bayes.ham.sqlite -h 127.0.0.1:6379 --symbol-ham BAYES_HAM --symbol-spam BAYES_SPAM
See the [Rspamd migration
notes](https://rspamd.com/doc/migration.html#migration-to-rspamd-20)
and [this SNM Merge
Request](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/164)
for details.
## Contributors ## Contributors
See the [contributor tab](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/graphs/master) See the [contributor tab](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/graphs/master)
### Alternative Implementations ### Alternative Implementations
* [NixCloud Webservices](https://github.com/nixcloud/nixcloud-webservices)
* [NixCloud Webservices](https://github.com/nixcloud/nixcloud-webservices)
### Credits ### Credits
* send mail graphic by [tnp_dreamingmao](https://thenounproject.com/dreamingmao)
* send mail graphic by [tnp_dreamingmao](https://thenounproject.com/dreamingmao)
from [TheNounProject](https://thenounproject.com/) is licensed under from [TheNounProject](https://thenounproject.com/) is licensed under
[CC BY 3.0](http://creativecommons.org/~/3.0/) [CC BY 3.0](http://creativecommons.org/~/3.0/)
* Logo made with [Logomakr.com](https://logomakr.com) * Logo made with [Logomakr.com](https://logomakr.com)
[logo]: docs/logo.png [logo]: docs/logo.png

View File

@ -48,11 +48,7 @@ in
type = types.listOf types.str; type = types.listOf types.str;
example = [ "imap.example.com" "pop3.example.com" ]; example = [ "imap.example.com" "pop3.example.com" ];
default = []; default = [];
description = '' description = "Secondary domains and subdomains for which it is necessary to generate a certificate.";
({option}`mailserver.certificateScheme` == `acme-nginx`)
Secondary domains and subdomains for which it is necessary to generate a certificate.
'';
}; };
messageSizeLimit = mkOption { messageSizeLimit = mkOption {
@ -76,14 +72,14 @@ in
default = null; default = null;
example = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/"; example = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/";
description = '' description = ''
The user's hashed password. Use `mkpasswd` as follows The user's hashed password. Use `htpasswd` as follows
``` ```
nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' nix run nixpkgs.apacheHttpd -c htpasswd -nbB "" "super secret password" | cut -d: -f2
``` ```
Warning: this is stored in plaintext in the Nix store! Warning: this is stored in plaintext in the Nix store!
Use {option}`mailserver.loginAccounts.<name>.hashedPasswordFile` instead. Use `hashedPasswordFile` instead.
''; '';
}; };
@ -92,10 +88,10 @@ in
default = null; default = null;
example = "/run/keys/user1-passwordhash"; example = "/run/keys/user1-passwordhash";
description = '' description = ''
A file containing the user's hashed password. Use `mkpasswd` as follows A file containing the user's hashed password. Use `htpasswd` as follows
``` ```
nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' nix run nixpkgs.apacheHttpd -c htpasswd -nbB "" "super secret password" | cut -d: -f2
``` ```
''; '';
}; };
@ -111,15 +107,6 @@ in
''; '';
}; };
aliasesRegexp = mkOption {
type = with types; listOf types.str;
example = [''/^tom\..*@domain\.com$/''];
default = [];
description = ''
Same as {option}`mailserver.aliases` but using PCRE (Perl compatible regex).
'';
};
catchAll = mkOption { catchAll = mkOption {
type = with types; listOf (enum cfg.domains); type = with types; listOf (enum cfg.domains);
example = ["example.com" "example2.com"]; example = ["example.com" "example2.com"];
@ -169,7 +156,7 @@ in
description = '' description = ''
Specifies if the account should be a send-only account. Specifies if the account should be a send-only account.
Emails sent to send-only accounts will be rejected from Emails sent to send-only accounts will be rejected from
unauthorized senders with the `sendOnlyRejectMessage` unauthorized senders with the sendOnlyRejectMessage
stating the reason. stating the reason.
''; '';
}; };
@ -197,174 +184,23 @@ in
}; };
description = '' description = ''
The login account of the domain. Every account is mapped to a unix user, The login account of the domain. Every account is mapped to a unix user,
e.g. `user1@example.com`. To generate the passwords use `mkpasswd` as e.g. `user1@example.com`. To generate the passwords use `htpasswd` as
follows follows
``` ```
nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' nix run nixpkgs.apacheHttpd -c htpasswd -nbB "" "super secret password" | cut -d: -f2
``` ```
''; '';
default = {}; default = {};
}; };
ldap = {
enable = mkEnableOption "LDAP support";
uris = mkOption {
type = types.listOf types.str;
example = literalExpression ''
[
"ldaps://ldap1.example.com"
"ldaps://ldap2.example.com"
]
'';
description = ''
URIs where your LDAP server can be reached
'';
};
startTls = mkOption {
type = types.bool;
default = false;
description = ''
Whether to enable StartTLS upon connection to the server.
'';
};
tlsCAFile = mkOption {
type = types.path;
default = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
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.
'';
};
bind = {
dn = mkOption {
type = types.str;
example = "cn=mail,ou=accounts,dc=example,dc=com";
description = ''
Distinguished name used by the mail server to do lookups
against the LDAP servers.
'';
};
passwordFile = mkOption {
type = types.str;
example = "/run/my-secret";
description = ''
A file containing the password required to authenticate against the LDAP servers.
'';
};
};
searchBase = mkOption {
type = types.str;
example = "ou=people,ou=accounts,dc=example,dc=com";
description = ''
Base DN at below which to search for users accounts.
'';
};
searchScope = mkOption {
type = types.enum [ "sub" "base" "one" ];
default = "sub";
description = ''
Search scope below which users accounts are looked for.
'';
};
dovecot = {
userAttrs = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
LDAP attributes to be retrieved during userdb lookups.
See the users_attrs reference at
https://doc.dovecot.org/configuration_manual/authentication/ldap_settings_auth/#user-attrs
in the Dovecot manual.
'';
};
userFilter = mkOption {
type = types.str;
default = "mail=%{user}";
example = "(&(objectClass=inetOrgPerson)(mail=%{user}))";
description = ''
Filter for user lookups in Dovecot.
See the user_filter reference at
https://doc.dovecot.org/configuration_manual/authentication/ldap_settings_auth/#user-filter
in the Dovecot manual.
'';
};
passAttrs = mkOption {
type = types.str;
default = "userPassword=password";
description = ''
LDAP attributes to be retrieved during passdb lookups.
See the pass_attrs reference at
https://doc.dovecot.org/configuration_manual/authentication/ldap_settings_auth/#pass-attrs
in the Dovecot manual.
'';
};
passFilter = mkOption {
type = types.nullOr types.str;
default = "mail=%{user}";
example = "(&(objectClass=inetOrgPerson)(mail=%{user}))";
description = ''
Filter for password lookups in Dovecot.
See the pass_filter reference for
https://doc.dovecot.org/configuration_manual/authentication/ldap_settings_auth/#pass-filter
in the Dovecot manual.
'';
};
};
postfix = {
filter = mkOption {
type = types.str;
default = "mail=%s";
example = "(&(objectClass=inetOrgPerson)(mail=%s))";
description = ''
LDAP filter used to search for an account by mail, where
`%s` is a substitute for the address in
question.
'';
};
uidAttribute = mkOption {
type = types.str;
default = "mail";
example = "uid";
description = ''
The LDAP attribute referencing the account name for a user.
'';
};
mailAttribute = mkOption {
type = types.str;
default = "mail";
description = ''
The LDAP attribute holding mail addresses for a user.
'';
};
};
};
indexDir = mkOption { indexDir = mkOption {
type = types.nullOr types.str; type = types.nullOr types.str;
default = null; default = null;
description = '' description = ''
Folder to store search indices. If null, indices are stored Folder to store search indices. If null, indices are stored
along with email, which could not necessarily be desirable, along with email, which could not necessarily be desirable,
especially when {option}`mailserver.fullTextSearch.enable` is `true` since especially when the fullTextSearch option is enable since
indices it creates are voluminous and do not need to be backed indices it creates are voluminous and do not need to be backed
up. up.
@ -380,21 +216,7 @@ in
}; };
fullTextSearch = { fullTextSearch = {
enable = mkEnableOption '' enable = mkEnableOption "Full text search indexing with xapian. This has significant performance and disk space cost.";
Full text search indexing with Xapian through the fts_flatcurve plugin.
This has significant performance and disk space cost.
'';
memoryLimit = mkOption {
type = types.nullOr types.int;
default = null;
example = 2000;
description = ''
Memory limit for the indexer process, in MiB.
If null, leaves the default (which is rather low),
and if 0, no limit.
'';
};
autoIndex = mkOption { autoIndex = mkOption {
type = types.bool; type = types.bool;
default = true; default = true;
@ -409,65 +231,58 @@ in
''; '';
}; };
indexAttachments = mkOption {
type = types.bool;
default = false;
description = "Also index text-only attachements. Binary attachements are never indexed.";
};
enforced = mkOption { enforced = mkOption {
type = types.enum [ "yes" "no" "body" ]; type = types.enum [ "yes" "no" "body" ];
default = "no"; default = "no";
description = '' description = ''
Fail searches when no index is available. If set to Fail searches when no index is available. If set to
`body`, then only body searches (as opposed to <literal>body</literal>, then only body searches (as opposed to
header) are affected. If set to `no`, searches may header) are affected. If set to <literal>no</literal>, searches may
fall back to a very slow brute force search. fall back to a very slow brute force search.
''; '';
}; };
languages = mkOption { minSize = mkOption {
type = types.nonEmptyListOf types.str; type = types.int;
default = [ "en" ]; default = 2;
example = [ "en" "de" ]; description = "Size of the smallest n-gram to index.";
description = '' };
A list of languages that the full text search should detect. maxSize = mkOption {
At least one language must be specified. type = types.int;
The language listed first is the default and is used when language recognition fails. default = 20;
See <https://doc.dovecot.org/main/core/plugins/fts.html#fts_languages>. description = "Size of the largest n-gram to index.";
''; };
memoryLimit = mkOption {
type = types.nullOr types.int;
default = null;
example = 2000;
description = "Memory limit for the indexer process, in MiB. If null, leaves the default (which is rather low), and if 0, no limit.";
}; };
substringSearch = mkOption { maintenance = {
enable = mkOption {
type = types.bool; type = types.bool;
default = false; default = true;
description = '' description = "Regularly optmize indices, as recommended by upstream.";
If enabled, allows substring searches.
See <https://doc.dovecot.org/main/core/plugins/fts_flatcurve.html#fts_flatcurve_substring_search>.
Enabling this requires significant additional storage space.
'';
}; };
headerExcludes = mkOption { onCalendar = mkOption {
type = types.listOf types.str; type = types.str;
default = [ default = "daily";
"Received" description = "When to run the maintenance job. See systemd.time(7) for more information about the format.";
"DKIM-*"
"X-*"
"Comments"
];
description = ''
The list of headers to exclude.
See <https://doc.dovecot.org/main/core/plugins/fts.html#fts_header_excludes>.
'';
}; };
filters = mkOption { randomizedDelaySec = mkOption {
type = types.listOf types.str; type = types.int;
default = [ default = 1000;
"normalizer-icu" description = "Run the maintenance job not exactly at the time specified with <literal>onCalendar</literal>, but plus or minus this many seconds.";
"snowball" };
"stopwords"
];
description = ''
The list of filters to apply.
<https://doc.dovecot.org/main/core/plugins/fts.html#filter-configuration>.
'';
}; };
}; };
@ -481,22 +296,6 @@ in
''; '';
}; };
lmtpMemoryLimit = mkOption {
type = types.int;
default = 256;
description = ''
The memory limit for the LMTP service, in megabytes.
'';
};
quotaStatusMemoryLimit = mkOption {
type = types.int;
default = 256;
description = ''
The memory limit for the quota-status service, in megabytes.
'';
};
extraVirtualAliases = mkOption { extraVirtualAliases = mkOption {
type = let type = let
loginAccount = mkOptionType { loginAccount = mkOptionType {
@ -534,7 +333,7 @@ in
the value {`"user@example.com" = "user@elsewhere.com";}` the value {`"user@example.com" = "user@elsewhere.com";}`
means that mails to `user@example.com` are forwarded to means that mails to `user@example.com` are forwarded to
`user@elsewhere.com`. The difference with the `user@elsewhere.com`. The difference with the
{option}`mailserver.extraVirtualAliases` option is that `user@elsewhere.com` `extraVirtualAliases` option is that `user@elsewhere.com`
can't send mail as `user@example.com`. Also, this option can't send mail as `user@example.com`. Also, this option
allows to forward mails to external addresses. allows to forward mails to external addresses.
''; '';
@ -543,7 +342,7 @@ in
rejectSender = mkOption { rejectSender = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
example = [ "example.com" "spammer@example.net" ]; example = [ "@example.com" "spammer@example.net" ];
description = '' description = ''
Reject emails from these addresses from unauthorized senders. Reject emails from these addresses from unauthorized senders.
Use if a spammer is using the same domain or the same sender over and over. Use if a spammer is using the same domain or the same sender over and over.
@ -568,7 +367,7 @@ in
description = '' description = ''
The unix UID of the virtual mail user. Be mindful that if this is The unix UID of the virtual mail user. Be mindful that if this is
changed, you will need to manually adjust the permissions of changed, you will need to manually adjust the permissions of
`mailDirectory`. mailDirectory.
''; '';
}; };
@ -607,15 +406,7 @@ in
- /var/vmail/example.com/user/.folder.subfolder/ (default layout) - /var/vmail/example.com/user/.folder.subfolder/ (default layout)
- /var/vmail/example.com/user/folder/subfolder/ (FS layout) - /var/vmail/example.com/user/folder/subfolder/ (FS layout)
See https://doc.dovecot.org/main/core/config/mailbox_formats/maildir.html#maildir-mailbox-format for details. See https://wiki2.dovecot.org/MailboxFormat/Maildir for details.
'';
};
useUTF8FolderNames = mkOption {
type = types.bool;
default = false;
description = ''
Store mailbox names on disk using UTF-8 instead of modified UTF-7 (mUTF-7).
''; '';
}; };
@ -628,7 +419,7 @@ in
This affects how mailboxes appear to mail clients and sieve scripts. This affects how mailboxes appear to mail clients and sieve scripts.
For instance when using "." then in a sieve script "example.com" would refer to the mailbox "com" in the parent mailbox "example". For instance when using "." then in a sieve script "example.com" would refer to the mailbox "com" in the parent mailbox "example".
This does not determine the way your mails are stored on disk. This does not determine the way your mails are stored on disk.
See https://doc.dovecot.org/main/core/config/namespaces.html#namespaces for details. See https://wiki.dovecot.org/Namespaces for details.
''; '';
}; };
@ -657,26 +448,19 @@ in
}; };
}; };
certificateScheme = let certificateScheme = mkOption {
schemes = [ "manual" "selfsigned" "acme-nginx" "acme" ]; type = types.enum [ 1 2 3 ];
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))}\"'." default = 2;
(builtins.elemAt schemes (i - 1));
in mkOption {
type = with types; coercedTo (enum [ 1 2 3 ]) translate (enum schemes);
default = "selfsigned";
description = '' description = ''
The scheme to use for managing TLS certificates: Certificate Files. There are three options for these.
1. `manual`: you specify locations via {option}`mailserver.certificateFile` and 1) You specify locations and manually copy certificates there.
{option}`mailserver.keyFile` and manually copy certificates there. 2) You let the server create new (self signed) certificates on the fly.
2. `selfsigned`: you let the server create new (self-signed) certificates on the fly. 3) You let the server create a certificate via `Let's Encrypt`. Note that
3. `acme-nginx`: you let the server request certificates from [Let's Encrypt](https://letsencrypt.org) this implies that a stripped down webserver has to be started. This also
via NixOS' ACME module. By default, this will set up a stripped-down Nginx server for implies that the FQDN must be set as an `A` record to point to the IP of
{option}`mailserver.fqdn` and open port 80. For this to work, the FQDN must be properly the server. In particular port 80 on the server will be opened. For details
configured to point to your server (see the [setup guide](setup-guide.rst) for more information). on how to set up the domain records, see the guide in the readme.
4. `acme`: you already have an ACME certificate set up (for example, you're already running a TLS-enabled
Nginx server on the FQDN). This is better than `manual` because the appropriate services will be reloaded
when the certificate is renewed.
''; '';
}; };
@ -684,9 +468,8 @@ in
type = types.path; type = types.path;
example = "/root/mail-server.crt"; example = "/root/mail-server.crt";
description = '' description = ''
({option}`mailserver.certificateScheme` == `manual`) Scheme 1)
Location of the certificate
Location of the certificate.
''; '';
}; };
@ -694,9 +477,8 @@ in
type = types.path; type = types.path;
example = "/root/mail-server.key"; example = "/root/mail-server.key";
description = '' description = ''
({option}`mailserver.certificateScheme` == `manual`) Scheme 1)
Location of the key file
Location of the key file.
''; '';
}; };
@ -704,27 +486,13 @@ in
type = types.path; type = types.path;
default = "/var/certs"; default = "/var/certs";
description = '' description = ''
({option}`mailserver.certificateScheme` == `selfsigned`) Scheme 2)
This is the folder where the certificate will be created. The name is
This is the folder where the self-signed certificate will be created. The name is
hardcoded to "cert-DOMAIN.pem" and "key-DOMAIN.pem" and the hardcoded to "cert-DOMAIN.pem" and "key-DOMAIN.pem" and the
certificate is valid for 10 years. certificate is valid for 10 years.
''; '';
}; };
acmeCertificateName = mkOption {
type = types.str;
default = cfg.fqdn;
example = "example.com";
description = ''
({option}`mailserver.certificateScheme` == `acme`)
When the `acme` `certificateScheme` is selected, you can use this option
to override the default certificate name. This is useful if you've
generated a wildcard certificate, for example.
'';
};
enableImap = mkOption { enableImap = mkOption {
type = types.bool; type = types.bool;
default = true; default = true;
@ -733,14 +501,6 @@ in
''; '';
}; };
imapMemoryLimit = mkOption {
type = types.int;
default = 256;
description = ''
The memory limit for the imap service, in megabytes.
'';
};
enableImapSsl = mkOption { enableImapSsl = mkOption {
type = types.bool; type = types.bool;
default = true; default = true;
@ -822,7 +582,7 @@ in
type = types.str; type = types.str;
default = "mail"; default = "mail";
description = '' description = ''
The DKIM selector.
''; '';
}; };
@ -830,20 +590,7 @@ in
type = types.path; type = types.path;
default = "/var/dkim"; default = "/var/dkim";
description = '' description = ''
The DKIM directory.
'';
};
dkimKeyType = mkOption {
type = types.enum [ "rsa" "ed25519" ];
default = "rsa";
description = ''
The key type used for generating DKIM keys. ED25519 was introduced in RFC6376 (2018).
If you have already deployed a key with a different type than specified
here, then you should use a different selector ({option}`mailserver.dkimSelector`). In order to get
this package to generate a key with the new type, you will either have to
change the selector or delete the old key file.
''; '';
}; };
@ -854,81 +601,32 @@ in
How many bits in generated DKIM keys. RFC6376 advises minimum 1024-bit keys. 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 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 here, then you should use a different selector (dkimSelector). In order to get
this package to generate a key with the new number of bits, you will either have to this package to generate a key with the new number of bits, you will either have to
change the selector or delete the old key file. change the selector or delete the old key file.
''; '';
}; };
dmarcReporting = { dkimHeaderCanonicalization = mkOption {
enable = mkOption { type = types.enum ["relaxed" "simple"];
type = types.bool; default = "relaxed";
default = false;
description = '' description = ''
Whether to send out aggregated, daily DMARC reports in response to incoming DKIM canonicalization algorithm for message headers.
mail, when the sender domain defines a DMARC policy including the RUA tag.
This is helpful for the mail ecosystem, because it allows third parties to See https://datatracker.ietf.org/doc/html/rfc6376/#section-3.4 for details.
get notified about SPF/DKIM violations originating from their sender domains.
See https://rspamd.com/doc/modules/dmarc.html#reporting
''; '';
}; };
localpart = mkOption { dkimBodyCanonicalization = mkOption {
type = types.str; type = types.enum ["relaxed" "simple"];
default = "dmarc-noreply"; default = "relaxed";
example = "dmarc-report";
description = '' description = ''
The local part of the email address used for outgoing DMARC reports. DKIM canonicalization algorithm for message bodies.
'';
};
domain = mkOption { See https://datatracker.ietf.org/doc/html/rfc6376/#section-3.4 for details.
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 = [];
description = ''
List of domains or eSLDs to be excluded from DMARC reports.
'';
};
};
debug = mkOption { debug = mkOption {
type = types.bool; type = types.bool;
default = false; default = false;
@ -969,19 +667,28 @@ in
address = mkOption { address = mkOption {
type = types.str; type = types.str;
# read the default from nixos' redis module # read the default from nixos' redis module
default = config.services.redis.servers.rspamd.unixSocket; default = let
defaultText = lib.literalExpression "config.services.redis.servers.rspamd.unixSocket"; cf = config.services.redis.servers.rspamd.bind;
cfdefault = if cf == null then "127.0.0.1" else cf;
ips = lib.strings.splitString " " cfdefault;
ip = lib.lists.head (ips ++ [ "127.0.0.1" ]);
isIpv6 = ip: lib.lists.elem ":" (lib.stringToCharacters ip);
in
if (ip == "0.0.0.0" || ip == "::")
then "127.0.0.1"
else if isIpv6 ip then "[${ip}]" else ip;
defaultText = lib.literalDocBook "computed from <option>config.services.redis.servers.rspamd.bind</option>";
description = '' description = ''
Path, IP address or hostname that Rspamd should use to contact Redis. Address that rspamd should use to contact redis.
''; '';
}; };
port = mkOption { port = mkOption {
type = with types; nullOr port; type = types.port;
default = null; default = config.services.redis.servers.rspamd.port;
example = lib.literalExpression "config.services.redis.servers.rspamd.port"; defaultText = lib.literalExpression "config.services.redis.servers.rspamd.port";
description = '' description = ''
Port that Rspamd should use to contact Redis. Port that rspamd should use to contact redis.
''; '';
}; };
@ -1005,25 +712,10 @@ 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 { sendingFqdn = mkOption {
type = types.str; type = types.str;
default = cfg.fqdn; default = cfg.fqdn;
defaultText = lib.literalMD "{option}`mailserver.fqdn`"; defaultText = "config.mailserver.fqdn";
example = "myserver.example.com"; example = "myserver.example.com";
description = '' description = ''
The fully qualified domain name of the mail server used to The fully qualified domain name of the mail server used to
@ -1039,7 +731,7 @@ in
This setting allows the server to identify as This setting allows the server to identify as
myserver.example.com when forwarding mail, independently of myserver.example.com when forwarding mail, independently of
{option}`mailserver.fqdn` (which, for SSL reasons, should generally be the name `fqdn` (which, for SSL reasons, should generally be the name
to which the user connects). to which the user connects).
Set this to the name to which the sending IP's reverse DNS Set this to the name to which the sending IP's reverse DNS
@ -1047,6 +739,18 @@ in
''; '';
}; };
policydSPFExtraConfig = mkOption {
type = types.lines;
default = "";
example = ''
skip_addresses = 127.0.0.0/8,::ffff:127.0.0.0/104,::1
'';
description = ''
Extra configuration options for policyd-spf. This can be use to among
other things skip spf checking for some IP addresses.
'';
};
monitoring = { monitoring = {
enable = mkEnableOption "monitoring via monit"; enable = mkEnableOption "monitoring via monit";
@ -1099,7 +803,7 @@ in
start program = "${pkgs.systemd}/bin/systemctl start rspamd" start program = "${pkgs.systemd}/bin/systemctl start rspamd"
stop program = "${pkgs.systemd}/bin/systemctl stop rspamd" stop program = "${pkgs.systemd}/bin/systemctl stop rspamd"
''; '';
defaultText = lib.literalMD "see [source](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/default.nix)"; defaultText = lib.literalDocBook "see source";
description = '' description = ''
The configuration used for monitoring via monit. The configuration used for monitoring via monit.
Use a mail address that you actively check and set it via 'set alert ...'. Use a mail address that you actively check and set it via 'set alert ...'.
@ -1116,8 +820,7 @@ in
description = '' description = ''
The location where borg saves the backups. The location where borg saves the backups.
This can be a local path or a remote location such as user@host:/path/to/repo. This can be a local path or a remote location such as user@host:/path/to/repo.
It is exported and thus available as an environment variable to It is exported and thus available as an environment variable to cmdPreexec and cmdPostexec.
{option}`mailserver.borgbackup.cmdPreexec` and {option}`mailserver.borgbackup.cmdPostexec`.
''; '';
}; };
@ -1177,7 +880,7 @@ in
default = "none"; default = "none";
description = '' description = ''
The backup can be encrypted by choosing any other value than 'none'. The backup can be encrypted by choosing any other value than 'none'.
When using encryption the password/passphrase must be provided in `passphraseFile`. When using encryption the password / passphrase must be provided in passphraseFile.
''; '';
}; };
@ -1200,7 +903,6 @@ in
locations = mkOption { locations = mkOption {
type = types.listOf types.path; type = types.listOf types.path;
default = [cfg.mailDirectory]; default = [cfg.mailDirectory];
defaultText = lib.literalExpression "[ config.mailserver.mailDirectory ]";
description = "The locations that are to be backed up by borg."; description = "The locations that are to be backed up by borg.";
}; };
@ -1221,9 +923,8 @@ in
default = null; default = null;
description = '' description = ''
The command to be executed before each backup operation. The command to be executed before each backup operation.
This is called prior to borg init in the same script that runs borg init and create and `cmdPostexec`. This is called prior to borg init in the same script that runs borg init and create and cmdPostexec.
''; Example:
example = ''
export BORG_RSH="ssh -i /path/to/private/key" export BORG_RSH="ssh -i /path/to/private/key"
''; '';
}; };
@ -1234,12 +935,33 @@ in
description = '' description = ''
The command to be executed after each backup operation. The command to be executed after each backup operation.
This is called after borg create completed successfully and in the same script that runs This is called after borg create completed successfully and in the same script that runs
`cmdPreexec`, borg init and create. cmdPreexec, borg init and create.
''; '';
}; };
}; };
rebootAfterKernelUpgrade = {
enable = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
Whether to enable automatic reboot after kernel upgrades.
This is to be used in conjunction with system.autoUpgrade.enable = true"
'';
};
method = mkOption {
type = types.enum [ "reboot" "systemctl kexec" ];
default = "reboot";
description = ''
Whether to issue a full "reboot" or just a "systemctl kexec"-only reboot.
It is recommended to use the default value because the quicker kexec reboot has a number of problems.
Also if your server is running in a virtual machine the regular reboot will already be very quick.
'';
};
};
backup = { backup = {
enable = mkEnableOption "backup via rsnapshot"; enable = mkEnableOption "backup via rsnapshot";
@ -1301,33 +1023,8 @@ in
}; };
imports = [ imports = [
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "enable" ] ''
This option is not needed for fts-flatcurve
'')
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "onCalendar" ] ''
This option is not needed for fts-flatcurve
'')
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "randomizedDelaySec" ] ''
This option is not needed for fts-flatcurve
'')
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "minSize" ] ''
This option is not supported by fts-flatcurve
'')
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maxSize" ] ''
This option is not needed since fts-xapian 1.8.3
'')
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "indexAttachments" ] ''
Text attachments are always indexed since fts-xapian 1.4.8
'')
(lib.mkRenamedOptionModule
[ "mailserver" "rebootAfterKernelUpgrade" "enable" ]
[ "system" "autoUpgrade" "allowReboot" ]
)
(lib.mkRemovedOptionModule [ "mailserver" "rebootAfterKernelUpgrade" "method" ] ''
Use `system.autoUpgrade` instead.
'')
./mail-server/assertions.nix
./mail-server/borgbackup.nix ./mail-server/borgbackup.nix
./mail-server/debug.nix
./mail-server/rsnapshot.nix ./mail-server/rsnapshot.nix
./mail-server/clamav.nix ./mail-server/clamav.nix
./mail-server/monit.nix ./mail-server/monit.nix
@ -1336,19 +1033,11 @@ in
./mail-server/networking.nix ./mail-server/networking.nix
./mail-server/systemd.nix ./mail-server/systemd.nix
./mail-server/dovecot.nix ./mail-server/dovecot.nix
./mail-server/opendkim.nix
./mail-server/postfix.nix ./mail-server/postfix.nix
./mail-server/rspamd.nix ./mail-server/rspamd.nix
./mail-server/nginx.nix ./mail-server/nginx.nix
./mail-server/kresd.nix ./mail-server/kresd.nix
(lib.mkRemovedOptionModule [ "mailserver" "policydSPFExtraConfig" ] '' ./mail-server/post-upgrade-check.nix
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.
'')
(lib.mkRemovedOptionModule [ "mailserver" "dkimHeaderCanonicalization" ] ''
DKIM signing has been migrated to Rspamd, which always uses relaxed canonicalization.
'')
(lib.mkRemovedOptionModule [ "mailserver" "dkimBodyCanonicalization" ] ''
DKIM signing has been migrated to Rspamd, which always uses relaxed canonicalization.
'')
]; ];
} }

View File

@ -4,8 +4,8 @@ Add Radicale
Configuration by @dotlambda Configuration by @dotlambda
Starting with Radicale 3 (first introduced in NixOS 20.09) the traditional Starting with Radicale 3 (first introduced in NixOS 20.09) the traditional
crypt passwords are no longer supported. Instead bcrypt passwords crypt passwords, as generated by `mkpasswd`, are no longer supported. Instead
have to be used. These can still be generated using `mkpasswd -m bcrypt`. bcrypt passwords have to be used which can be generated using `htpasswd`.
.. code:: nix .. code:: nix
@ -24,13 +24,12 @@ have to be used. These can still be generated using `mkpasswd -m bcrypt`.
in { in {
services.radicale = { services.radicale = {
enable = true; enable = true;
settings = { config = ''
auth = { [auth]
type = "htpasswd"; type = htpasswd
htpasswd_filename = "${htpasswd}"; htpasswd_filename = ${htpasswd}
htpasswd_encryption = "bcrypt"; htpasswd_encryption = bcrypt
}; '';
};
}; };
services.nginx = { services.nginx = {

View File

@ -1,5 +1,5 @@
Add Roundcube, a webmail Add Roundcube, a webmail
======================== =======================
The NixOS module for roundcube nearly works out of the box with SNM. By The NixOS module for roundcube nearly works out of the box with SNM. By
default, it sets up a nginx virtual host to serve the webmail, other web default, it sets up a nginx virtual host to serve the webmail, other web
@ -20,7 +20,7 @@ servers may require more work.
extraConfig = '' extraConfig = ''
# starttls needed for authentication, so the fqdn required to match # starttls needed for authentication, so the fqdn required to match
# the certificate # the certificate
$config['smtp_host'] = "tls://${config.mailserver.fqdn}"; $config['smtp_server'] = "tls://${config.mailserver.fqdn}";
$config['smtp_user'] = "%u"; $config['smtp_user'] = "%u";
$config['smtp_pass'] = "%p"; $config['smtp_pass'] = "%p";
''; '';

View File

@ -1,18 +0,0 @@
Autodiscovery
=============
`RFC6186 <https://www.rfc-editor.org/rfc/rfc6186>`_ allows supporting email clients to automatically discover SMTP / IMAP addresses
of the mailserver. For that, the following records are required:
================= ==== ==== ======== ====== ==== =================
Record TTL Type Priority Weight Port Value
================= ==== ==== ======== ====== ==== =================
_submission._tcp 3600 SRV 5 0 587 mail.example.com.
_submissions._tcp 3600 SRV 5 0 465 mail.example.com.
_imap._tcp 3600 SRV 5 0 143 mail.example.com.
_imaps._tcp 3600 SRV 5 0 993 mail.example.com.
================= ==== ==== ======== ====== ==== =================
Please note that only a few MUAs currently implement this. For vendor-specific
discovery mechanisms `automx <https://github.com/rseichter/automx2>`_ can be used instead.

View File

@ -17,9 +17,9 @@
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = "NixOS Mailserver" project = 'NixOS Mailserver'
copyright = "2022, NixOS Mailserver Contributors" copyright = '2020, NixOS Mailserver Contributors'
author = "NixOS Mailserver Contributors" author = 'NixOS Mailserver Contributors'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
@ -27,33 +27,27 @@ author = "NixOS Mailserver Contributors"
# Add any Sphinx extension module names here, as strings. They can be # Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = ["myst_parser"] extensions = [
myst_enable_extensions = [
"colon_fence",
"linkify",
] ]
smartquotes = False
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"] templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path. # This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
master_doc = "index" master_doc = 'index'
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # a list of builtin themes.
# #
html_theme = "sphinx_rtd_theme" html_theme = 'sphinx_rtd_theme'
# Add any paths that contain custom static files (such as style sheets) here, # Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = [] html_static_path = ['_static']

View File

@ -1,7 +1,7 @@
Nix Flakes Nix Flakes
========== ==========
If you're using `flakes <https://wiki.nixos.org/wiki/Flakes>`__, you can use If you're using `flakes <https://nixos.wiki/wiki/Flakes>`__, you can use
the following minimal ``flake.nix`` as an example: the following minimal ``flake.nix`` as an example:
.. code:: nix .. code:: nix

View File

@ -4,7 +4,7 @@ Full text search
By default, when your IMAP client searches for an email containing some By default, when your IMAP client searches for an email containing some
text in its *body*, dovecot will read all your email sequentially. This text in its *body*, dovecot will read all your email sequentially. This
is very slow and IO intensive. To speed body searches up, it is possible to is very slow and IO intensive. To speed body searches up, it is possible to
*index* emails with a plugin to dovecot, ``fts_flatcurve``. *index* emails with a plugin to dovecot, ``fts_xapian``.
Enabling full text search Enabling full text search
~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -20,6 +20,8 @@ To enable indexing for full text search here is an example configuration.
enable = true; enable = true;
# index new email as they arrive # index new email as they arrive
autoIndex = true; autoIndex = true;
# this only applies to plain text attachments, binary attachments are never indexed
indexAttachments = true;
enforced = "body"; enforced = "body";
}; };
}; };
@ -59,8 +61,8 @@ Mitigating resources requirements
You can: You can:
* exclude some headers from indexation with ``mailserver.fullTextSearch.headerExcludes`` * disable indexation of attachements ``mailserver.fullTextSearch.indexAttachments = false``
* disable expensive token normalisation in ``mailserver.fullTextSearch.filters`` * reduce the size of ngrams to be indexed ``mailserver.fullTextSearch.minSize`` and ``maxSize``
* disable automatic indexation for some folders with * disable automatic indexation for some folders with
``mailserver.fullTextSearch.autoIndexExclude``. Folders can be specified by ``mailserver.fullTextSearch.autoIndexExclude``. Folders can be specified by
name (``"Trash"``), by special use (``"\\Junk"``) or with a wildcard. name (``"Trash"``), by special use (``"\\Junk"``) or with a wildcard.

View File

@ -4,33 +4,13 @@ Contribute or troubleshoot
To report an issue, please go to To report an issue, please go to
`<https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues>`_. `<https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues>`_.
If you have questions, feel free to reach out: You can also chat with us on the Libera IRC channel ``#nixos-mailserver``.
* Matrix: `#nixos-mailserver:nixos.org <https://matrix.to/#/#nixos-mailserver:nixos.org>`__
* IRC: `#nixos-mailserver <ircs://irc.libera.chat/nixos-mailserver>`__ on `Libera Chat <https://libera.chat/guides/connect>`__
All our workflows rely on Nix being configured with `Flakes <https://wiki.nixos.org/wiki/Flakes#Installing_flakes>`__.
Development Shell
-----------------
We provide a `flake.nix` devshell that automatically sets up pre-commit hooks,
which allows for fast feedback cycles when making changes to the repository.
::
$ nix develop
We recommend setting up `direnv <https://direnv.net/>`__ to automatically
attach to the development environment when entering the project directories.
Run NixOS tests Run NixOS tests
--------------- ---------------
To run the test suite, you need to enable `Nix Flakes To run the test suite, you need to enable `Nix Flakes
<https://wiki.nixos.org/wiki/Flakes#Installing_flakes>`__. <https://nixos.wiki/wiki/Flakes#Installing_flakes>`.
You can then run the testsuite via You can then run the testsuite via
@ -50,17 +30,51 @@ run tests manually. For instance:
Contributing to the documentation Contributing to the documentation
--------------------------------- ---------------------------------
The documentation is written in RST (except option documentation which is in CommonMark), The documentation is written in RST, build with Sphinx and published
built with Sphinx and published by `Read the Docs <https://readthedocs.org/>`_. by `Read the Docs <https://readthedocs.org/>`_.
For the syntax, see the `RST/Sphinx primer For the syntax, see `RST/Sphinx Cheatsheet
<https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html>`_. <https://sphinx-tutorial.readthedocs.io/cheatsheet/>`_.
To build the documentation, you need to enable `Nix Flakes
<https://wiki.nixos.org/wiki/Flakes#Installing_flakes>`__.
The ``shell.nix`` provides all the tooling required to build the
documentation:
:: ::
$ nix build .#documentation $ nix-shell
$ xdg-open result/index.html $ cd docs
$ make html
$ firefox ./_build/html/index.html
Note if you modify some NixOS mailserver options, you would also need
to regenerate the ``options.rst`` file:
::
$ nix-shell --run generate-rst-options
Nixops
------
You can test the setup via ``nixops``. After installation, do
::
$ nixops create nixops/single-server.nix nixops/vbox.nix -d mail
$ nixops deploy -d mail
$ nixops info -d mail
You can then test the server via e.g. \ ``telnet``. To log into it, use
::
$ nixops ssh -d mail mailserver
Imap
----
To test imap manually use
::
$ openssl s_client -host mail.example.com -port 143 -starttls imap

View File

@ -29,8 +29,6 @@ Welcome to NixOS Mailserver's documentation!
rspamd-tuning rspamd-tuning
fts fts
flakes flakes
autodiscovery
ldap
Indices and tables Indices and tables
================== ==================

View File

@ -1,14 +0,0 @@
LDAP Support
============
It is possible to manage mail user accounts with LDAP rather than with
the option `loginAccounts <options.html#mailserver-loginaccounts>`_.
All related LDAP options are described in the `LDAP options section
<options.html#mailserver-ldap>`_ and the `LDAP test
<https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/tests/ldap.nix>`_
provides a getting started example.
.. note::
The LDAP support can not be enabled if some accounts are also defined with ``mailserver.loginAccounts``.

1123
docs/options.rst Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,75 +1,6 @@
Release Notes Release Notes
============= =============
NixOS 25.05
-----------
- OpenDKIM has been removed and DKIM signing is now handled by Rspamd, which only supports ``relaxed`` canoncalizaliaton.
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/374>`__)
- Rspamd now connects to Redis over its Unix Domain Socket by default
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/375>`__)
- If you need to revert TCP connections, configure ``mailserver.redis.address`` to reference the value of ``config.services.redis.servers.rspamd.bind``.
- The integration with policyd-spf was removed and SPF handling is now fully based on Rspamd scoring.
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/380>`__)
- Switch to the more efficient `fts-flatcurve` indexer for full text search
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/361>`__).
This makes use of a new index, which will be automatically re-generated the
next time a folder is searched.
The operation is now quick enough to be performed "just-in-time".
Alternatively, all indices can be immediately re-generated for all users and
folders by running
.. code-block:: bash
doveadm fts rescan -u '*' && doveadm index -u '*' -q '*'
The previous index (which is not automatically discarded to allow rollbacks)
can be cleaned up by removing all the `xapian-indexes` directories within
``mailserver.indexDir``.
- Individual domains can now be excluded from DMARC Reporting through ``mailserver.dmarcReporting.excludedDomains``.
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/297>`__)
- Configuring ``mailserver.forwards`` is now possible when the setup relies on LDAP.
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/313>`__)
- Support for TLS 1.1 was disabled in accordance with `Mozilla's recommendations <https://ssl-config.mozilla.org/#server=postfix>`_.
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/234>`__)
NixOS 24.11
-----------
- No new feature, only bug fixes and documentation improvements
NixOS 24.05
-----------
- Add new option ``acmeCertificateName`` which can be used to support
wildcard certificates
NixOS 23.11
-----------
- Add basic support for LDAP users
- Add support for regex (PCRE) aliases
NixOS 23.05
-----------
- Existing ACME certificates can be reused without configuring NGINX
- Certificate scheme is no longer a number, but a meaningful string instead
NixOS 22.11
-----------
- Allow Rspamd to send DMARC reporting
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/244>`__)
NixOS 22.05
-----------
- Make NixOS Mailserver options discoverable from search.nixos.org
- Add a roundcube setup guide in the documentation
NixOS 21.11 NixOS 21.11
----------- -----------
@ -84,6 +15,7 @@ NixOS 21.11
- New option ``certificateDomains`` to generate certificate for - New option ``certificateDomains`` to generate certificate for
additional domains (such as ``imap.example.com``) additional domains (such as ``imap.example.com``)
NixOS 21.05 NixOS 21.05
----------- -----------

View File

@ -1,5 +1,2 @@
sphinx ~= 5.3 sphinx==4.0.2
sphinx_rtd_theme ~= 1.1 sphinx_rtd_theme==0.5.2
myst-parser ~= 0.18
linkify-it-py ~= 2.0
standard-imghdr

View File

@ -24,14 +24,17 @@ You can run the training in a root shell as follows:
.. code:: bash .. code:: bash
# Path to the controller socket
export RSOCK="/var/run/rspamd/worker-controller.sock"
# Learn the Junk folder as spam # Learn the Junk folder as spam
rspamc learn_spam /var/vmail/$DOMAIN/$USER/.Junk/cur/ rspamc -h $RSOCK learn_spam /var/vmail/$DOMAIN/$USER/.Junk/cur/
# Learn the INBOX as ham # Learn the INBOX as ham
rspamc learn_ham /var/vmail/$DOMAIN/$USER/cur/ rspamc -h $RSOCK learn_ham /var/vmail/$DOMAIN/$USER/cur/
# Check that training was successful # Check that training was successful
rspamc stat | grep learned rspamc -h $RSOCK stat | grep learned
Tune symbol weight Tune symbol weight
~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~

View File

@ -20,30 +20,25 @@ an up and running mail server. Once the server is deployed, we could
then set all DNS entries required to send and receive mails on this then set all DNS entries required to send and receive mails on this
server. server.
Setup DNS A/AAAA records for server Setup DNS A record for server
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Add DNS records to the domain ``example.com`` with the following Add a DNS record to the domain ``example.com`` with the following
entries entries
==================== ===== ==== ============= ==================== ===== ==== =============
Name (Subdomain) TTL Type Value Name (Subdomain) TTL Type Value
==================== ===== ==== ============= ==================== ===== ==== =============
``mail.example.com`` 10800 A ``1.2.3.4`` ``mail.example.com`` 10800 A ``1.2.3.4``
``mail.example.com`` 10800 AAAA ``2001::1``
==================== ===== ==== ============= ==================== ===== ==== =============
If your server does not have an IPv6 address, you must skip the `AAAA` record.
You can check this with You can check this with
:: ::
$ nix-shell -p bind --command "host -t A mail.example.com" $ ping mail.example.com
mail.example.com has address 1.2.3.4 64 bytes from mail.example.com (1.2.3.4): icmp_seq=1 ttl=46 time=21.3 ms
...
$ nix-shell -p bind --command "host -t AAAA mail.example.com"
mail.example.com has address 2001::1
Note that it can take a while until a DNS entry is propagated. This Note that it can take a while until a DNS entry is propagated. This
DNS entry is required for the Let's Encrypt certificate generation DNS entry is required for the Let's Encrypt certificate generation
@ -53,19 +48,18 @@ Setup the server
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
The following describes a server setup that is fairly complete. Even The following describes a server setup that is fairly complete. Even
though there are more possible options (see the `NixOS Mailserver though there are more possible options (see the ``default.nix`` file),
options documentation <options.html>`_), these should be the most these should be the most common ones.
common ones.
.. code:: nix .. code:: nix
{ config, pkgs, ... }: { { config, pkgs, ... }:
{
imports = [ imports = [
(builtins.fetchTarball { (builtins.fetchTarball {
# Pick a release version you are interested in and set its hash, e.g. # Pick a commit from the branch you are interested in
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-25.05/nixos-mailserver-nixos-25.05.tar.gz"; url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/A-COMMIT-ID/nixos-mailserver-A-COMMIT-ID.tar.gz";
# To get the sha256 of the nixos-mailserver tarball, we can use the nix-prefetch-url command: # And set its hash
# 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"; sha256 = "0000000000000000000000000000000000000000000000000000";
}) })
]; ];
@ -76,7 +70,7 @@ common ones.
domains = [ "example.com" ]; domains = [ "example.com" ];
# A list of all login accounts. To create the password hashes, use # A list of all login accounts. To create the password hashes, use
# nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' # nix run nixpkgs.apacheHttpd -c htpasswd -nbB "" "super secret password" | cut -d: -f2
loginAccounts = { loginAccounts = {
"user1@example.com" = { "user1@example.com" = {
hashedPasswordFile = "/a/file/containing/a/hashed/password"; hashedPasswordFile = "/a/file/containing/a/hashed/password";
@ -87,10 +81,8 @@ common ones.
# Use Let's Encrypt certificates. Note that this needs to set up a stripped # Use Let's Encrypt certificates. Note that this needs to set up a stripped
# down nginx and opens port 80. # down nginx and opens port 80.
certificateScheme = "acme-nginx"; certificateScheme = 3;
}; };
security.acme.acceptTerms = true;
security.acme.defaults.email = "security@example.com";
} }
After a ``nixos-rebuild switch`` your server should be running all After a ``nixos-rebuild switch`` your server should be running all
@ -103,18 +95,8 @@ Set rDNS (reverse DNS) entry for server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Wherever you have rented your server, you should be able to set reverse Wherever you have rented your server, you should be able to set reverse
DNS entries for the IPs you own: DNS entries for the IPs you own. Add an entry resolving ``1.2.3.4``
to ``mail.example.com``
- Add an entry resolving IPv4 address ``1.2.3.4`` to ``mail.example.com``.
- Add an entry resolving IPv6 ``2001::1`` to ``mail.example.com``. Again, this
must be skipped if your server does not have an IPv6 address.
.. warning::
We don't recommend setting up a mail server if you are not able to
set a reverse DNS on your public IP because sent emails would be
mostly marked as spam. Note that many residential ISP providers
don't allow you to set a reverse DNS entry.
You can check this with You can check this with
@ -123,9 +105,6 @@ You can check this with
$ nix-shell -p bind --command "host 1.2.3.4" $ nix-shell -p bind --command "host 1.2.3.4"
4.3.2.1.in-addr.arpa domain name pointer mail.example.com. 4.3.2.1.in-addr.arpa domain name pointer mail.example.com.
$ nix-shell -p bind --command "host 2001::1"
1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.2.ip6.arpa domain name pointer mail.example.com.
Note that it can take a while until a DNS entry is propagated. Note that it can take a while until a DNS entry is propagated.
Set a ``MX`` record Set a ``MX`` record
@ -173,26 +152,25 @@ Note that it can take a while until a DNS entry is propagated.
Set ``DKIM`` signature Set ``DKIM`` signature
^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^
On your server, the ``rspamd`` systemd service generated a file On your server, the ``opendkim`` systemd service generated a file
containing your DKIM public key in the file containing your DKIM public key in the file
``/var/dkim/example.com.mail.txt``. The content of this file looks ``/var/dkim/example.com.mail.txt``. The content of this file looks
like like
:: ::
mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " mail._domainkey IN TXT "v=DKIM1; k=rsa; s=email; p=<really-long-key>" ; ----- DKIM mail for domain.tld
"p=<really-long-key>" ) ; ----- DKIM key mail for nixos.org
where ``really-long-key`` is your public key. where ``really-long-key`` is your public key.
Based on the content of this file, we can add a ``DKIM`` record to the Based on the content of this file, we can add a ``DKIM`` record to the
domain ``example.com``. domain ``example.com``.
=========================== ===== ==== ================================================ =========================== ===== ==== ==============================
Name (Subdomain) TTL Type Value Name (Subdomain) TTL Type Value
=========================== ===== ==== ================================================ =========================== ===== ==== ==============================
mail._domainkey.example.com 10800 TXT ``v=DKIM1; k=rsa; s=email; p=<really-long-key>`` mail._domainkey.example.com 10800 TXT ``v=DKIM1; p=<really-long-key>``
=========================== ===== ==== ================================================ =========================== ===== ==== ==============================
You can check this with You can check this with

107
flake.lock generated
View File

@ -16,106 +16,41 @@
"type": "gitlab" "type": "gitlab"
} }
}, },
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1747046372,
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1742649964,
"narHash": "sha256-DwOTp7nvfi8mRfuL1escHDXabVXFGT1VlPD1JHrtrco=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "dcf5072734cb576d2b0c59b2ac44f5050b5eac82",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1747179050, "lastModified": 1642635915,
"narHash": "sha256-qhFMmDkeJX9KJwr5H32f1r7Prs7XbQWtO0h3V0a0rFY=", "narHash": "sha256-vabPA32j81xBO5m3+qXndWp5aqepe+vu96Wkd9UnngM=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e", "rev": "6d8215281b2f87a5af9ed7425a26ac575da0438f",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "id": "nixpkgs",
"ref": "nixos-unstable", "ref": "nixos-unstable",
"repo": "nixpkgs", "type": "indirect"
"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"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"blobs": "blobs", "blobs": "blobs",
"flake-compat": "flake-compat",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"nixpkgs-25_05": "nixpkgs-25_05" "utils": "utils"
}
},
"utils": {
"locked": {
"lastModified": 1605370193,
"narHash": "sha256-YyMTf3URDL/otKdKgtoMChu4vfVL3vCMkRqpGifhUn0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "5021eac20303a61fafe17224c087f5519baed54d",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
} }
} }
}, },

196
flake.nix
View File

@ -2,192 +2,126 @@
description = "A complete and Simple Nixos Mailserver"; description = "A complete and Simple Nixos Mailserver";
inputs = { inputs = {
flake-compat = { utils.url = "github:numtide/flake-utils";
# for shell.nix compat nixpkgs.url = "flake:nixpkgs/nixos-unstable";
url = "github:edolstra/flake-compat";
flake = false;
};
git-hooks = {
url = "github:cachix/git-hooks.nix";
inputs.flake-compat.follows = "flake-compat";
inputs.nixpkgs.follows = "nixpkgs";
};
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs-25_05.url = "github:NixOS/nixpkgs/nixos-25.05";
blobs = { blobs = {
url = "gitlab:simple-nixos-mailserver/blobs"; url = "gitlab:simple-nixos-mailserver/blobs";
flake = false; flake = false;
}; };
}; };
outputs = { self, blobs, git-hooks, nixpkgs, nixpkgs-25_05, ... }: let outputs = { self, utils, blobs, nixpkgs }: let
lib = nixpkgs.lib;
system = "x86_64-linux"; system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
releases = [ releases = [
{ {
name = "unstable"; name = "unstable";
nixpkgs = nixpkgs;
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
} }
{
name = "25.05";
nixpkgs = nixpkgs-25_05;
pkgs = nixpkgs-25_05.legacyPackages.${system};
}
]; ];
testNames = [ testNames = [
"clamav"
"external"
"internal" "internal"
"ldap" "external"
"clamav"
"multiple" "multiple"
]; ];
genTest = testName: release: {
genTest = testName: release: let "name"= "${testName}-${release.name}";
"value"= import (./tests/. + "/${testName}.nix") {
pkgs = release.pkgs; pkgs = release.pkgs;
nixos-lib = import (release.nixpkgs + "/nixos/lib") { inherit blobs;
inherit (pkgs) lib;
};
in {
name = "${testName}-${builtins.replaceStrings ["."] ["_"] release.name}";
value = nixos-lib.runTest {
hostPkgs = pkgs;
imports = [ ./tests/${testName}.nix ];
_module.args = { inherit blobs; };
extraBaseModules.imports = [ ./default.nix ];
}; };
}; };
# Generate an attribute set such as # Generate an attribute set such as
# { # {
# external-unstable = <derivation>; # external-unstable = <derivation>;
# external-21_05 = <derivation>; # external-21_05 = <derivation>;
# ... # ...
# } # }
allTests = lib.listToAttrs ( allTests = pkgs.lib.listToAttrs (
lib.flatten (map (t: map (r: genTest t r) releases) testNames)); pkgs.lib.flatten (map (t: map (r: genTest t r) releases) testNames));
mailserverModule = import ./.; mailserverModule = import ./.;
# Generate a MarkDown file describing the options of the NixOS mailserver module # Generate a rst file describing options of the NixOS mailserver module
optionsDoc = let generateRstOptions = let
eval = lib.evalModules { eval = import (pkgs.path + "/nixos/lib/eval-config.nix") {
inherit system;
modules = [ modules = [
mailserverModule mailserverModule
{ {
_module.check = false; # Because the blockbook package is currently broken (we
mailserver = { # don't care about this package but it is part of the
fqdn = "mx.example.com"; # NixOS module evaluation)
domains = [ nixpkgs.config.allowBroken = true;
"example.com" mailserver.fqdn = "mx.example.com";
];
dmarcReporting = {
organizationName = "Example Corp";
domain = "example.com";
};
};
} }
]; ];
}; };
options = builtins.toFile "options.json" (builtins.toJSON options = pkgs.nixosOptionsDoc {
(lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver") options = eval.options;
(lib.optionAttrSetToDocList eval.options))); };
in pkgs.runCommand "options.md" { buildInputs = [pkgs.python3Minimal]; } '' in pkgs.runCommand "options.rst" { buildInputs = [pkgs.python3]; } ''
echo "Generating options.md from ${options}" echo Generating options.rst from ${options.optionsJSON}/share/doc/nixos/options.json
python ${./scripts/generate-options.py} ${options} > $out python ${./scripts/generate-rst-options.py} ${options.optionsJSON}/share/doc/nixos/options.json > $out
echo $out '';
# This is a script helping users to generate this file in the docs directory
generateRstOptionsScript = pkgs.writeScriptBin "generate-rst-options" ''
cp -v ${generateRstOptions} ./docs/options.rst
'';
# This is to ensure we don't forget to update the options.rst file
testRstOptions = pkgs.runCommand "test-rst-options" {} ''
if ! diff -q ${./docs/options.rst} ${generateRstOptions}
then
echo "The file ./docs/options.rst is not up-to-date and needs to be regenerated!"
echo " hint: run 'nix-shell --run generate-rst-options' to generate this file"
exit 1
fi
echo "test: ok" > $out
''; '';
documentation = pkgs.stdenv.mkDerivation { documentation = pkgs.stdenv.mkDerivation {
name = "documentation"; name = "documentation";
src = lib.sourceByRegex ./docs ["logo\\.png" "conf\\.py" "Makefile" ".*\\.rst"]; src = pkgs.lib.sourceByRegex ./docs ["logo.png" "conf.py" "Makefile" ".*rst$"];
buildInputs = [( buildInputs = [(
pkgs.python3.withPackages (p: with p; [ pkgs.python3.withPackages(p: [
sphinx p.sphinx
sphinx_rtd_theme p.sphinx_rtd_theme
myst-parser
linkify-it-py
]) ])
)]; )];
buildPhase = '' buildPhase = ''
cp ${optionsDoc} options.md cp ${generateRstOptions} options.rst
mkdir -p _static
# Workaround for https://github.com/sphinx-doc/sphinx/issues/3451 # Workaround for https://github.com/sphinx-doc/sphinx/issues/3451
unset SOURCE_DATE_EPOCH export SOURCE_DATE_EPOCH=$(${pkgs.coreutils}/bin/date +%s)
make html make html
''; '';
installPhase = '' installPhase = ''
cp -Tr _build/html $out cp -r _build/html $out
''; '';
}; };
in { in rec {
nixosModules = rec { nixosModules.mailserver = mailserverModule ;
mailserver = mailserverModule; nixosModule = self.nixosModules.mailserver;
default = mailserver;
};
nixosModule = self.nixosModules.default; # compatibility
hydraJobs.${system} = allTests // { hydraJobs.${system} = allTests // {
test-rst-options = testRstOptions;
inherit documentation; inherit documentation;
inherit (self.checks.${system}) pre-commit;
}; };
checks.${system} = allTests // { checks.${system} = allTests;
pre-commit = git-hooks.lib.${system}.run { devShell.${system} = pkgs.mkShell {
src = ./.; buildInputs = with pkgs; [
hooks = { generateRstOptionsScript
# docs (python3.withPackages (p: with p; [
markdownlint = { sphinx
enable = true; sphinx_rtd_theme
settings.configuration = { ]))
# Max line length, doesn't seem to correclty account for lines containing links jq
# https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md clamav
MD013 = false;
};
};
rstcheck = {
enable = true;
package = pkgs.rstcheckWithSphinx;
entry = lib.getExe pkgs.rstcheckWithSphinx;
files = "\\.rst$";
};
# nix
deadnix.enable = true;
# python
pyright.enable = true;
ruff = {
enable = true;
args = [
"--extend-select"
"I"
]; ];
}; };
ruff-format.enable = true;
# scripts
shellcheck.enable = true;
# sieve
check-sieve = {
enable = true;
package = pkgs.check-sieve;
entry = lib.getExe pkgs.check-sieve;
files = "\\.sieve$";
};
};
};
};
packages.${system} = {
inherit optionsDoc documentation;
};
devShells.${system}.default = pkgs.mkShellNoCC {
inputsFrom = [ documentation ];
packages = with pkgs; [
glab
] ++ self.checks.${system}.pre-commit.enabledPackages;
shellHook = self.checks.${system}.pre-commit.shellHook;
};
devShell.${system} = self.devShells.${system}.default; # compatibility
}; };
} }

View File

@ -1,18 +0,0 @@
{ config, lib, ... }:
{
assertions = lib.optionals config.mailserver.ldap.enable [
{
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 == {};
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases";
}
] ++ 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,7 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, lib, ... }: { config, pkgs, lib, options, ... }:
let let
cfg = config.mailserver; cfg = config.mailserver;

View File

@ -21,22 +21,22 @@ let
in in
{ {
# cert :: PATH # cert :: PATH
certificatePath = if cfg.certificateScheme == "manual" certificatePath = if cfg.certificateScheme == 1
then cfg.certificateFile then cfg.certificateFile
else if cfg.certificateScheme == "selfsigned" else if cfg.certificateScheme == 2
then "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem" then "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem"
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" else if cfg.certificateScheme == 3
then "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/fullchain.pem" then "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem"
else throw "unknown certificate scheme"; else throw "Error: Certificate Scheme must be in { 1, 2, 3 }";
# key :: PATH # key :: PATH
keyPath = if cfg.certificateScheme == "manual" keyPath = if cfg.certificateScheme == 1
then cfg.keyFile then cfg.keyFile
else if cfg.certificateScheme == "selfsigned" else if cfg.certificateScheme == 2
then "${cfg.certificateDirectory}/key-${cfg.fqdn}.pem" then "${cfg.certificateDirectory}/key-${cfg.fqdn}.pem"
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" else if cfg.certificateScheme == 3
then "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/key.pem" then "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem"
else throw "unknown certificate scheme"; else throw "Error: Certificate Scheme must be in { 1, 2, 3 }";
passwordFiles = let passwordFiles = let
mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash; mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash;
@ -45,26 +45,4 @@ in
if value.hashedPasswordFile == null then if value.hashedPasswordFile == null then
builtins.toString (mkHashFile name value.hashedPassword) 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}" ''
#!${pkgs.stdenv.shell}
set -euo pipefail
baseDir=$(dirname ${destination})
if (! test -d "$baseDir"); then
mkdir -p $baseDir
chmod 755 $baseDir
fi
cat ${file} > ${destination}
echo -n '${prefix}' >> ${destination}
cat ${passwordFile} | tr -d '\n' >> ${destination}
echo -n '${suffix}' >> ${destination}
chmod 600 ${destination}
'';
} }

4
mail-server/debug.nix Normal file
View File

@ -0,0 +1,4 @@
{ config, lib, ... }:
{
mailserver.policydSPFExtraConfig = lib.mkIf config.mailserver.debug "debugLevel = 4";
}

View File

@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ options, config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
with (import ./common.nix { inherit config pkgs lib; }); with (import ./common.nix { inherit config pkgs lib; });
@ -23,63 +23,40 @@ let
passwdDir = "/run/dovecot2"; passwdDir = "/run/dovecot2";
passwdFile = "${passwdDir}/passwd"; passwdFile = "${passwdDir}/passwd";
userdbFile = "${passwdDir}/userdb";
# This file contains the ldap bind password bool2int = x: if x then "1" else "0";
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}";
value = x;
}) attrs);
maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs"; maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs";
maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8";
# maildir in format "/${domain}/${user}" # maildir in format "/${domain}/${user}"
dovecotMaildir = dovecotMaildir =
"maildir:${cfg.mailDirectory}/%{domain}/%{username}${maildirLayoutAppendix}${maildirUTF8FolderNames}" "maildir:${cfg.mailDirectory}/%d/%n${maildirLayoutAppendix}"
+ (lib.optionalString (cfg.indexDir != null) + (lib.optionalString (cfg.indexDir != null)
":INDEX=${cfg.indexDir}/%{domain}/%{username}" ":INDEX=${cfg.indexDir}/%d/%n"
); );
postfixCfg = config.services.postfix; postfixCfg = config.services.postfix;
dovecot2Cfg = config.services.dovecot2;
ldapConfig = pkgs.writeTextFile { stateDir = "/var/lib/dovecot";
name = "dovecot-ldap.conf.ext.template";
text = '' pipeBin = pkgs.stdenv.mkDerivation {
ldap_version = 3 name = "pipe_bin";
uris = ${lib.concatStringsSep " " cfg.ldap.uris} src = ./dovecot/pipe_bin;
${lib.optionalString cfg.ldap.startTls '' buildInputs = with pkgs; [ makeWrapper coreutils bash rspamd ];
tls = yes buildCommand = ''
''} mkdir -p $out/pipe/bin
tls_require_cert = hard cp $src/* $out/pipe/bin/
tls_ca_cert_file = ${cfg.ldap.tlsCAFile} chmod a+x $out/pipe/bin/*
dn = ${cfg.ldap.bind.dn} patchShebangs $out/pipe/bin
sasl_bind = no
auth_bind = yes for file in $out/pipe/bin/*; do
base = ${cfg.ldap.searchBase} wrapProgram $file \
scope = ${mkLdapSearchScope cfg.ldap.searchScope} --set PATH "${pkgs.coreutils}/bin:${pkgs.rspamd}/bin"
${lib.optionalString (cfg.ldap.dovecot.userAttrs != null) '' done
user_attrs = ${cfg.ldap.dovecot.userAttrs}
''}
user_filter = ${cfg.ldap.dovecot.userFilter}
${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") ''
pass_attrs = ${cfg.ldap.dovecot.passAttrs}
''}
pass_filter = ${cfg.ldap.dovecot.passFilter}
''; '';
}; };
setPwdInLdapConfFile = appendLdapBindPwd {
name = "ldap-conf-file";
file = ldapConfig;
prefix = ''dnpass = "'';
suffix = ''"'';
passwordFile = cfg.ldap.bind.passwordFile;
destination = ldapConfFile;
};
genPasswdScript = pkgs.writeScript "generate-password-file" '' genPasswdScript = pkgs.writeScript "generate-password-file" ''
#!${pkgs.stdenv.shell} #!${pkgs.stdenv.shell}
@ -90,10 +67,7 @@ let
chmod 755 "${passwdDir}" chmod 755 "${passwdDir}"
fi fi
# Prevent world-readable password files, even temporarily. for f in ${builtins.toString (lib.mapAttrsToList (name: value: passwordFiles."${name}") cfg.loginAccounts)}; do
umask 077
for f in ${builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts)}; do
if [ ! -f "$f" ]; then if [ ! -f "$f" ]; then
echo "Expected password hash file $f does not exist!" echo "Expected password hash file $f does not exist!"
exit 1 exit 1
@ -101,48 +75,22 @@ let
done done
cat <<EOF > ${passwdFile} cat <<EOF > ${passwdFile}
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _: ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value:
"${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::" "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}:${builtins.toString cfg.vmailUID}:${builtins.toString cfg.vmailUID}::${cfg.mailDirectory}:/run/current-system/sw/bin/nologin:"
+ (if lib.isString value.quota
then "userdb_quota_rule=*:storage=${value.quota}"
else "")
) cfg.loginAccounts)} ) cfg.loginAccounts)}
EOF EOF
cat <<EOF > ${userdbFile} chmod 600 ${passwdFile}
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value:
"${name}:::::::"
+ lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}"
) cfg.loginAccounts)}
EOF
''; '';
junkMailboxes = builtins.attrNames (lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes); junkMailboxes = builtins.attrNames (lib.filterAttrs (n: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes);
junkMailboxNumber = builtins.length junkMailboxes; junkMailboxNumber = builtins.length junkMailboxes;
# The assertion garantees there is exactly one Junk mailbox. # The assertion garantees there is exactly one Junk mailbox.
junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else ""; 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
);
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_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);
in in
{ {
config = with cfg; lib.mkIf enable { config = with cfg; lib.mkIf enable {
@ -153,33 +101,7 @@ in
} }
]; ];
warnings = services.dovecot2 = {
(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.optionals (!haveDovecotModulesOption) dovecotModules;
# For compatibility with python imaplib
environment.etc = lib.mkIf (!haveDovecotModulesOption) {
"dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
};
services.dovecot2 = lib.mkMerge [{
enable = true; enable = true;
enableImap = enableImap || enableImapSsl; enableImap = enableImap || enableImapSsl;
enablePop3 = enablePop3 || enablePop3Ssl; enablePop3 = enablePop3 || enablePop3Ssl;
@ -191,24 +113,12 @@ in
sslServerCert = certificatePath; sslServerCert = certificatePath;
sslServerKey = keyPath; sslServerKey = keyPath;
enableLmtp = true; enableLmtp = true;
mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [ modules = [ pkgs.dovecot_pigeonhole ] ++ (lib.optional cfg.fullTextSearch.enable pkgs.dovecot_fts_xapian );
"fts" mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [ "fts" "fts_xapian" ];
"fts_flatcurve"
];
protocols = lib.optional cfg.enableManageSieve "sieve"; protocols = lib.optional cfg.enableManageSieve "sieve";
pluginSettings = { sieveScripts = {
sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve"; after = builtins.toFile "spam.sieve" ''
sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve";
sieve_default_name = "default";
} // (lib.optionalAttrs cfg.fullTextSearch.enable ftsPluginSettings);
sieve = {
extensions = [
"fileinto"
];
scripts.after = builtins.toFile "spam.sieve" ''
require "fileinto"; require "fileinto";
if header :is "X-Spam" "Yes" { if header :is "X-Spam" "Yes" {
@ -216,29 +126,8 @@ in
stop; stop;
} }
''; '';
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")
];
}; };
imapsieve.mailbox = [
{
name = junkMailboxName;
causes = [ "COPY" "APPEND" ];
before = ./dovecot/imap_sieve/report-spam.sieve;
}
{
name = "*";
from = junkMailboxName;
causes = [ "COPY" ];
before = ./dovecot/imap_sieve/report-ham.sieve;
}
];
mailboxes = cfg.mailboxes; mailboxes = cfg.mailboxes;
extraConfig = '' extraConfig = ''
@ -297,10 +186,6 @@ in
mail_plugins = $mail_plugins imap_sieve mail_plugins = $mail_plugins imap_sieve
} }
service imap {
vsz_limit = ${builtins.toString cfg.imapMemoryLimit} MB
}
protocol pop3 { protocol pop3 {
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
} }
@ -308,7 +193,7 @@ in
mail_access_groups = ${vmailGroupName} mail_access_groups = ${vmailGroupName}
ssl = required ssl = required
ssl_min_protocol = TLSv1 ssl_min_protocol = TLSv1
ssl_prefer_server_ciphers = no ssl_prefer_server_ciphers = yes
service lmtp { service lmtp {
unix_listener dovecot-lmtp { unix_listener dovecot-lmtp {
@ -316,17 +201,6 @@ in
mode = 0600 mode = 0600
user = ${postfixCfg.user} user = ${postfixCfg.user}
} }
vsz_limit = ${builtins.toString cfg.lmtpMemoryLimit} MB
}
service quota-status {
inet_listener {
port = 0
}
unix_listener quota-status {
user = postfix
}
vsz_limit = ${builtins.toString cfg.quotaStatusMemoryLimit} MB
} }
recipient_delimiter = ${cfg.recipientDelimiter} recipient_delimiter = ${cfg.recipientDelimiter}
@ -343,23 +217,9 @@ in
userdb { userdb {
driver = passwd-file driver = passwd-file
args = ${userdbFile} args = ${passwdFile}
default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}
} }
${lib.optionalString cfg.ldap.enable ''
passdb {
driver = ldap
args = ${ldapConfFile}
}
userdb {
driver = ldap
args = ${ldapConfFile}
default_fields = home=/var/vmail/ldap/%{user} uid=${toString cfg.vmailUID} gid=${toString cfg.vmailUID}
}
''}
service auth { service auth {
unix_listener auth { unix_listener auth {
mode = 0660 mode = 0660
@ -375,27 +235,90 @@ in
inbox = yes inbox = yes
} }
service indexer-worker { plugin {
${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) '' sieve_plugins = sieve_imapsieve sieve_extprograms
vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit*1024*1024)} sieve = file:${cfg.sieveDirectory}/%u/scripts;active=${cfg.sieveDirectory}/%u/active.sieve
''} sieve_default = file:${cfg.sieveDirectory}/%u/default.sieve
sieve_default_name = default
# From elsewhere to Spam folder
imapsieve_mailbox1_name = ${junkMailboxName}
imapsieve_mailbox1_causes = COPY
imapsieve_mailbox1_before = file:${stateDir}/imap_sieve/report-spam.sieve
# From Spam folder to elsewhere
imapsieve_mailbox2_name = *
imapsieve_mailbox2_from = ${junkMailboxName}
imapsieve_mailbox2_causes = COPY
imapsieve_mailbox2_before = file:${stateDir}/imap_sieve/report-ham.sieve
sieve_pipe_bin_dir = ${pipeBin}/pipe/bin
sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.environment
} }
${lib.optionalString cfg.fullTextSearch.enable ''
plugin {
plugin = fts fts_xapian
fts = xapian
fts_xapian = partial=${toString cfg.fullTextSearch.minSize} full=${toString cfg.fullTextSearch.maxSize} attachments=${bool2int cfg.fullTextSearch.indexAttachments} verbose=${bool2int cfg.debug}
fts_autoindex = ${if cfg.fullTextSearch.autoIndex then "yes" else "no"}
${lib.strings.concatImapStringsSep "\n" (n: x: "fts_autoindex_exclude${if n==1 then "" else toString n} = ${x}") cfg.fullTextSearch.autoIndexExclude}
fts_enforced = ${cfg.fullTextSearch.enforced}
}
${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) ''
service indexer-worker {
vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit*1024*1024)}
}
''}
''}
lda_mailbox_autosubscribe = yes lda_mailbox_autosubscribe = yes
lda_mailbox_autocreate = yes lda_mailbox_autocreate = yes
''; '';
} };
(lib.mkIf haveDovecotModulesOption {
modules = dovecotModules;
})
];
systemd.services.dovecot2 = { systemd.services.dovecot2 = {
preStart = '' preStart = ''
${genPasswdScript} ${genPasswdScript}
'' + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile); rm -rf '${stateDir}/imap_sieve'
mkdir '${stateDir}/imap_sieve'
cp -p "${./dovecot/imap_sieve}"/*.sieve '${stateDir}/imap_sieve/'
for k in "${stateDir}/imap_sieve"/*.sieve ; do
${pkgs.dovecot_pigeonhole}/bin/sievec "$k"
done
chown -R '${dovecot2Cfg.mailUser}:${dovecot2Cfg.mailGroup}' '${stateDir}/imap_sieve'
'';
}; };
systemd.services.postfix.restartTriggers = [ genPasswdScript ] ++ (lib.optional cfg.ldap.enable [setPwdInLdapConfFile]); systemd.services.postfix.restartTriggers = [ genPasswdScript ];
systemd.services.dovecot-fts-xapian-optimize = lib.mkIf (cfg.fullTextSearch.enable && cfg.fullTextSearch.maintenance.enable) {
description = "Optimize dovecot indices for fts_xapian";
requisite = [ "dovecot2.service" ];
after = [ "dovecot2.service" ];
startAt = cfg.fullTextSearch.maintenance.onCalendar;
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.dovecot}/bin/doveadm fts optimize -A";
PrivateDevices = true;
PrivateNetwork = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectSystem = true;
PrivateTmp = true;
};
};
systemd.timers.dovecot-fts-xapian-optimize = lib.mkIf (cfg.fullTextSearch.enable && cfg.fullTextSearch.maintenance.enable && cfg.fullTextSearch.maintenance.randomizedDelaySec != 0) {
timerConfig = {
RandomizedDelaySec = cfg.fullTextSearch.maintenance.randomizedDelaySec;
};
};
}; };
} }

View File

@ -12,4 +12,4 @@ if environment :matches "imap.user" "*" {
set "username" "${1}"; set "username" "${1}";
} }
pipe :copy "rspamd-learn-ham.sh" [ "${username}" ]; pipe :copy "sa-learn-ham.sh" [ "${username}" ];

View File

@ -4,4 +4,4 @@ if environment :matches "imap.user" "*" {
set "username" "${1}"; set "username" "${1}";
} }
pipe :copy "rspamd-learn-spam.sh" [ "${username}" ]; pipe :copy "sa-learn-spam.sh" [ "${username}" ];

View File

@ -0,0 +1,3 @@
#!/bin/bash
set -o errexit
exec rspamc -h /run/rspamd/worker-controller.sock learn_ham

View File

@ -0,0 +1,3 @@
#!/bin/bash
set -o errexit
exec rspamc -h /run/rspamd/worker-controller.sock learn_spam

View File

@ -22,7 +22,7 @@ in
{ {
config = with cfg; lib.mkIf enable { config = with cfg; lib.mkIf enable {
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
dovecot openssh postfix rspamd dovecot opendkim openssh postfix rspamd
] ++ (if certificateScheme == "selfsigned" then [ openssl ] else []); ] ++ (if certificateScheme == 2 then [ openssl ] else []);
}; };
} }

View File

@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, lib, ... }: { config, pkgs, lib, ... }:
let let
cfg = config.mailserver; cfg = config.mailserver;

View File

@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, lib, ... }: { config, pkgs, lib, ... }:
let let
cfg = config.mailserver; cfg = config.mailserver;

View File

@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, lib, ... }: { config, pkgs, lib, ... }:
let let
cfg = config.mailserver; cfg = config.mailserver;
@ -31,7 +31,7 @@ in
++ lib.optional enablePop3 110 ++ lib.optional enablePop3 110
++ lib.optional enablePop3Ssl 995 ++ lib.optional enablePop3Ssl 995
++ lib.optional enableManageSieve 4190 ++ lib.optional enableManageSieve 4190
++ lib.optional (certificateScheme == "acme-nginx") 80; ++ lib.optional (certificateScheme == 3) 80;
}; };
}; };
} }

View File

@ -17,24 +17,26 @@
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
with (import ./common.nix { inherit config lib pkgs; }); with (import ./common.nix { inherit config; });
let let
cfg = config.mailserver; cfg = config.mailserver;
acmeRoot = "/var/lib/acme/acme-challenge";
in in
{ {
config = lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx")) { config = lib.mkIf (cfg.enable && cfg.certificateScheme == 3) {
services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") { services.nginx = {
enable = true; enable = true;
virtualHosts."${cfg.fqdn}" = { virtualHosts."${cfg.fqdn}" = {
serverName = cfg.fqdn; serverName = cfg.fqdn;
serverAliases = cfg.certificateDomains; serverAliases = cfg.certificateDomains;
forceSSL = true; forceSSL = true;
enableACME = true; enableACME = true;
acmeRoot = acmeRoot;
}; };
}; };
security.acme.certs."${cfg.acmeCertificateName}".reloadServices = [ security.acme.certs."${cfg.fqdn}".reloadServices = [
"postfix.service" "postfix.service"
"dovecot2.service" "dovecot2.service"
]; ];

88
mail-server/opendkim.nix Normal file
View File

@ -0,0 +1,88 @@
# nixos-mailserver: a simple mail server
# Copyright (C) 2017 Brian Olsen
#
# 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/>
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.mailserver;
dkimUser = config.services.opendkim.user;
dkimGroup = config.services.opendkim.group;
createDomainDkimCert = dom:
let
dkim_key = "${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.key";
dkim_txt = "${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.txt";
in
''
if [ ! -f "${dkim_key}" ]
then
${pkgs.opendkim}/bin/opendkim-genkey -s "${cfg.dkimSelector}" \
-d "${dom}" \
--bits="${toString cfg.dkimKeyBits}" \
--directory="${cfg.dkimKeyDirectory}"
mv "${cfg.dkimKeyDirectory}/${cfg.dkimSelector}.private" "${dkim_key}"
mv "${cfg.dkimKeyDirectory}/${cfg.dkimSelector}.txt" "${dkim_txt}"
echo "Generated key for domain ${dom} selector ${cfg.dkimSelector}"
fi
'';
createAllCerts = lib.concatStringsSep "\n" (map createDomainDkimCert cfg.domains);
keyTable = pkgs.writeText "opendkim-KeyTable"
(lib.concatStringsSep "\n" (lib.flip map cfg.domains
(dom: "${dom} ${dom}:${cfg.dkimSelector}:${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.key")));
signingTable = pkgs.writeText "opendkim-SigningTable"
(lib.concatStringsSep "\n" (lib.flip map cfg.domains (dom: "${dom} ${dom}")));
dkim = config.services.opendkim;
args = [ "-f" "-l" ] ++ lib.optionals (dkim.configFile != null) [ "-x" dkim.configFile ];
in
{
config = mkIf (cfg.dkimSigning && cfg.enable) {
services.opendkim = {
enable = true;
selector = cfg.dkimSelector;
keyPath = cfg.dkimKeyDirectory;
domains = "csl:${builtins.concatStringsSep "," cfg.domains}";
configFile = pkgs.writeText "opendkim.conf" (''
Canonicalization ${cfg.dkimHeaderCanonicalization}/${cfg.dkimBodyCanonicalization}
UMask 0002
Socket ${dkim.socket}
KeyTable file:${keyTable}
SigningTable file:${signingTable}
'' + (lib.optionalString cfg.debug ''
Syslog yes
SyslogSuccess yes
LogWhy yes
''));
};
users.users = optionalAttrs (config.services.postfix.user == "postfix") {
postfix.extraGroups = [ "${dkimGroup}" ];
};
systemd.services.opendkim = {
preStart = lib.mkForce createAllCerts;
serviceConfig = {
ExecStart = lib.mkForce "${pkgs.opendkim}/bin/opendkim ${escapeShellArgs args}";
PermissionsStartOnly = lib.mkForce false;
};
};
systemd.tmpfiles.rules = [
"d '${cfg.dkimKeyDirectory}' - ${dkimUser} ${dkimGroup} - -"
];
};
}

View File

@ -0,0 +1,46 @@
# 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/>
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.mailserver;
in
{
config = mkIf (cfg.enable && cfg.rebootAfterKernelUpgrade.enable) {
systemd.services.nixos-upgrade.serviceConfig.ExecStartPost = pkgs.writeScript "post-upgrade-check" ''
#!${pkgs.stdenv.shell}
# Checks whether the "current" kernel is different from the booted kernel
# and then triggers a reboot so that the "current" kernel will be the booted one.
# This is just an educated guess. If the links do not differ the kernels might still be different, according to spacefrogg in #nixos.
current=$(readlink -f /run/current-system/kernel)
booted=$(readlink -f /run/booted-system/kernel)
if [ "$current" == "$booted" ]; then
echo "kernel version seems unchanged, skipping reboot" | systemd-cat --priority 4 --identifier "post-upgrade-check";
else
echo "kernel path changed, possibly a new version" | systemd-cat --priority 2 --identifier "post-upgrade-check"
echo "$booted" | systemd-cat --priority 2 --identifier "post-upgrade-kernel-check"
echo "$current" | systemd-cat --priority 2 --identifier "post-upgrade-kernel-check"
${cfg.rebootAfterKernelUpgrade.method}
fi
'';
};
}

View File

@ -25,7 +25,7 @@ let
# Merge several lookup tables. A lookup table is a attribute set where # 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 key is an address (user@example.com) or a domain (@example.com)
# - the value is a list of addresses # - the value is a list of addresses
mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables; mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables;
# valiases_postfix :: Map String [String] # valiases_postfix :: Map String [String]
valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
@ -33,11 +33,6 @@ let
let to = name; let to = name;
in map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name)) in map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name))
cfg.loginAccounts)); 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 :: Map String [String]
catchAllPostfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList catchAllPostfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
@ -70,10 +65,6 @@ let
content = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix]); content = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix]);
in builtins.toFile "valias" content; in builtins.toFile "valias" content;
regex_valiases_file = let
content = lookupTableToString regex_valiases_postfix;
in builtins.toFile "regex_valias" content;
# denied_recipients_postfix :: [ String ] # denied_recipients_postfix :: [ String ]
denied_recipients_postfix = (map denied_recipients_postfix = (map
(acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}")
@ -103,7 +94,6 @@ let
# every alias is owned (uniquely) by its user. # every alias is owned (uniquely) by its user.
# The user's own address is already in all_valiases_postfix. # The user's own address is already in all_valiases_postfix.
vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix); vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_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. # Removes sensitive headers from mails handed in via the submission port.
@ -123,10 +113,16 @@ let
/^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}> /^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
''); '');
smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ]; inetSocket = addr: port: "inet:[${toString port}@${addr}]";
unixSocket = sock: "unix:${sock}";
smtpdMilters =
(lib.optional cfg.dkimSigning "unix:/run/opendkim/opendkim.sock")
++ [ "unix:/run/rspamd/rspamd-milter.sock" ];
policyd-spf = pkgs.writeText "policyd-spf.conf" cfg.policydSPFExtraConfig;
mappedFile = name: "hash:/var/lib/postfix/conf/${name}"; mappedFile = name: "hash:/var/lib/postfix/conf/${name}";
mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}";
submissionOptions = submissionOptions =
{ {
@ -137,73 +133,21 @@ let
smtpd_sasl_security_options = "noanonymous"; smtpd_sasl_security_options = "noanonymous";
smtpd_sasl_local_domain = "$myhostname"; smtpd_sasl_local_domain = "$myhostname";
smtpd_client_restrictions = "permit_sasl_authenticated,reject"; 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";
smtpd_sender_restrictions = "reject_sender_login_mismatch"; smtpd_sender_restrictions = "reject_sender_login_mismatch";
smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject";
cleanup_service_name = "submission-header-cleanup"; cleanup_service_name = "submission-header-cleanup";
}; };
commonLdapConfig = ''
server_host = ${lib.concatStringsSep " " cfg.ldap.uris}
start_tls = ${if cfg.ldap.startTls then "yes" else "no"}
version = 3
tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
tls_require_cert = yes
search_base = ${cfg.ldap.searchBase}
scope = ${cfg.ldap.searchScope}
bind = yes
bind_dn = ${cfg.ldap.bind.dn}
'';
ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" ''
${commonLdapConfig}
query_filter = ${cfg.ldap.postfix.filter}
result_attribute = ${cfg.ldap.postfix.mailAttribute}
'';
ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf";
appendPwdInSenderLoginMap = appendLdapBindPwd {
name = "ldap-sender-login-map";
file = ldapSenderLoginMap;
prefix = "bind_pw = ";
passwordFile = cfg.ldap.bind.passwordFile;
destination = ldapSenderLoginMapFile;
};
ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" ''
${commonLdapConfig}
query_filter = ${cfg.ldap.postfix.filter}
result_attribute = ${cfg.ldap.postfix.uidAttribute}
'';
ldapVirtualMailboxMapFile = "/run/postfix/ldap-virtual-mailbox-map.cf";
appendPwdInVirtualMailboxMap = appendLdapBindPwd {
name = "ldap-virtual-mailbox-map";
file = ldapVirtualMailboxMap;
prefix = "bind_pw = ";
passwordFile = cfg.ldap.bind.passwordFile;
destination = ldapVirtualMailboxMapFile;
};
in in
{ {
config = with cfg; lib.mkIf enable { config = with cfg; lib.mkIf enable {
systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable {
preStart = ''
${appendPwdInVirtualMailboxMap}
${appendPwdInSenderLoginMap}
'';
restartTriggers = [ appendPwdInVirtualMailboxMap appendPwdInSenderLoginMap ];
};
services.postfix = { services.postfix = {
enable = true; enable = true;
hostname = "${sendingFqdn}"; hostname = "${sendingFqdn}";
networksStyle = "host"; networksStyle = "host";
mapFiles."valias" = valiases_file; mapFiles."valias" = valiases_file;
mapFiles."regex_valias" = regex_valiases_file;
mapFiles."vaccounts" = vaccounts_file; mapFiles."vaccounts" = vaccounts_file;
mapFiles."regex_vaccounts" = regex_vaccounts_file;
mapFiles."denied_recipients" = denied_recipients_file; mapFiles."denied_recipients" = denied_recipients_file;
mapFiles."reject_senders" = reject_senders_file; mapFiles."reject_senders" = reject_senders_file;
mapFiles."reject_recipients" = reject_recipients_file; mapFiles."reject_recipients" = reject_recipients_file;
@ -226,25 +170,11 @@ in
virtual_gid_maps = "static:5000"; virtual_gid_maps = "static:5000";
virtual_mailbox_base = mailDirectory; virtual_mailbox_base = mailDirectory;
virtual_mailbox_domains = vhosts_file; virtual_mailbox_domains = vhosts_file;
virtual_mailbox_maps = [ virtual_mailbox_maps = mappedFile "valias";
(mappedFile "valias")
] ++ lib.optionals (cfg.ldap.enable) [
"ldap:${ldapVirtualMailboxMapFile}"
] ++ lib.optionals (regex_valiases_postfix != {}) [
(mappedRegexFile "regex_valias")
];
virtual_alias_maps = lib.mkAfter (lib.optionals (regex_valiases_postfix != {}) [
(mappedRegexFile "regex_valias")
]);
virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp";
# Avoid leakage of X-Original-To, X-Delivered-To headers between recipients # Avoid leakage of X-Original-To, X-Delivered-To headers between recipients
lmtp_destination_recipient_limit = "1"; 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 # sasl with dovecot
smtpd_sasl_type = "dovecot"; smtpd_sasl_type = "dovecot";
smtpd_sasl_path = "/run/dovecot2/auth"; smtpd_sasl_path = "/run/dovecot2/auth";
@ -253,23 +183,28 @@ in
"permit_mynetworks" "permit_sasl_authenticated" "reject_unauth_destination" "permit_mynetworks" "permit_sasl_authenticated" "reject_unauth_destination"
]; ];
policy-spf_time_limit = "3600s";
# reject selected senders # reject selected senders
smtpd_sender_restrictions = [ smtpd_sender_restrictions = [
"check_sender_access ${mappedFile "reject_senders"}" "check_sender_access ${mappedFile "reject_senders"}"
]; ];
# quota and spf checking
smtpd_recipient_restrictions = [ smtpd_recipient_restrictions = [
# reject selected recipients
"check_recipient_access ${mappedFile "denied_recipients"}" "check_recipient_access ${mappedFile "denied_recipients"}"
"check_recipient_access ${mappedFile "reject_recipients"}" "check_recipient_access ${mappedFile "reject_recipients"}"
# quota checking "check_policy_service inet:localhost:12340"
"check_policy_service unix:/run/dovecot2/quota-status" "check_policy_service unix:private/policy-spf"
]; ];
# TLS settings, inspired by https://github.com/jeaye/nix-files # TLS settings, inspired by https://github.com/jeaye/nix-files
# Submission by mail clients is handled in submissionOptions # Submission by mail clients is handled in submissionOptions
smtpd_tls_security_level = "may"; smtpd_tls_security_level = "may";
# strong might suffice and is computationally less expensive
smtpd_tls_eecdh_grade = "ultra";
# Disable obselete protocols # Disable obselete protocols
smtpd_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3"; smtpd_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3";
smtp_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3"; smtp_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3";
@ -292,20 +227,16 @@ in
# Allowing AUTH on a non encrypted connection poses a security risk # Allowing AUTH on a non encrypted connection poses a security risk
smtpd_tls_auth_only = true; smtpd_tls_auth_only = true;
# Log only a summary message on TLS handshake completion # Log only a summary message on TLS handshake completion
smtp_tls_loglevel = "1";
smtpd_tls_loglevel = "1"; smtpd_tls_loglevel = "1";
# Configure a non blocking source of randomness # Configure a non blocking source of randomness
tls_random_source = "dev:/dev/urandom"; tls_random_source = "dev:/dev/urandom";
smtpd_milters = smtpdMilters; smtpd_milters = smtpdMilters;
non_smtpd_milters = lib.mkIf cfg.dkimSigning [ "unix:/run/rspamd/rspamd-milter.sock" ]; non_smtpd_milters = lib.mkIf cfg.dkimSigning ["unix:/run/opendkim/opendkim.sock"];
milter_protocol = "6"; milter_protocol = "6";
milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}"; milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer}";
# Fix for https://www.postfix.org/smtp-smuggling.html
smtpd_forbid_bare_newline = cfg.smtpdForbidBareNewline;
smtpd_forbid_bare_newline_exclusions = "$mynetworks";
}; };
submissionOptions = submissionOptions; submissionOptions = submissionOptions;
@ -317,6 +248,13 @@ in
# D => Delivered-To, O => X-Original-To, R => Return-Path # D => Delivered-To, O => X-Original-To, R => Return-Path
args = [ "flags=O" ]; args = [ "flags=O" ];
}; };
"policy-spf" = {
type = "unix";
privileged = true;
chroot = false;
command = "spawn";
args = [ "user=nobody" "argv=${pkgs.pypolicyd-spf}/bin/policyd-spf" "${policyd-spf}"];
};
"submission-header-cleanup" = { "submission-header-cleanup" = {
type = "unix"; type = "unix";
private = false; private = false;

View File

@ -22,51 +22,18 @@ let
postfixCfg = config.services.postfix; postfixCfg = config.services.postfix;
rspamdCfg = config.services.rspamd; rspamdCfg = config.services.rspamd;
rspamdSocket = "rspamd.service"; rspamdSocket = "rspamd.service";
rspamdUser = config.services.rspamd.user;
rspamdGroup = config.services.rspamd.group;
createDkimKeypair = domain: let
privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key";
publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt";
in pkgs.writeShellScript "dkim-keygen-${domain}" ''
if [ ! -f "${privateKey}" ]
then
${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \
--domain "${domain}" \
--selector "${cfg.dkimSelector}" \
--type "${cfg.dkimKeyType}" \
--bits ${toString cfg.dkimKeyBits} \
--privkey "${privateKey}" > "${publicKey}"
chmod 0644 "${publicKey}"
echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}"
fi
'';
in in
{ {
config = with cfg; lib.mkIf enable { config = with cfg; lib.mkIf enable {
environment.systemPackages = lib.mkBefore [
(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 = { services.rspamd = {
enable = true; enable = true;
inherit debug; inherit debug;
locals = { locals = {
"milter_headers.conf" = { text = '' "milter_headers.conf" = { text = ''
extended_spam_headers = true; extended_spam_headers = yes;
''; }; ''; };
"redis.conf" = { text = '' "redis.conf" = { text = ''
servers = "${if cfg.redis.port == null servers = "${cfg.redis.address}:${toString cfg.redis.port}";
then
cfg.redis.address
else
"${cfg.redis.address}:${toString cfg.redis.port}"}";
'' + (lib.optionalString (cfg.redis.password != null) '' '' + (lib.optionalString (cfg.redis.password != null) ''
password = "${cfg.redis.password}"; password = "${cfg.redis.password}";
''); }; ''); };
@ -86,28 +53,19 @@ in
} }
''; }; ''; };
"dkim_signing.conf" = { text = '' "dkim_signing.conf" = { text = ''
enabled = ${lib.boolToString cfg.dkimSigning}; # Disable outbound email signing, we use opendkim for this
path = "${cfg.dkimKeyDirectory}/$domain.$selector.key"; enabled = false;
selector = "${cfg.dkimSelector}";
# Allow for usernames w/o domain part
allow_username_mismatch = true
''; };
"dmarc.conf" = { text = ''
${lib.optionalString cfg.dmarcReporting.enable ''
reporting {
enabled = true;
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 = {
"milter_headers.conf" = {
text = ''
extended_spam_headers = true;
'';
};
};
workers.rspamd_proxy = { workers.rspamd_proxy = {
type = "rspamd_proxy"; type = "rspamd_proxy";
bindSockets = [{ bindSockets = [{
@ -140,93 +98,14 @@ in
}; };
services.redis.servers.rspamd.enable = lib.mkDefault true; services.redis.servers.rspamd = {
enable = lib.mkDefault true;
systemd.tmpfiles.settings."10-rspamd.conf" = { port = lib.mkDefault 6380;
"${cfg.dkimKeyDirectory}" = {
d = {
# Create /var/dkim owned by rspamd user/group
user = rspamdUser;
group = rspamdGroup;
};
Z = {
# Recursively adjust permissions in /var/dkim
user = rspamdUser;
group = rspamdGroup;
};
};
}; };
systemd.services.rspamd = { systemd.services.rspamd = {
requires = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service"); requires = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service");
after = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service"); after = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service");
serviceConfig = lib.mkMerge [
{
SupplementaryGroups = [ config.services.redis.servers.rspamd.group ];
}
(lib.optionalAttrs cfg.dkimSigning {
ExecStartPre = map createDkimKeypair cfg.domains;
ReadWritePaths = [ cfg.dkimKeyDirectory ];
})
];
};
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 = ''
${pkgs.rspamd}/bin/rspamadm dmarc_report $(date -d "yesterday" "+%Y%m%d")
'';
serviceConfig = {
User = "${config.services.rspamd.user}";
Group = "${config.services.rspamd.group}";
AmbientCapabilities = [];
CapabilityBoundingSet = "";
DevicePolicy = "closed";
IPAddressAllow = "localhost";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProcSubset = "pid";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
UMask = "0077";
};
};
systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) {
description = "Daily delivery of aggregated DMARC reports";
wantedBy = [
"timers.target"
];
timerConfig = {
OnCalendar = "daily";
Persistent = true;
RandomizedDelaySec = 86400;
FixedRandomDelay = true;
};
}; };
systemd.services.postfix = { systemd.services.postfix = {

View File

@ -19,9 +19,9 @@
let let
cfg = config.mailserver; cfg = config.mailserver;
certificatesDeps = certificatesDeps =
if cfg.certificateScheme == "manual" then if cfg.certificateScheme == 1 then
[] []
else if cfg.certificateScheme == "selfsigned" then else if cfg.certificateScheme == 2 then
[ "mailserver-selfsigned-certificate.service" ] [ "mailserver-selfsigned-certificate.service" ]
else else
[ "acme-finished-${cfg.fqdn}.target" ]; [ "acme-finished-${cfg.fqdn}.target" ];
@ -29,7 +29,7 @@ in
{ {
config = with cfg; lib.mkIf enable { config = with cfg; lib.mkIf enable {
# Create self signed certificate # Create self signed certificate
systemd.services.mailserver-selfsigned-certificate = lib.mkIf (cfg.certificateScheme == "selfsigned") { systemd.services.mailserver-selfsigned-certificate = lib.mkIf (cfg.certificateScheme == 2) {
after = [ "local-fs.target" ]; after = [ "local-fs.target" ];
script = '' script = ''
# Create certificates if they do not exist yet # Create certificates if they do not exist yet
@ -63,9 +63,7 @@ in
); );
in '' in ''
# Create mail directory and set permissions. See # Create mail directory and set permissions. See
# <https://doc.dovecot.org/main/core/config/shared_mailboxes.html#filesystem-permissions-1>. # <http://wiki2.dovecot.org/SharedMailboxes/Permissions>.
# Prevent world-readable paths, even temporarily.
umask 007
mkdir -p ${directories} mkdir -p ${directories}
chgrp "${vmailGroupName}" ${directories} chgrp "${vmailGroupName}" ${directories}
chmod 02770 ${directories} chmod 02770 ${directories}
@ -76,10 +74,10 @@ in
systemd.services.postfix = { systemd.services.postfix = {
wants = certificatesDeps; wants = certificatesDeps;
after = [ "dovecot2.service" ] after = [ "dovecot2.service" ]
++ lib.optional cfg.dkimSigning "rspamd.service" ++ lib.optional cfg.dkimSigning "opendkim.service"
++ certificatesDeps; ++ certificatesDeps;
requires = [ "dovecot2.service" ] requires = [ "dovecot2.service" ]
++ lib.optional cfg.dkimSigning "rspamd.service"; ++ lib.optional cfg.dkimSigning "opendkim.service";
}; };
}; };
} }

View File

@ -34,9 +34,6 @@ let
set -euo pipefail set -euo pipefail
# Prevent world-readable paths, even temporarily.
umask 007
# Create directory to store user sieve scripts if it doesn't exist # Create directory to store user sieve scripts if it doesn't exist
if (! test -d "${sieveDirectory}"); then if (! test -d "${sieveDirectory}"); then
mkdir "${sieveDirectory}" mkdir "${sieveDirectory}"

31
nixops/single-server.nix Normal file
View File

@ -0,0 +1,31 @@
{
network.description = "mail server";
mailserver =
{ config, pkgs, ... }:
{
imports = [
../default.nix
];
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" "example2.com" ];
loginAccounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
};
};
extraVirtualAliases = {
"info@example.com" = "user1@example.com";
"postmaster@example.com" = "user1@example.com";
"abuse@example.com" = "user1@example.com";
"user1@example2.com" = "user1@example.com";
"info@example2.com" = "user1@example.com";
"postmaster@example2.com" = "user1@example.com";
"abuse@example2.com" = "user1@example.com";
};
};
};
}

9
nixops/vbox.nix Normal file
View File

@ -0,0 +1,9 @@
{
mailserver =
{ config, pkgs, ... }:
{ deployment.targetEnv = "virtualbox";
deployment.virtualbox.memorySize = 1024; # megabytes
deployment.virtualbox.vcpu = 2; # number of cpus
deployment.virtualbox.headless = true;
};
}

View File

@ -1,109 +0,0 @@
import json
import sys
from textwrap import indent
from typing import Any, Mapping
header = """
# Mailserver options
## `mailserver`
"""
template = """
`````{{option}} {key}
{description}
{type}
{default}
{example}
`````
"""
f = open(sys.argv[1])
options = json.load(f)
groups = [
"mailserver.loginAccounts",
"mailserver.certificate",
"mailserver.dkim",
"mailserver.dmarcReporting",
"mailserver.fullTextSearch",
"mailserver.redis",
"mailserver.ldap",
"mailserver.monitoring",
"mailserver.backup",
"mailserver.borgbackup",
]
def md_literal(value: str) -> str:
return f"`{value}`"
def md_codefence(value: str, language: str = "nix") -> str:
return indent(
f"\n```{language}\n{value}\n```",
prefix=2 * " ",
)
def render_option_value(option: Mapping[str, Any], key: str) -> str:
if key not in option:
return ""
if isinstance(option[key], dict) and "_type" in option[key]:
if option[key]["_type"] == "literalExpression":
# multi-line codeblock
if "\n" in option[key]["text"]:
text = option[key]["text"].rstrip("\n")
value = md_codefence(text)
# inline codeblock
else:
value = md_literal(option[key]["text"])
# literal markdown
elif option[key]["_type"] == "literalMD":
value = option[key]["text"]
else:
assert RuntimeError(f"Unhandled option type {option[key]['_type']}")
else:
text = str(option[key])
if text == "":
value = md_literal('""')
elif "\n" in text:
value = md_codefence(text.rstrip("\n"))
else:
value = md_literal(text)
return f"- {key}: {value}" # type: ignore
def print_option(option):
if (
isinstance(option["description"], dict) and "_type" in option["description"]
): # mdDoc
description = option["description"]["text"]
else:
description = option["description"]
print(
template.format(
key=option["name"],
description=description or "",
type=f"- type: {md_literal(option['type'])}",
default=render_option_value(option, "default"),
example=render_option_value(option, "example"),
)
)
print(header)
for opt in options:
if any([opt["name"].startswith(c) for c in groups]):
continue
print_option(opt)
for c in groups:
print(f"## `{c}`\n")
for opt in options:
if opt["name"].startswith(c):
print_option(opt)

View File

@ -0,0 +1,77 @@
import json
import sys
import re
header = """
Mailserver Options
==================
mailserver
~~~~~~~~~~
"""
template = """
{key}
{line}
{description}
{type}
{default}
"""
f = open(sys.argv[1])
options = json.load(f)
options = {k: v for k, v in options.items()
if k.startswith("mailserver.")}
groups = ["mailserver.loginAccount",
"mailserver.certificate",
"mailserver.dkim",
"mailserver.fullTextSearch",
"mailserver.redis",
"mailserver.monitoring",
"mailserver.backup",
"mailserver.borg"]
def print_option(name, value):
if 'default' in value:
if value['default'] == "":
default = '``""``'
elif isinstance(value['default'], dict) and '_type' in value['default']:
if value['default']['_type'] == 'literalExpression':
default = '``{}``'.format(value['default']['text'])
if value['default']['_type'] == 'literalDocBook':
default = value['default']['text']
else:
default = '``{}``'.format(value['default'])
# Some default values contains OUTPUTPATHS which make the
# output not stable across nixpkgs updates.
default = re.sub('/nix/store/[\w.-]*/', '<OUTPUT-PATH>/', default) # noqa
default = '- Default: ' + default
else:
default = ""
print(template.format(
key=name,
line="-"*len(name),
description=value['description'],
type="- Type: ``{}``".format(value['type']),
default=default))
print(header)
for k, v in options.items():
if any([k.startswith(c) for c in groups]):
continue
print_option(k, v)
for c in groups:
print(c)
print("~"*len(c))
print()
for k, v in options.items():
if k.startswith(c):
print_option(k, v)

View File

@ -1,31 +1,26 @@
import smtplib, sys
import argparse import argparse
import email import os
import email.utils
import imaplib
import smtplib
import time
import uuid import uuid
import imaplib
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import cast import email
import time
RETRY = 100 RETRY = 100
def _send_mail(smtp_host, smtp_port, from_addr, from_pwd, to_addr, subject, starttls):
def _send_mail( print("Sending mail with subject '{}'".format(subject))
smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr, subject, starttls message = "\n".join([
): "From: {from_addr}",
print(f"Sending mail with subject '{subject}'") "To: {to_addr}",
message = "\n".join( "Subject: {subject}",
[
f"From: {from_addr}",
f"To: {to_addr}",
f"Subject: {subject}",
f"Message-ID: {uuid.uuid4()}@mail-check.py",
f"Date: {email.utils.formatdate()}",
"", "",
"This validates our mail server can send to Gmail :/", "This validates our mail server can send to Gmail :/"]).format(
] from_addr=from_addr,
) to_addr=to_addr,
subject=subject)
retry = RETRY retry = RETRY
while True: while True:
@ -35,16 +30,14 @@ def _send_mail(
if starttls: if starttls:
smtp.starttls() smtp.starttls()
if from_pwd is not None: if from_pwd is not None:
smtp.login(smtp_username or from_addr, from_pwd) smtp.login(from_addr, from_pwd)
smtp.sendmail(from_addr, [to_addr], message) smtp.sendmail(from_addr, [to_addr], message)
return return
except smtplib.SMTPResponseException as e: except smtplib.SMTPResponseException as e:
if e.smtp_code == 451: # service unavailable error if e.smtp_code == 451: # service unavailable error
print(e) print(e)
elif ( elif e.smtp_code == 454: # smtplib.SMTPResponseException: (454, b'4.3.0 Try again later')
e.smtp_code == 454
): # smtplib.SMTPResponseException: (454, b'4.3.0 Try again later')
print(e) print(e)
else: else:
raise raise
@ -62,7 +55,6 @@ def _send_mail(
print("Retry attempts exhausted") print("Retry attempts exhausted")
exit(5) exit(5)
def _read_mail( def _read_mail(
imap_host, imap_host,
imap_port, imap_port,
@ -71,9 +63,8 @@ def _read_mail(
subject, subject,
ignore_dkim_spf, ignore_dkim_spf,
show_body=False, show_body=False,
delete=True, delete=True):
): print("Reading mail from %s" % imap_username)
print("Reading mail from {imap_username}")
message = None message = None
@ -83,62 +74,49 @@ def _read_mail(
today = datetime.today() today = datetime.today()
cutoff = today - timedelta(days=1) cutoff = today - timedelta(days=1)
dt = cutoff.strftime("%d-%b-%Y") dt = cutoff.strftime('%d-%b-%Y')
for _ in range(0, RETRY): for _ in range(0, RETRY):
print("Retrying") print("Retrying")
obj.select() obj.select()
_, data = obj.search(None, f'(SINCE {dt}) (SUBJECT "{subject}")') typ, data = obj.search(None, '(SINCE %s) (SUBJECT "%s")'%(dt, subject))
if data == [b""]: if data == [b'']:
time.sleep(1) time.sleep(1)
continue continue
uids = data[0].decode("utf-8").split(" ") uids = data[0].decode("utf-8").split(" ")
if len(uids) != 1: if len(uids) != 1:
print( print("Warning: %d messages have been found with subject containing %s " % (len(uids), subject))
f"Warning: {len(uids)} messages have been found with subject containing {subject}"
)
# FIXME: we only consider the first matching message... # FIXME: we only consider the first matching message...
uid = uids[0] uid = uids[0]
_, raw = obj.fetch(uid, "(RFC822)") _, raw = obj.fetch(uid, '(RFC822)')
if delete: if delete:
obj.store(uid, "+FLAGS", "\\Deleted") obj.store(uid, '+FLAGS', '\\Deleted')
obj.expunge() obj.expunge()
assert raw[0] and raw[0][1] message = email.message_from_bytes(raw[0][1])
message = email.message_from_bytes(cast(bytes, raw[0][1])) print("Message with subject '%s' has been found" % message['subject'])
print(f"Message with subject '{message['subject']}' has been found")
if show_body: if show_body:
if message.is_multipart(): for m in message.get_payload():
for part in message.walk(): if m.get_content_type() == 'text/plain':
ctype = part.get_content_type() print("Body:\n%s" % m.get_payload(decode=True).decode('utf-8'))
if ctype == "text/plain":
body = cast(bytes, part.get_payload(decode=True)).decode()
print(f"Body:\n{body}")
else:
print(f"Body with content type {ctype} not printed")
else:
body = cast(bytes, message.get_payload(decode=True)).decode()
print(f"Body:\n{body}")
break break
if message is None: if message is None:
print( print("Error: no message with subject '%s' has been found in INBOX of %s" % (subject, imap_username))
f"Error: no message with subject '{subject}' has been found in INBOX of {imap_username}"
)
exit(1) exit(1)
if ignore_dkim_spf: if ignore_dkim_spf:
return return
# gmail set this standardized header # gmail set this standardized header
if "ARC-Authentication-Results" in message: if 'ARC-Authentication-Results' in message:
if "dkim=pass" in message["ARC-Authentication-Results"]: if "dkim=pass" in message['ARC-Authentication-Results']:
print("DKIM ok") print("DKIM ok")
else: else:
print("Error: no DKIM validation found in message:") print("Error: no DKIM validation found in message:")
print(message.as_string()) print(message.as_string())
exit(2) exit(2)
if "spf=pass" in message["ARC-Authentication-Results"]: if "spf=pass" in message['ARC-Authentication-Results']:
print("SPF ok") print("SPF ok")
else: else:
print("Error: no SPF validation found in message:") print("Error: no SPF validation found in message:")
@ -148,108 +126,69 @@ def _read_mail(
print("DKIM and SPF verification failed") print("DKIM and SPF verification failed")
exit(4) exit(4)
def send_and_read(args): def send_and_read(args):
src_pwd = None src_pwd = None
if args.src_password_file is not None: if args.src_password_file is not None:
src_pwd = args.src_password_file.readline().rstrip() src_pwd = args.src_password_file.readline().rstrip()
dst_pwd = args.dst_password_file.readline().rstrip() dst_pwd = args.dst_password_file.readline().rstrip()
if args.imap_username != "": if args.imap_username != '':
imap_username = args.imap_username imap_username = args.imap_username
else: else:
imap_username = args.to_addr imap_username = args.to_addr
subject = f"{uuid.uuid4()}" subject = "{}".format(uuid.uuid4())
_send_mail( _send_mail(smtp_host=args.smtp_host,
smtp_host=args.smtp_host,
smtp_port=args.smtp_port, smtp_port=args.smtp_port,
smtp_username=args.smtp_username,
from_addr=args.from_addr, from_addr=args.from_addr,
from_pwd=src_pwd, from_pwd=src_pwd,
to_addr=args.to_addr, to_addr=args.to_addr,
subject=subject, subject=subject,
starttls=args.smtp_starttls, starttls=args.smtp_starttls)
)
_read_mail( _read_mail(imap_host=args.imap_host,
imap_host=args.imap_host,
imap_port=args.imap_port, imap_port=args.imap_port,
imap_username=imap_username, imap_username=imap_username,
to_pwd=dst_pwd, to_pwd=dst_pwd,
subject=subject, subject=subject,
ignore_dkim_spf=args.ignore_dkim_spf, ignore_dkim_spf=args.ignore_dkim_spf)
)
def read(args): def read(args):
_read_mail( _read_mail(imap_host=args.imap_host,
imap_host=args.imap_host,
imap_port=args.imap_port, imap_port=args.imap_port,
imap_username=args.imap_username, to_addr=args.imap_username,
to_pwd=args.imap_password, to_pwd=args.imap_password,
subject=args.subject, subject=args.subject,
ignore_dkim_spf=args.ignore_dkim_spf, ignore_dkim_spf=args.ignore_dkim_spf,
show_body=args.show_body, show_body=args.show_body,
delete=False, delete=False)
)
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers() subparsers = parser.add_subparsers()
parser_send_and_read = subparsers.add_parser( parser_send_and_read = subparsers.add_parser('send-and-read', description="Send a email with a subject containing a random UUID and then try to read this email from the recipient INBOX.")
"send-and-read", parser_send_and_read.add_argument('--smtp-host', type=str)
description="Send a email with a subject containing a random UUID and then try to read this email from the recipient INBOX.", 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-host", type=str) parser_send_and_read.add_argument('--from-addr', type=str)
parser_send_and_read.add_argument("--smtp-port", type=str, default=25) parser_send_and_read.add_argument('--imap-host', required=True, type=str)
parser_send_and_read.add_argument("--smtp-starttls", action="store_true") parser_send_and_read.add_argument('--imap-port', type=str, default=993)
parser_send_and_read.add_argument( parser_send_and_read.add_argument('--to-addr', type=str, required=True)
"--smtp-username", parser_send_and_read.add_argument('--imap-username', type=str, default='', help="username used for imap login. If not specified, the to-addr value is used")
type=str, parser_send_and_read.add_argument('--src-password-file', type=argparse.FileType('r'))
default="", parser_send_and_read.add_argument('--dst-password-file', required=True, type=argparse.FileType('r'))
help="username used for smtp login. If not specified, the from-addr value is used", parser_send_and_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail")
)
parser_send_and_read.add_argument("--from-addr", type=str)
parser_send_and_read.add_argument("--imap-host", required=True, type=str)
parser_send_and_read.add_argument("--imap-port", type=str, default=993)
parser_send_and_read.add_argument("--to-addr", type=str, required=True)
parser_send_and_read.add_argument(
"--imap-username",
type=str,
default="",
help="username used for imap login. If not specified, the to-addr value is used",
)
parser_send_and_read.add_argument("--src-password-file", type=argparse.FileType("r"))
parser_send_and_read.add_argument(
"--dst-password-file", required=True, type=argparse.FileType("r")
)
parser_send_and_read.add_argument(
"--ignore-dkim-spf",
action="store_true",
help="to ignore the dkim and spf verification on the read mail",
)
parser_send_and_read.set_defaults(func=send_and_read) parser_send_and_read.set_defaults(func=send_and_read)
parser_read = subparsers.add_parser( parser_read = subparsers.add_parser('read', description="Search for an email with a subject containing 'subject' in the INBOX.")
"read", parser_read.add_argument('--imap-host', type=str, default="localhost")
description="Search for an email with a subject containing 'subject' in the INBOX.", parser_read.add_argument('--imap-port', type=str, default=993)
) parser_read.add_argument('--imap-username', required=True, type=str)
parser_read.add_argument("--imap-host", type=str, default="localhost") parser_read.add_argument('--imap-password', required=True, type=str)
parser_read.add_argument("--imap-port", type=str, default=993) parser_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail")
parser_read.add_argument("--imap-username", required=True, type=str) parser_read.add_argument('--show-body', action='store_true', help="print mail text/plain payload")
parser_read.add_argument("--imap-password", required=True, type=str) parser_read.add_argument('subject', type=str)
parser_read.add_argument(
"--ignore-dkim-spf",
action="store_true",
help="to ignore the dkim and spf verification on the read mail",
)
parser_read.add_argument(
"--show-body", action="store_true", help="print mail text/plain payload"
)
parser_read.add_argument("subject", type=str)
parser_read.set_defaults(func=read) parser_read.set_defaults(func=read)
args = parser.parse_args() args = parser.parse_args()

View File

@ -1,10 +1 @@
(import (import (builtins.fetchGit "https://github.com/edolstra/flake-compat") { src = ./.; }).shellNix
(
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

View File

@ -14,17 +14,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { pkgs ? import <nixpkgs> {}, blobs}:
lib,
blobs,
...
}:
{ pkgs.nixosTest {
name = "clamav"; name = "clamav";
nodes = { nodes = {
server = { pkgs, ... }: server = { config, pkgs, lib, ... }:
{ {
imports = [ imports = [
../default.nix ../default.nix
@ -33,8 +28,6 @@
virtualisation.memorySize = 1500; virtualisation.memorySize = 1500;
environment.systemPackages = with pkgs; [ netcat ];
services.rsyslogd = { services.rsyslogd = {
enable = true; enable = true;
defaultConfig = '' defaultConfig = ''
@ -90,9 +83,9 @@
"root/eicar.com.txt".text = "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; "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, config, pkgs, ... }: let
serverIP = nodes.server.networking.primaryIPAddress; serverIP = nodes.server.config.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress; clientIP = nodes.client.config.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" '' grep-ip = pkgs.writeScriptBin "grep-ip" ''
#!${pkgs.stdenv.shell} #!${pkgs.stdenv.shell}
echo grep '${clientIP}' "$@" >&2 echo grep '${clientIP}' "$@" >&2
@ -187,7 +180,8 @@
}; };
}; };
testScript = '' testScript = { nodes, ... }:
''
start_all() start_all()
server.wait_for_unit("multi-user.target") server.wait_for_unit("multi-user.target")
@ -195,10 +189,10 @@
# TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket. # TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket.
server.wait_until_succeeds( server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 ${nodes.server.pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
) )
server.wait_until_succeeds( server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 ${nodes.server.pkgs.netcat}/bin/nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]"
) )
client.execute("cp -p /etc/root/.* ~/") client.execute("cp -p /etc/root/.* ~/")
@ -228,7 +222,7 @@
with subtest("virus scan email"): with subtest("virus scan email"):
client.succeed( client.succeed(
'set +o pipefail; msmtp -a user2 user1@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2' 'set +o pipefail; msmtp -a user2 user1\@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2'
) )
server.succeed("journalctl -u rspamd | grep -i eicar") server.succeed("journalctl -u rspamd | grep -i eicar")
# give the mail server some time to process the mail # give the mail server some time to process the mail

View File

@ -14,19 +14,18 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { pkgs ? import <nixpkgs> {}, ...}:
name = "external";
pkgs.nixosTest {
name = "external";
nodes = { nodes = {
server = { pkgs, ... }: server = { config, pkgs, ... }:
{ {
imports = [ imports = [
../default.nix ../default.nix
./lib/config.nix ./lib/config.nix
]; ];
environment.systemPackages = with pkgs; [ netcat ];
virtualisation.memorySize = 1024; virtualisation.memorySize = 1024;
services.rsyslogd = { services.rsyslogd = {
@ -44,11 +43,6 @@
domains = [ "example.com" "example2.com" ]; domains = [ "example.com" "example2.com" ];
rewriteMessageId = true; rewriteMessageId = true;
dkimKeyBits = 1535; dkimKeyBits = 1535;
dmarcReporting = {
enable = true;
domain = "example.com";
organizationName = "ACME Corp";
};
loginAccounts = { loginAccounts = {
"user1@example.com" = { "user1@example.com" = {
@ -82,12 +76,14 @@
# special use depends on https://github.com/NixOS/nixpkgs/pull/93201 # 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"; enforced = "yes";
# fts-xapian warns when memory is low, which makes the test fail
memoryLimit = 100000;
}; };
}; };
}; };
client = { nodes, pkgs, ... }: let client = { nodes, config, pkgs, ... }: let
serverIP = nodes.server.networking.primaryIPAddress; serverIP = nodes.server.config.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress; clientIP = nodes.client.config.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" '' grep-ip = pkgs.writeScriptBin "grep-ip" ''
#!${pkgs.stdenv.shell} #!${pkgs.stdenv.shell}
echo grep '${clientIP}' "$@" >&2 echo grep '${clientIP}' "$@" >&2
@ -271,7 +267,7 @@
To: Chuck <chuck@example.com> To: Chuck <chuck@example.com>
Cc: Cc:
Bcc: Bcc:
Subject: This is a test Email from postmaster@example.com to chuck Subject: This is a test Email from postmaster\@example.com to chuck
Reply-To: Reply-To:
Hello Chuck, Hello Chuck,
@ -285,7 +281,7 @@
To: User1 <user1@example.com> To: User1 <user1@example.com>
Cc: Cc:
Bcc: Bcc:
Subject: This is a test Email from single-alias@example.com to user1 Subject: This is a test Email from single-alias\@example.com to user1
Reply-To: Reply-To:
Hello User1, Hello User1,
@ -300,7 +296,7 @@
To: Multi Alias <multi-alias@example.com> To: Multi Alias <multi-alias@example.com>
Cc: Cc:
Bcc: Bcc:
Subject: This is a test Email from user2@example.com to multi-alias Subject: This is a test Email from user2\@example.com to multi-alias
Reply-To: Reply-To:
Hello Multi Alias, Hello Multi Alias,
@ -340,7 +336,8 @@
}; };
}; };
testScript = '' testScript = { nodes, ... }:
''
start_all() start_all()
server.wait_for_unit("multi-user.target") server.wait_for_unit("multi-user.target")
@ -348,7 +345,7 @@
# TODO put this blocking into the systemd units? # TODO put this blocking into the systemd units?
server.wait_until_succeeds( server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 ${nodes.server.pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
) )
client.execute("cp -p /etc/root/.* ~/") client.execute("cp -p /etc/root/.* ~/")
@ -365,7 +362,7 @@
with subtest("submission port send mail"): with subtest("submission port send mail"):
# send email from user2 to user1 # send email from user2 to user1
client.succeed( client.succeed(
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2" "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email1 >&2"
) )
# give the mail server some time to process the mail # give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
@ -393,20 +390,20 @@
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from user2 to user1 # send email from user2 to user1
client.succeed( client.succeed(
"msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email2 >&2" "msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email2 >&2"
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("fetchmail --nosslcertck -v") client.succeed("fetchmail --nosslcertck -v")
client.succeed("cat ~/mail/* >&2") client.succeed("cat ~/mail/* >&2")
# make sure it is dkim signed # make sure it is dkim signed
client.succeed("grep DKIM-Signature: ~/mail/*") client.succeed("grep DKIM ~/mail/*")
with subtest("aliases"): with subtest("aliases"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from chuck to postmaster # send email from chuck to postmaster
client.succeed( client.succeed(
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on postmaster@example.com < /etc/root/email2 >&2" "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on postmaster\@example.com < /etc/root/email2 >&2"
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
@ -416,7 +413,7 @@
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from chuck to non exsitent account # send email from chuck to non exsitent account
client.succeed( client.succeed(
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol@example.com < /etc/root/email2 >&2" "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol\@example.com < /etc/root/email2 >&2"
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
@ -425,7 +422,7 @@
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from user1 to chuck # send email from user1 to chuck
client.succeed( client.succeed(
"msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck@example.com < /etc/root/email2 >&2" "msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck\@example.com < /etc/root/email2 >&2"
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 1 when no new mail # fetchmail returns EXIT_CODE 1 when no new mail
@ -436,7 +433,7 @@
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from single-alias to user1 # send email from single-alias to user1
client.succeed( client.succeed(
"msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email4 >&2" "msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email4 >&2"
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
@ -445,7 +442,7 @@
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from user1 to multi-alias (user{1,2}@example.com) # send email from user1 to multi-alias (user{1,2}@example.com)
client.succeed( client.succeed(
"msmtp -a test --tls=on --tls-certcheck=off --auth=on multi-alias@example.com < /etc/root/email5 >&2" "msmtp -a test --tls=on --tls-certcheck=off --auth=on multi-alias\@example.com < /etc/root/email5 >&2"
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
@ -456,7 +453,7 @@
client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc") client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc")
client.succeed( client.succeed(
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota@example.com < /etc/root/email2 >&2" "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota\@example.com < /etc/root/email2 >&2"
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
@ -465,23 +462,23 @@
with subtest("imap sieve junk trainer"): with subtest("imap sieve junk trainer"):
# send email from user2 to user1 # send email from user2 to user1
client.succeed( client.succeed(
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2" "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email1 >&2"
) )
# give the mail server some time to process the mail # give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
client.succeed("imap-mark-spam >&2") client.succeed("imap-mark-spam >&2")
server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-spam.sh >&2") server.wait_until_succeeds("journalctl -u dovecot2 | grep -i sa-learn-spam.sh >&2")
client.succeed("imap-mark-ham >&2") client.succeed("imap-mark-ham >&2")
server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-ham.sh >&2") server.wait_until_succeeds("journalctl -u dovecot2 | grep -i sa-learn-ham.sh >&2")
with subtest("full text search and indexation"): with subtest("full text search and indexation"):
# send 2 email from user2 to user1 # send 2 email from user2 to user1
client.succeed( client.succeed(
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email6 >&2" "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email6 >&2"
) )
client.succeed( client.succeed(
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email7 >&2" "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email7 >&2"
) )
# give the mail server some time to process the mail # give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
@ -491,23 +488,19 @@
# should fail because this folder is not indexed # should fail because this folder is not indexed
client.fail("search Junk a >&2") client.fail("search Junk a >&2")
# check that search really goes through the indexer # check that search really goes through the indexer
server.succeed("journalctl -u dovecot2 | grep 'fts-flatcurve(INBOX): Query ' >&2") server.succeed(
"journalctl -u dovecot2 | grep -E 'indexer-worker.* Done indexing .INBOX.' >&2"
)
# check that Junk is not indexed # check that Junk is not indexed
server.fail("journalctl -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2") server.fail("journalctl -u dovecot2 | grep 'indexer-worker' | grep -i 'JUNK' >&2")
with subtest("dmarc reporting"):
server.systemctl("start rspamd-dmarc-reporter.service")
with subtest("no warnings or errors"): with subtest("no warnings or errors"):
server.fail("journalctl -u postfix | grep -i error >&2") server.fail("journalctl -u postfix | grep -i error >&2")
server.fail("journalctl -u postfix | grep -i warning >&2") server.fail("journalctl -u postfix | grep -i warning >&2")
server.fail("journalctl -u dovecot2 | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2") server.fail("journalctl -u dovecot2 | grep -i error >&2")
# harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html # harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html
server.fail( server.fail(
"journalctl -u dovecot2 | \ "journalctl -u dovecot2 |grep -v 'Expunged message reappeared, giving a new UID'| grep -i warning >&2"
grep -v 'Expunged message reappeared, giving a new UID' | \
grep -v 'Time moved forwards' | \
grep -i warning >&2"
) )
''; '';
} }

View File

@ -14,10 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { pkgs ? import <nixpkgs> {}, ...}:
pkgs,
...
}:
let let
sendMail = pkgs.writeTextFile { sendMail = pkgs.writeTextFile {
@ -32,18 +29,17 @@ let
hashPassword = password: pkgs.runCommand hashPassword = password: pkgs.runCommand
"password-${password}-hashed" "password-${password}-hashed"
{ buildInputs = [ pkgs.mkpasswd ]; inherit password; } '' { buildInputs = [ pkgs.apacheHttpd ]; } ''
mkpasswd -sm bcrypt <<<"$password" > $out htpasswd -nbB "" "${password}" | cut -d: -f2 > $out
''; '';
hashedPasswordFile = hashPassword "my-password"; hashedPasswordFile = hashPassword "my-password";
passwordFile = pkgs.writeText "password" "my-password"; passwordFile = pkgs.writeText "password" "my-password";
in in
{ pkgs.nixosTest {
name = "internal"; name = "internal";
nodes = { nodes = {
machine = { pkgs, ... }: { machine = { config, pkgs, ... }: {
imports = [ imports = [
./../default.nix ./../default.nix
./lib/config.nix ./lib/config.nix
@ -54,17 +50,12 @@ in
environment.systemPackages = [ environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" '' (pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'') '')];
] ++ (with pkgs; [
curl
openssl
netcat
]);
mailserver = { mailserver = {
enable = true; enable = true;
fqdn = "mail.example.com"; fqdn = "mail.example.com";
domains = [ "example.com" "domain.com" ]; domains = [ "example.com" ];
localDnsResolver = false; localDnsResolver = false;
loginAccounts = { loginAccounts = {
@ -73,7 +64,6 @@ in
}; };
"user2@example.com" = { "user2@example.com" = {
hashedPasswordFile = hashedPasswordFile; hashedPasswordFile = hashedPasswordFile;
aliasesRegexp = [''/^user2.*@domain\.com$/''];
}; };
"send-only@example.com" = { "send-only@example.com" = {
hashedPasswordFile = hashPassword "send-only"; hashedPasswordFile = hashPassword "send-only";
@ -136,46 +126,6 @@ in
) )
) )
with subtest("regex email alias are received"):
# A mail sent to user2-regex-alias@domain.com is in the user2@example.com mailbox
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user2@example.com",
"--from-addr user1@example.com",
"--to-addr user2-regex-alias@domain.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
with subtest("user can send from regex email alias"):
# 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 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--smtp-username user2@example.com",
"--from-addr user2-regex-alias@domain.com",
"--to-addr user1@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
with subtest("vmail gid is set correctly"): with subtest("vmail gid is set correctly"):
machine.succeed("getent group vmail | grep 5000") machine.succeed("getent group vmail | grep 5000")
@ -183,22 +133,22 @@ in
machine.wait_for_open_port(25) machine.wait_for_open_port(25)
# TODO put this blocking into the systemd units # TODO put this blocking into the systemd units
machine.wait_until_succeeds( machine.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
) )
machine.succeed( machine.succeed(
"cat ${sendMail} | nc localhost 25 | grep -q '554 5.5.0 Error'" "cat ${sendMail} | ${pkgs.netcat-gnu}/bin/nc localhost 25 | grep -q 'This account cannot receive emails'"
) )
with subtest("rspamd controller serves web ui"): with subtest("rspamd controller serves web ui"):
machine.succeed( machine.succeed(
"set +o pipefail; curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q '<body>'" "set +o pipefail; ${pkgs.curl}/bin/curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q '<body>'"
) )
with subtest("imap port 143 is closed and imaps is serving SSL"): with subtest("imap port 143 is closed and imaps is serving SSL"):
machine.wait_for_closed_port(143) machine.wait_for_closed_port(143)
machine.wait_for_open_port(993) machine.wait_for_open_port(993)
machine.succeed( machine.succeed(
"echo | openssl s_client -connect localhost:993 | grep 'New, TLS'" "echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:993 | grep 'New, TLS'"
) )
''; '';
} }

View File

@ -1,218 +0,0 @@
let
bindPassword = "unsafegibberish";
alicePassword = "testalice";
bobPassword = "testbob";
in
{
name = "ldap";
nodes = {
machine = { pkgs, ... }: {
imports = [
./../default.nix
./lib/config.nix
];
virtualisation.memorySize = 1024;
services.openssh = {
enable = true;
settings.PermitRootLogin = "yes";
};
environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'')];
environment.etc.bind-password.text = bindPassword;
services.openldap = {
enable = true;
settings = {
children = {
"cn=schema".includes = [
"${pkgs.openldap}/etc/schema/core.ldif"
"${pkgs.openldap}/etc/schema/cosine.ldif"
"${pkgs.openldap}/etc/schema/inetorgperson.ldif"
"${pkgs.openldap}/etc/schema/nis.ldif"
];
"olcDatabase={1}mdb" = {
attrs = {
objectClass = [
"olcDatabaseConfig"
"olcMdbConfig"
];
olcDatabase = "{1}mdb";
olcDbDirectory = "/var/lib/openldap/example";
olcSuffix = "dc=example";
};
};
};
};
declarativeContents."dc=example" = ''
dn: dc=example
objectClass: domain
dc: example
dn: cn=mail,dc=example
objectClass: organizationalRole
objectClass: simpleSecurityObject
objectClass: top
cn: mail
userPassword: ${bindPassword}
dn: ou=users,dc=example
objectClass: organizationalUnit
ou: users
dn: cn=alice,ou=users,dc=example
objectClass: inetOrgPerson
cn: alice
sn: Foo
mail: alice@example.com
userPassword: ${alicePassword}
dn: cn=bob,ou=users,dc=example
objectClass: inetOrgPerson
cn: bob
sn: Bar
mail: bob@example.com
userPassword: ${bobPassword}
'';
};
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" ];
localDnsResolver = false;
ldap = {
enable = true;
uris = [
"ldap://"
];
bind = {
dn = "cn=mail,dc=example";
passwordFile = "/etc/bind-password";
};
searchBase = "ou=users,dc=example";
searchScope = "sub";
};
forwards = {
"bob_fw@example.com" = "bob@example.com";
};
vmailGroupName = "vmail";
vmailUID = 5000;
enableImap = false;
};
};
};
testScript = ''
import sys
import re
machine.start()
machine.wait_for_unit("multi-user.target")
# This function retrieves the ldap table file from a postconf
# command.
# A key lookup is achived and the returned value is compared
# to the expected value.
def test_lookup(postconf_cmdline, key, expected):
conf = machine.succeed(postconf_cmdline).rstrip()
ldap_table_path = re.match('.* =.*ldap:(.*)', conf).group(1)
value = machine.succeed(f"postmap -q {key} ldap:{ldap_table_path}").rstrip()
try:
assert value == expected
except AssertionError:
print(f"Expected {conf} lookup for key '{key}' to return '{expected}, but got '{value}'", file=sys.stderr)
raise
with subtest("Test postmap lookups"):
test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice@example.com")
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice@example.com")
test_lookup("postconf virtual_mailbox_maps", "bob@example.com", "bob@example.com")
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "bob@example.com", "bob@example.com")
with subtest("Test doveadm lookups"):
machine.succeed("doveadm user -u alice@example.com")
machine.succeed("doveadm user -u bob@example.com")
with subtest("Files containing secrets are only readable by root"):
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"):
machine.fail(" ".join([
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username alice@example.com",
"--imap-host localhost",
"--imap-username bob@example.com",
"--from-addr bob@example.com",
"--to-addr aliceb@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${bobPassword}')",
"--ignore-dkim-spf"
]))
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice@example.com'")
with subtest("Test mail delivery"):
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username alice@example.com",
"--imap-host localhost",
"--imap-username bob@example.com",
"--from-addr alice@example.com",
"--to-addr bob@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${bobPassword}')",
"--ignore-dkim-spf"
]))
with subtest("Test mail forwarding works"):
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username alice@example.com",
"--imap-host localhost",
"--imap-username bob@example.com",
"--from-addr alice@example.com",
"--to-addr bob_fw@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${bobPassword}')",
"--ignore-dkim-spf"
]))
with subtest("Test cannot send mail from forwarded address"):
machine.fail(" ".join([
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username bob@example.com",
"--imap-host localhost",
"--imap-username alice@example.com",
"--from-addr bob_fw@example.com",
"--to-addr alice@example.com",
"--src-password-file <(echo '${bobPassword}')",
"--dst-password-file <(echo '${alicePassword}')",
"--ignore-dkim-spf"
]))
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob@example.com'")
'';
}

View File

@ -1,3 +1,3 @@
{ {
security.dhparams.defaultBitSize = 2048; # minimum size required by dovecot security.dhparams.defaultBitSize = 1024; # minimum size required by dovecot
} }

View File

@ -14,14 +14,18 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ import <nixpkgs/nixos/tests/make-test.nix> {
name = "minimal";
nodes.machine = { machine =
imports = [ ./../default.nix ]; { config, pkgs, ... }:
{
imports = [
./../default.nix
];
}; };
testScript = '' testScript =
machine.wait_for_unit("multi-user.target"); ''
$machine->waitForUnit("multi-user.target");
''; '';
} }

View File

@ -1,23 +1,19 @@
# This tests is used to test features requiring several mail domains. # This tests is used to test features requiring several mail domains.
{ { pkgs ? import <nixpkgs> {}, ...}:
pkgs,
...
}:
let let
hashPassword = password: pkgs.runCommand hashPassword = password: pkgs.runCommand
"password-${password}-hashed" "password-${password}-hashed"
{ buildInputs = [ pkgs.mkpasswd ]; inherit password; } { buildInputs = [ pkgs.apacheHttpd ]; }
'' ''
mkpasswd -sm bcrypt <<<"$password" > $out htpasswd -nbB "" "${password}" | cut -d: -f2 > $out
''; '';
password = pkgs.writeText "password" "password"; password = pkgs.writeText "password" "password";
domainGenerator = domain: { pkgs, ... }: { domainGenerator = domain: { config, pkgs, ... }: {
imports = [../default.nix]; imports = [../default.nix];
environment.systemPackages = with pkgs; [ netcat ];
virtualisation.memorySize = 1024; virtualisation.memorySize = 1024;
mailserver = { mailserver = {
enable = true; enable = true;
@ -34,15 +30,17 @@ let
}; };
services.dnsmasq = { services.dnsmasq = {
enable = true; enable = true;
settings.mx-host = [ "domain1.com,domain1,10" "domain2.com,domain2,10" ]; extraConfig = ''
mx-host=domain1.com,domain1,10
mx-host=domain2.com,domain2,10
'';
}; };
}; };
in in
{ pkgs.nixosTest {
name = "multiple"; name = "multiple";
nodes = { nodes = {
domain1 = {...}: { domain1 = {...}: {
imports = [ imports = [
@ -55,7 +53,7 @@ in
}; };
}; };
domain2 = domainGenerator "domain2.com"; domain2 = domainGenerator "domain2.com";
client = { pkgs, ... }: { client = { config, pkgs, ... }: {
environment.systemPackages = [ environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" '' (pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
@ -70,10 +68,10 @@ in
# TODO put this blocking into the systemd units? # TODO put this blocking into the systemd units?
domain1.wait_until_succeeds( domain1.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
) )
domain2.wait_until_succeeds( domain2.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
) )
# user@domain1.com sends a mail to user@domain2.com # user@domain1.com sends a mail to user@domain2.com

7
update.sh Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
sed -i -e "s/v[0-9]\+\.[0-9]\+\.[0-9]\+/$1/g" README.md
HASH=$(nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/v2.3.0/nixos-mailserver-$1.tar.gz" --unpack)
sed -i -e "s/sha256 = \"[0-9a-z]\{52\}\"/sha256 = \"$HASH\"/g" README.md