mailserver: update to e33fbde and add test

This commit is contained in:
ibizaman 2026-05-23 15:03:19 +02:00
parent 3489b4434a
commit fe5a577d93
6 changed files with 368 additions and 36 deletions

View file

@ -16,6 +16,19 @@ Template:
# Upcoming Release
## Breaking Changes
- Updated simple-nixos-mailserver. Update all options following this pattern:
```diff
- mailserver.mailboxes.<name>.specialUse = "value"
+ mailserver.mailboxes.<name>.special_use = "\\value"
```
Note that simple-nixos-mailserver wanted to upgrade the location of the storage path
to include the ldap UID instead of the email but I kept the email address.
This means there is no migration to do.
# v0.8.0
## Breaking Changes

View file

@ -5009,6 +5009,9 @@
"services-mailserver-options-shb.mailserver.ssl.systemdService": [
"services-mailserver.html#services-mailserver-options-shb.mailserver.ssl.systemdService"
],
"services-mailserver-options-shb.mailserver.stateVersion": [
"services-mailserver.html#services-mailserver-options-shb.mailserver.stateVersion"
],
"services-mailserver-options-shb.mailserver.subdomain": [
"services-mailserver.html#services-mailserver-options-shb.mailserver.subdomain"
],

View file

@ -403,6 +403,7 @@
// (vm_test "homepage" ./test/services/homepage.nix)
// (vm_test "jellyfin" ./test/services/jellyfin.nix)
// (vm_test "karakeep" ./test/services/karakeep.nix)
// (vm_test "mailserver" ./test/services/mailserver.nix)
// (vm_test "nextcloud" ./test/services/nextcloud.nix)
// (vm_test "open-webui" ./test/services/open-webui.nix)
// (vm_test "paperless" ./test/services/paperless.nix)

View file

@ -14,7 +14,7 @@ in
builtins.fetchGit {
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver.git";
ref = "master";
rev = "7d433bf89882f61621f95082e90a4ab91eb0bdd3";
rev = "e33fbde199eaad513ef5d0746db19d5878150232";
}
+ "/default.nix"
)
@ -105,7 +105,7 @@ in
description = ''
Accounts to sync emails from using IMAP.
Emails will be stored under `''${config.mailserver.mailDirectory}/''${name}/''${username}`
Emails will be stored under `''${config.mailserver.storage.path}/''${name}/''${username}`
'';
type = lib.types.attrsOf (
lib.types.submodule {
@ -137,7 +137,7 @@ in
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = config.mailserver.vmailUserName;
owner = config.mailserver.storage.owner;
restartUnits = [ "mbsync.service" ];
};
};
@ -267,7 +267,7 @@ in
Enabling this app will create a new LDAP configuration or update one that exists with
the given host.
'';
default = { };
default = null;
type = lib.types.nullOr (
lib.types.submodule {
options = {
@ -333,6 +333,26 @@ in
);
};
stateVersion = lib.mkOption {
type = lib.types.ints.positive;
# SHB started at stateVersion 3.
default = 4;
description = ''
Tracking stateful version changes as an incrementing number.
When a new release comes out we may require manual migration steps to
be completed, before the new version can be put into production.
If your `stateVersion` is too low one or multiple assertions may
trigger to give you instructions on what migrations steps are required
to continue. Increase the `stateVersion` as instructed by the assertion
message.
See https://nixos-mailserver.readthedocs.io/en/latest/release-notes.html
and https://nixos-mailserver.readthedocs.io/en/latest/migrations.html
'';
};
backup = lib.mkOption {
description = ''
Backup emails, index and sieve.
@ -340,16 +360,16 @@ in
default = { };
type = lib.types.submodule {
options = shb.contracts.backup.mkRequester {
user = config.mailserver.vmailUserName;
user = config.mailserver.storage.owner;
sourceDirectories = builtins.filter (x: x != null) [
config.mailserver.indexDir
config.mailserver.mailDirectory
config.mailserver.storage.path
config.mailserver.sieveDirectory
];
sourceDirectoriesText = ''
[
config.mailserver.indexDir
config.mailserver.mailDirectory
config.mailserver.storage.path
config.mailserver.sieveDirectory
]
'';
@ -367,7 +387,7 @@ in
user = config.services.rspamd.user;
userText = "services.rspamd.user";
sourceDirectories = builtins.filter (x: x != null) [
config.mailserver.dkimKeyDirectory
config.mailserver.dkim.keyDirectory
];
sourceDirectoriesText = ''
[
@ -385,14 +405,14 @@ in
type = lib.types.attrsOf lib.types.str;
default = {
index = config.mailserver.indexDir;
mail = config.mailserver.mailDirectory;
mail = config.mailserver.storage.path;
sieve = config.mailserver.sieveDirectory;
dkim = config.mailserver.dkimKeyDirectory;
};
defaultText = lib.literalExpression ''
{
index = config.mailserver.indexDir;
mail = config.mailserver.mailDirectory;
mail = config.mailserver.storage.path;
sieve = config.mailserver.sieveDirectory;
dkim = config.mailserver.dkimKeyDirectory;
}
@ -417,8 +437,8 @@ in
(lib.mkIf cfg.enable {
mailserver = {
enable = true;
stateVersion = 3;
fqdn = "${cfg.subdomain}.${cfg.domain}";
inherit (cfg) stateVersion;
domains = [ cfg.domain ];
localDnsResolver = false;
@ -433,18 +453,22 @@ in
# Using / is needed for iOS mail.
# Both following options are used to organize subfolders in subdirectories.
hierarchySeparator = "/";
useFsLayout = true;
storage = {
directoryLayout = "fs";
};
};
services.postfix.config = {
services.postfix.settings.main = {
smtpd_tls_security_level = lib.mkForce "encrypt";
};
# Is probably needed for iOS mail.
services.dovecot2.extraConfig = ''
ssl_min_protocol = TLSv1.2
ssl_cipher_list = HIGH:!aNULL:!MD5
'';
services.dovecot2.settings = {
ssl_min_protocol = "TLSv1.2";
# This conflicts with the default setting which seems much better than this one.
# So I'm leaving the default settings and we'll see if something breaks.
# ssl_cipher_list = "HIGH:!aNULL:!MD5";
};
services.nginx = {
enable = true;
@ -482,6 +506,8 @@ in
in
{
forceSSL = true; # Redirect HTTP → HTTPS
sslCertificate = cfg.ssl.paths.cert;
sslCertificateKey = cfg.ssl.paths.key;
root = "/var/www"; # Dummy root
locations."/.well-known/autoconfig/mail/" = {
alias = "${announce}/";
@ -501,6 +527,8 @@ in
in
{
forceSSL = true; # Redirect HTTP → HTTPS
sslCertificate = cfg.ssl.paths.cert;
sslCertificateKey = cfg.ssl.paths.key;
root = "/var/www"; # Dummy root
locations."/" = {
alias = "${landingPage}/";
@ -548,37 +576,44 @@ in
uris = [
"ldap://${cfg.ldap.host}:${toString cfg.ldap.port}"
];
searchBase = "ou=people,${cfg.ldap.dcdomain}";
searchScope = "sub";
base = "ou=people,${cfg.ldap.dcdomain}";
scope = "sub";
bind = {
dn = "uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain}";
passwordFile = cfg.ldap.adminPassword.result.path;
};
# Note that nixos simple mailserver sets auth_bind=yes
# which means authentication binds are used.
# https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_bind/#authentication-ldap-bind
dovecot =
let
filter = "(&(objectClass=inetOrgPerson)(mail=%{user})(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))";
in
{
passAttrs = "user=user";
passFilter = filter;
userAttrs = lib.concatStringsSep "," [
"=home=${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u"
# "mail=maildir:${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u/mail"
"uid=${config.mailserver.vmailUserName}"
"gid=${config.mailserver.vmailGroupName}"
];
userFilter = filter;
};
# username needs to be set to mail so postfix maps correctly the mail address.
# Otherwise we get error messages when sending
# Sender address rejected: not owned by user
attributes = {
username = "mail";
};
postfix = {
filter = "(&(objectClass=inetOrgPerson)(mail=%s)(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))";
mailAttribute = "mail";
uidAttribute = "mail";
};
};
};
services.dovecot2.settings = {
"userdb ldap" = {
fields.home = lib.mkForce "${config.mailserver.storage.path}/${cfg.ldap.account}/%{user}";
fields.mail_index_path = lib.mkForce "${config.mailserver.storage.path}/${cfg.ldap.account}/%{user}";
};
"passdb ldap" = {
# LLDAP does not understand ldap user password setup.
fields.password = lib.mkForce null;
# We use bind DN auth.
# https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_bind/#authentication-ldap-bind
bind = true;
};
};
})
(lib.mkIf (cfg.enable && cfg.imapSync != null) {
systemd.services.mbsync =
@ -601,11 +636,11 @@ in
Account ${name}
MaildirStore ${name}-local
INBOX ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/
INBOX ${config.mailserver.storage.path}/${name}/${acct.username}/mail/
# Maps subfolders on far side to actual subfolders on disk.
# The other option is Maildir++ but then the mailserver.hierarchySeparator must be set to a dot '.'
SubFolders Verbatim
Path ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/
Path ${config.mailserver.storage.path}/${name}/${acct.username}/mail/
Channel ${name}-main
Far :${name}-remote:
@ -672,7 +707,7 @@ in
description = "Sync mailbox";
serviceConfig = {
Type = "oneshot";
User = config.mailserver.vmailUserName;
User = config.mailserver.storage.owner;
};
script =
let
@ -689,8 +724,8 @@ in
name: acct:
# The equal sign makes sure parent directories have the corret user and group too.
[
"d '${config.mailserver.mailDirectory}/${name}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -"
"d '${config.mailserver.mailDirectory}/${name}/${acct.username}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -"
"d '${config.mailserver.storage.path}/${name}' 0750 ${config.mailserver.storage.owner} ${config.mailserver.storage.group} - -"
"d '${config.mailserver.storage.path}/${name}/${acct.username}' 0750 ${config.mailserver.storage.owner} ${config.mailserver.storage.group} - -"
];
in
lib.flatten (lib.mapAttrsToList mkAccount cfg.imapSync.accounts);

View file

@ -128,6 +128,8 @@ in
}
```
With the example above, the mails are stored under `/var/vmail/fastmail/<email address>`.
### Secrets {#services-mailserver-usage-secrets}
Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.

View file

@ -0,0 +1,278 @@
{
lib,
pkgs,
shb,
...
}:
let
domain = "example.com";
subdomain = "imap";
fqdn = "${subdomain}.${domain}";
trySendMail = pkgs.writeShellApplication {
name = "trySendMail";
runtimeInputs = [
pkgs.swaks
];
text = ''
USER="''${1:-}"
PASSWORD="''${2:-}"
if [ -z "$USER" ]; then
echo "No user given"
exit 1
fi
if [ -z "$PASSWORD" ]; then
swaks \
--server ${fqdn} \
--port 465 \
--tls-on-connect \
--from "$USER" \
--to "$USER"
else
swaks \
--server ${fqdn} \
--port 465 \
--tls-on-connect \
--auth LOGIN \
--auth-user "$USER" \
--auth-password "$PASSWORD" \
--from "$USER" \
--to "$USER"
fi
'';
};
tryRcvMail = pkgs.writeShellApplication {
name = "tryRcvMail";
runtimeInputs = [
pkgs.curl
];
text = ''
USER="''${1:-}"
PASSWORD="''${2:-}"
if [ -z "$USER" ]; then
echo "No user given"
exit 1
fi
if [ -z "$PASSWORD" ]; then
echo "No password given"
exit 1
fi
curl -s --url "imaps://imap.example.com/INBOX;UID=1" \
--user "$USER:$PASSWORD"
'';
};
in
{
basic = shb.test.runNixOSTest {
name = "mailserver_basic";
# Connect to the running VM started with driverInteractive:
# ssh-keygen -R 'vsock-mux//run/user/1000/tmpcf3hs4yp/server_host.socket'; ssh -o User=root vsock-mux//run/user/1000/tmpcf3hs4yp/server_host.socket
interactive.sshBackdoor.enable = true;
nodes.server =
{ config, ... }:
{
imports = [
../../modules/blocks/ssl.nix
../../modules/services/mailserver.nix
];
networking.hosts = {
"127.0.0.1" = [ fqdn ];
};
shb.certs.cas.selfsigned.myca = {
name = "My CA";
};
shb.certs.certs.selfsigned = {
${domain} = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "*.${domain}";
group = "nginx";
};
};
shb.mailserver = {
enable = true;
inherit subdomain domain;
stateVersion = 4;
ssl = config.shb.certs.certs.selfsigned.${domain};
# imapSync = {
# # syncTimer = "10s";
# # debug = false;
# # accounts.fastmail = {
# # host = "imap.fastmail.com";
# # port = 993;
# # username = email;
# # password.result = config.shb.sops.secret."mailserver/imap/fastmail/password".result;
# # mapSpecialJunk = "Spam";
# # };
# };
# smtpRelay = {
# host = "smtp.fastmail.com";
# port = 587;
# username = email;
# password.result = config.shb.sops.secret."mailserver/smtp/fastmail/password".result;
# };
};
mailserver.mailboxes = {
Junk = {
auto = "subscribe";
special_use = "\\Junk";
};
};
environment.systemPackages = [
pkgs.openssl
pkgs.swaks
trySendMail
tryRcvMail
];
specialisation = {
ldap.configuration =
{ config, ... }:
{
imports = [
../../modules/blocks/hardcodedsecret.nix
../../modules/blocks/lldap.nix
];
networking.hosts = {
"127.0.0.1" = [ "ldap.${domain}" ];
};
environment.systemPackages = [
pkgs.openldap
];
shb.hardcodedsecret.ldapUserPassword = {
request = config.shb.lldap.ldapUserPassword.request;
settings.content = "ldapUserPassword";
};
shb.hardcodedsecret.jwtSecret = {
request = config.shb.lldap.jwtSecret.request;
settings.content = "jwtSecrets";
};
shb.hardcodedsecret.alice = {
request = config.shb.lldap.ensureUsers.alice.password.request;
settings.content = "alicePassword";
};
shb.hardcodedsecret.charlie = {
request = config.shb.lldap.ensureUsers.charlie.password.request;
settings.content = "charliePassword";
};
shb.lldap = {
enable = true;
inherit domain;
subdomain = "ldap";
ldapPort = 3890;
webUIListenPort = 17170;
dcdomain = "dc=example,dc=com";
ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;
jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;
debug = true;
ensureUsers = {
alice = {
email = "alice@example.com";
groups = [ "user_group" ];
password.result = config.shb.hardcodedsecret.alice.result;
};
charlie = {
email = "charlie@example.com";
groups = [ "other_group" ];
password.result = config.shb.hardcodedsecret.charlie.result;
};
};
ensureGroups = {
user_group = { };
admin_group = { };
other_group = { };
};
};
shb.mailserver = {
ldap = {
enable = true;
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain;
adminName = "admin";
adminPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;
account = "fastmail";
userGroup = "user_group";
};
};
};
};
};
testScript =
{ nodes, ... }:
let
specialisations = "${nodes.server.system.build.toplevel}/specialisation";
switch = name: "${specialisations}/${name}/bin/switch-to-configuration test";
ldapSearch =
let
config = nodes.server.specialisation.ldap.configuration;
in
lib.concatStringsSep " " [
"ldapsearch"
"-H ldap://ldap.example.com:${toString config.shb.lldap.ldapPort}"
];
ldapSearchAdmin =
let
config = nodes.server.specialisation.ldap.configuration;
in
lib.concatStringsSep " " [
"ldapsearch"
"-H ldap://ldap.example.com:${toString config.shb.lldap.ldapPort}"
"-D uid=admin,ou=people,${config.shb.lldap.dcdomain}"
"-w ${config.shb.hardcodedsecret.ldapUserPassword.settings.content}"
];
in
''
server.wait_for_unit("multi-user.target")
server.wait_for_unit("dovecot")
with subtest("ldap"):
server.succeed("${switch "ldap"}")
server.wait_for_unit("lldap")
print(server.succeed("${ldapSearch} -LLL -D uid=alice,ou=people,dc=example,dc=com -w alicePassword -b cn=user_group,ou=groups,dc=example,dc=com | grep uniquemember | grep alice"))
print(server.succeed("${ldapSearchAdmin} -LLL -b cn=user_group,ou=groups,dc=example,dc=com | grep uniquemember | grep alice"))
server.wait_for_unit("dovecot")
print(server.fail("doveadm auth test auser@example.com apassword"))
print(server.fail("doveadm auth test charlie@example.com charliePassword"))
print(server.succeed("doveadm auth test alice@example.com alicePassword"))
server.wait_for_unit("postfix")
print(server.fail("trySendMail alice@example.com"))
print(server.fail("trySendMail auser@example.com apassword"))
print(server.fail("trySendMail charlie@example.com charliePassword"))
print(server.succeed("trySendMail alice@example.com alicePassword"))
print(server.fail("tryRcvMail charlie@example.com charliePassword"))
print(server.succeed("tryRcvMail alice@example.com alicePassword"))
print(server.succeed("find /var/vmail"))
print(server.succeed("find /var/vmail/fastmail/alice@example.com"))
'';
};
}