Compare commits

..

17 commits
main ... quota

Author SHA1 Message Date
1e75d07d56 foobar 2024-12-28 01:42:00 +01:00
c90e09d125 Add configuration option to alias entire domains and respective tests 2024-12-28 00:40:32 +01:00
48bd6b8981 tests: Add various tests for alias functionality 2024-12-28 00:35:18 +01:00
fb834ec7ee tests: Add basic tests for sending/receiving mails and verification of headers 2024-12-28 00:34:56 +01:00
b630755ea8 tests: common: Add lib.nix containing various helpers for testing mailsystem behaviour 2024-12-28 00:23:33 +01:00
a033432bb8 flake.nix: Add and configure treefmt-nix for nix fmt 2024-12-28 00:23:33 +01:00
b81e8f00bb flake.nix: Rename pre-commit-hooks-nix into git-hooks-nix
Cachix has renamed their project.
2024-12-28 00:23:33 +01:00
5c280dcedb tests: minimal: Configure and verify vmail user/group/uid/gid 2024-12-28 00:23:33 +01:00
9687dbaae1 Add minimal (internal) tests 2024-12-28 00:23:33 +01:00
a592881b8b mailsystem: Add option to use selfsigned certificates in preparation for testing 2024-12-28 00:23:33 +01:00
6d6b856bee flake.nix: Actually expose mailsystem as flake module 2024-12-28 00:23:33 +01:00
3d0e0dd95c mailsystem: Add configuration for roundcube as webmail interface 2024-12-28 00:23:33 +01:00
5583676384 mailsystem: rspamd: Add configuration options to make rspamd's web ui accessible 2024-12-28 00:23:33 +01:00
0ce3ecae52 mailsystem: dovecot: Autolearn ham/spam when moving mails 2024-12-28 00:23:33 +01:00
d35763a8a2 mailsystem: Configure rspamd as spam filter 2024-12-28 00:23:33 +01:00
b7fac23bd1 mailsystem: Add basic postfix configuration 2024-12-28 00:23:33 +01:00
bc01d4d2d0 mailsystem: Add minimal dovecot configuration 2024-12-28 00:21:53 +01:00
23 changed files with 267 additions and 1751 deletions

View file

@ -1,17 +0,0 @@
name: "Test"
on:
pull_request:
push:
branches:
- main
schedule:
- cron: '42 7 * * *'
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v30
- name: Execute unit tests
run: nix flake check

61
flake.lock generated
View file

@ -1,20 +1,5 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1748047550,
"narHash": "sha256-t0qLLqb4C1rdtiY8IFRH5KIapTY/n3Lqt57AmxEv9mk=",
"owner": "ipetkov",
"repo": "crane",
"rev": "b718a78696060df6280196a6f992d04c87a16aef",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
@ -38,11 +23,11 @@
]
},
"locked": {
"lastModified": 1743550720,
"narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=",
"lastModified": 1730504689,
"narHash": "sha256-hgmguH29K2fvs9szpq2r3pz2/8cJd2LPS+b4tfNFCwE=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "c621e8422220273271f52058f618c94e405bb0f5",
"rev": "506278e768c2a08bec68eb62932193e341f55c90",
"type": "github"
},
"original": {
@ -57,14 +42,15 @@
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1747372754,
"narHash": "sha256-2Y53NGIX2vxfie1rOW0Qb86vjRZ7ngizoo+bnXU9D9k=",
"lastModified": 1733318908,
"narHash": "sha256-SVQVsbafSM1dJ4fpgyBqLZ+Lft+jcQuMtEL3lQWx2Sk=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "80479b6ec16fefd9c1db3ea13aeb038c60530f46",
"rev": "6f4e2a2112050951a314d2733a994fbab94864c6",
"type": "github"
},
"original": {
@ -96,23 +82,38 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1748162331,
"narHash": "sha256-rqc2RKYTxP3tbjA+PB3VMRQNnjesrT0pEofXQTrMsS8=",
"lastModified": 1731755305,
"narHash": "sha256-v5P3dk5JdiT+4x69ZaB18B8+Rcu3TIOrcdG4uEX7WZ8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7c43f080a7f28b2774f3b3f43234ca11661bf334",
"rev": "057f63b6dc1a2c67301286152eb5af20747a9cb4",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"ref": "nixos-24.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1730741070,
"narHash": "sha256-edm8WG19kWozJ/GqyYx2VjW99EdhjKwbY3ZwdlPAAlo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d063c1dd113c91ab27959ba540c0d9753409edf3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"flake-parts": "flake-parts",
"git-hooks-nix": "git-hooks-nix",
"nixpkgs": "nixpkgs",
@ -126,11 +127,11 @@
]
},
"locked": {
"lastModified": 1748243702,
"narHash": "sha256-9YzfeN8CB6SzNPyPm2XjRRqSixDopTapaRsnTpXUEY8=",
"lastModified": 1733440889,
"narHash": "sha256-qKL3vjO+IXFQ0nTinFDqNq/sbbnnS5bMI1y0xX215fU=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "1f3f7b784643d488ba4bf315638b2b0a4c5fb007",
"rev": "50862ba6a8a0255b87377b9d2d4565e96f29b410",
"type": "github"
},
"original": {

View file

@ -2,12 +2,11 @@
description = "An opinionated Nixos Mailsystem";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
flake-parts.url = "github:hercules-ci/flake-parts";
flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
treefmt-nix.url = "github:numtide/treefmt-nix";
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
crane.url = "github:ipetkov/crane";
git-hooks-nix.url = "github:cachix/git-hooks.nix";
git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs";
};
@ -24,7 +23,6 @@
"aarch64-linux"
];
imports = [
flake-parts.flakeModules.easyOverlay
inputs.treefmt-nix.flakeModule
inputs.git-hooks-nix.flakeModule
];
@ -36,12 +34,9 @@
pkgs,
system,
...
}: let
craneLib = inputs.crane.mkLib pkgs;
pkgs = nixpkgs.legacyPackages.${system}.extend self.overlays.default;
in {
}: {
checks = let
tests = ["internal" "basic" "aliases" "rspamd"];
tests = ["internal" "basic" "aliases"];
genTest = testName: {
"name" = testName;
"value" = import (./tests + "/${testName}.nix") {inherit pkgs;};
@ -49,20 +44,9 @@
in
pkgs.lib.listToAttrs (map genTest tests);
packages = rec {
default = mailnix;
mailnix = craneLib.buildPackage {
src = craneLib.cleanCargoSource ./pkgs/mailnix;
};
};
overlayAttrs = {
mailnix = config.packages.mailnix;
};
devShells.default = craneLib.devShell {
devShells.default = pkgs.mkShell {
packages = with pkgs; [
self'.formatter.outPath # Add all formatters to environment
mailnix
];
shellHook = ''
${config.pre-commit.installationScript}
@ -75,9 +59,7 @@
treefmt = {
programs = {
actionlint.enable = true;
alejandra.enable = true;
rustfmt.enable = true;
};
settings.global.excludes = [
".envrc"
@ -86,7 +68,7 @@
};
};
flake.nixosModules = rec {
flake.flakeModules = rec {
default = mailsystem;
mailsystem = import ./mailsystem;
};

View file

@ -1,8 +1,4 @@
{
config,
pkgs,
...
}: let
{config, ...}: let
cfg = config.mailsystem;
in rec {
certificateDirectory = "/var/certs";
@ -21,17 +17,6 @@ in rec {
then ["acme-finished-${cfg.fqdn}.target"]
else ["mailsystem-selfsigned-certificate.service"];
mailnixCmd = let
mailnixCfgFile = pkgs.writeText "mailnix-public.json" (builtins.toJSON {
inherit (cfg) accounts domains;
aliases = cfg.virtualAliases;
});
extraCfgFile =
if (cfg.extraSettingsFile != null)
then cfg.extraSettingsFile
else "";
in "${pkgs.mailnix}/bin/mailnix ${extraCfgFile} ${mailnixCfgFile}";
dovecotDynamicStateDir = "/var/lib/dovecot";
dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd";

View file

@ -92,6 +92,27 @@ in {
'';
};
aliases = lib.mkOption {
type = with lib.types; listOf types.str;
example = ["abuse@example.com" "postmaster@example.com"];
default = [];
description = ''
A list of aliases of this login account.
Note: Use list entries like "@example.com" to create a catchAll
that allows sending from all email addresses in these domain.
'';
};
quota = lib.mkOption {
type = with lib.types; nullOr types.str;
default = null;
example = "2G";
description = ''
Sets quota for the this login account. The size has to be suffixed with `k/M/G/T`.
Not setting a quota results in a standard quota of `100G`.
'';
};
isSystemUser = lib.mkOption {
type = lib.types.bool;
default = false;
@ -101,17 +122,7 @@ in {
account will be rejected.
'';
};
rejectMessage = lib.mkOption {
type = lib.types.str;
default = "This account cannot receive emails.";
description = ''
The message that will be returned to the sender when an email is
sent to a system account.
'';
};
};
config.name = lib.mkDefault name;
}));
example = {
@ -122,102 +133,54 @@ in {
hashedPassword = "$6$oE0ZNv2n7Vk9gOf$9xcZWCCLGdMflIfuA0vR1Q1Xblw6RZqPrP94mEit2/81/7AKj2bqUai5yPyWE.QYPyv6wLMHZvjw3Rlg7yTCD/";
};
};
description = "All available accounts for the mailsystem.";
description = "All available login account for the mailsystem.";
default = {};
};
virtualAliases = lib.mkOption {
type = let
isAccount = value: builtins.elem value (builtins.attrNames cfg.accounts);
isDomain = value: !(lib.hasInfix "@" value) && (builtins.elem value cfg.domains);
account = lib.mkOptionType {
name = "Mail Account";
check = isAccount;
virtualDomainAliases = lib.mkOption {
type = with lib.types; attrsOf str;
example = {
"@aliasdomain.com" = "@domain.com";
};
accountOrDomain = lib.mkOptionType {
name = "Mail Account or Domain";
check = value: (isAccount value) || (isDomain value);
description = ''
Virtual aliasing of domains. A virtual alias `"@aliasdomain.com" = "@domain.com"`
means that all mail directed at `aliasdomain.com` are forwarded to `domain.com`.
This also entails, that any account or alias of `domain.com` is partially valid
for `aliasdomain.com`. For example, `user@domain.com` can receive mails at
`user@aliasdomain.com`. However, if `user@domain.com` shall be able to dispatch
mails using `user@aliasdomain.com`, an explicit alias needs to be configured.
'';
default = {};
};
extraVirtualAliases = lib.mkOption {
type = let
account = lib.mkOptionType {
name = "Login Account";
check = account: builtins.elem account (builtins.attrNames cfg.accounts);
};
in
with lib.types; attrsOf (either (nonEmptyListOf account) accountOrDomain);
with lib.types; attrsOf (either account (nonEmptyListOf account));
example = {
"info@example.com" = "user1@example.com";
"postmaster@example.com" = "user1@example.com";
"abuse@example.com" = "user1@example.com";
"multi@example.com" = ["user1@example.com" "user2@example.com"];
"aliasdomain.com" = "domain.com";
};
description = ''
Virtual account and domain aliases. A virtual alias means, that all mail directed
at a given target are forwarded to the specified other destinations, too.
For account aliases, this means that, e.g., `"user1@example.com"` receives all mail
sent to `"info@example.com"`. In addition, `"user1@example.com"` is also able to
impersonate `"info@example.com"` when sending mails. It is also possible to create
an alias for multiple accounts. In this example, all mails for `"multi@example.com"`
will be forwarded to both
`"user1@example.com"` and `"user2@example.com"`.
For domain aliases, this means that all mails directed at an aliased domain, e.g.,
`"aliasdomain.com"` are forwarded to `"domain.com"` This also entails, that any
account or alias of `"domain.com"` receives mails directed at `"aliasdomain.com"`.
However, if `"user@domain.com"` shall be able to send mails using
`"user@aliasdomain.com"`, an explicit alias needs to be configured.
Virtual Aliases. A virtual alias `"info@example.com" = "user1@example.com"` means that
all mail to `info@example.com` is forwarded to `user1@example.com`. Note
that it is expected that `postmaster@example.com` and `abuse@example.com` is
forwarded to some valid email address. (Alternatively you can create login
accounts for `postmaster` and (or) `abuse`). Furthermore, it also allows
the user `user1@example.com` to send emails as `info@example.com`.
It's also possible to create an alias for multiple accounts. In this
example all mails for `multi@example.com` will be forwarded to both
`user1@example.com` and `user2@example.com`.
'';
default = {};
};
dkimSettings = lib.mkOption {
type = with lib.types;
attrsOf (listOf (submodule {
options = {
selector = lib.mkOption {
type = lib.types.str;
example = "mail";
description = "DKIM Selector";
};
keyFile = lib.mkOption {
type = lib.types.path;
example = "/run/secrets/dkim/example.com.mail.key";
description = ''
Path to DKIM private-key-file. A public-private-pair can be generated as follows:
```
nix-shell -p rspamd --run 'rspamadm dkim_keygen -s "selector" -t ed25519 -d example.com
nix-shell -p rspamd --run 'rspamadm dkim_keygen -s "selector" -b 2048 -d example.com
```
'';
};
};
}));
example = {
"example.com" = [
{
selector = "mail";
keyFile = "/run/secrets/dkim/example.com.mail.key";
}
];
};
description = ''
Per-domain DKIM configuration.
This option allows to optionally set one or more DKIM private keys
and their respective selectors for each domain individually.
'';
default = {};
};
extraSettingsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = ''
YAML file to merge into the mailsystem configuration at runtime.
This can be used to store secrets, and, more importantly, keep your email
addresses out of the hands of spammers. This `extraSettingsFile` currently
supports `domains`, `accounts` and `virtualAliases` which can be defined in
the same manner as they can be via nix.
'';
default = null;
};
certificateScheme = lib.mkOption {
type = lib.types.enum ["acme" "selfsigned"];
default = "acme";

View file

@ -4,7 +4,7 @@
pkgs,
...
}:
with (import ./common.nix {inherit config pkgs;}); let
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem;
postfixCfg = config.services.postfix;
dovecot2Cfg = config.services.dovecot2;
@ -19,7 +19,13 @@ with (import ./common.nix {inherit config pkgs;}); let
systemUsers = lib.filterAttrs (user: value: value.isSystemUser) cfg.accounts;
normalUsers = lib.filterAttrs (user: value: !value.isSystemUser) cfg.accounts;
genUserdbEntry = user: value: "${user}:::::::";
genUserdbEntry = user: value:
"${user}:::::::"
+ (
if lib.isString value.quota
then "userdb_quota_rule=*:storage=${value.quota}"
else ""
);
genPasswdEntry = user: value: "${user}:${"$(head -n 1 ${value.hashedPasswordFile})"}::::::";
genAuthDbsScript = pkgs.writeScript "generate-dovecot-auth-dbs" ''
@ -39,22 +45,51 @@ with (import ./common.nix {inherit config pkgs;}); let
# Ensure passwd files are not world-readable at any time
umask 077
# Ensure we have a file for every user's (initial) password hash.
for f in ${builtins.toString (lib.mapAttrsToList (user: value: value.hashedPasswordFile) cfg.accounts)}; do
if [ ! -f "$f" ]; then
echo "Expected password hash file $f does not exist!"
exit 1
fi
done
# Prepare static passwd-file for system users
${mailnixCmd} generate-static-passdb > "${staticPasswdFile}"
cat <<EOF > "${staticPasswdFile}"
${lib.concatStringsSep "\n" (lib.mapAttrsToList genPasswdEntry systemUsers)}
EOF
# Prepare/Update passwd-file for dynamic users
${mailnixCmd} update-dynamic-passdb ${dovecotDynamicPasswdFile} > "${dovecotDynamicPasswdFile}"
# Prepare initial passwd-file for dynamic users
# (used for lookup during actual passwd-file generation)
cat <<EOF > "${initialPasswdFile}"
${lib.concatStringsSep "\n" (lib.mapAttrsToList genPasswdEntry normalUsers)}
EOF
${lib.optionalString cfg.roundcube.enable ''
# Ensure roundcube has access to dynamic passwd file
${pkgs.acl.bin}/bin/setfacl -m "u:${config.services.phpfpm.pools.roundcube.user}:rw" "${dovecotDynamicPasswdFile}"
''}
# Check for existence of dynamic passwd-file
touch "${dovecotDynamicPasswdFile}"
if (! test -f "${dovecotDynamicPasswdFile}"); then
echo "${dovecotDynamicPasswdFile} exists and is no regular file"
exit 1
fi
# Ensure that only configured users are actually present and remove any others
truncate -s 0 "${dovecotDynamicPasswdFile}-filtered"
for u in ${builtins.toString (lib.mapAttrsToList (user: value: value.name) normalUsers)}; do
if grep -q "^$u:" "${dovecotDynamicPasswdFile}"; then
# User already has some password set -> Keep currently set password
grep "^$u:" "${dovecotDynamicPasswdFile}" >> "${dovecotDynamicPasswdFile}-filtered"
else
# User has no password set, yet -> Take password from initialPasswdFile
grep "^$u:" "${initialPasswdFile}" >> "${dovecotDynamicPasswdFile}-filtered"
fi
done
mv "${dovecotDynamicPasswdFile}-filtered" "${dovecotDynamicPasswdFile}"
# Prepare userdb-file
${mailnixCmd} generate-userdb > "${userdbFile}"
cat <<EOF > "${userdbFile}"
${lib.concatStringsSep "\n" (lib.mapAttrsToList genUserdbEntry cfg.accounts)}
EOF
'';
genMaildirScript = pkgs.writeScript "generate-maildir" ''
genMaildir = pkgs.writeScript "generate-maildir" ''
#!${pkgs.stdenv.shell}
# Create mail directory and set permissions accordingly.
@ -94,14 +129,6 @@ in {
)
cfg.accounts;
environment.systemPackages = [
# sieves + managesieve
pkgs.dovecot_pigeonhole
];
# For compatibility with python imaplib
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
services.dovecot2 = {
enable = true;
enableImap = true;
@ -117,6 +144,11 @@ in {
enableLmtp = true;
modules = [
# sieves + managesieve
pkgs.dovecot_pigeonhole
];
# enable managesieve
protocols = ["sieve"];
@ -236,7 +268,7 @@ in {
userdb {
driver = passwd-file
args = ${userdbFile}
default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}/%{domain}/%{username}
default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}/%d/%n
}
service auth {
@ -264,7 +296,7 @@ in {
systemd.services.dovecot2 = {
preStart = ''
${genAuthDbsScript}
${genMaildirScript}
${genMaildir}
'';
wants = sslCertService;
after = sslCertService;

View file

@ -4,21 +4,18 @@
lib,
...
}:
with (import ./common.nix {inherit config pkgs;}); let
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem;
in {
config =
lib.mkIf cfg.enable {
services.nginx = {
enable = true;
virtualHosts."${cfg.fqdn}" =
{
virtualHosts."${cfg.fqdn}" = {
forceSSL = true;
enableACME = cfg.certificateScheme == "acme";
}
// lib.optionalAttrs (cfg.certificateScheme == "selfsigned") {
sslCertificate = sslCertPath;
sslCertificateKey = sslKeyPath;
sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath;
sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath;
};
};

View file

@ -4,29 +4,55 @@
pkgs,
...
}:
with (import ./common.nix {inherit config pkgs;}); let
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem;
mappedFile = name: "hash:/var/lib/postfix/conf/${name}";
runtimeDir = "/run/postfix";
aliases_file = "${runtimeDir}/virtual_aliases";
virtual_domains_file = "${runtimeDir}/virtual_domains";
denied_recipients_file = "${runtimeDir}/denied_recipients";
attrsToLookupTable = aliases: let
lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases;
in
mergeLookupTables lookupTables;
genPostmapsScript = pkgs.writeScript "generate-postfix-postmaps" ''
#!${pkgs.stdenv.shell}
set -euo pipefail
lookupTableToString = attrs: let
valueToString = value: lib.concatStringsSep ", " value;
in
lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs);
if (! test -d "${runtimeDir}"); then
mkdir "${runtimeDir}"
chmod 755 "${runtimeDir}"
fi
mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables;
${mailnixCmd} "generate-aliases" > "${aliases_file}"
${mailnixCmd} "generate-domains" > "${virtual_domains_file}"
${mailnixCmd} "generate-denied-recipients" > "${denied_recipients_file}"
'';
account_virtual_aliases = mergeLookupTables (lib.flatten (lib.mapAttrsToList
(name: value: let
to = name;
in
map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name))
cfg.accounts));
virtual_domain_aliases = let
alias_domains =
lib.concatMapAttrs (src: dst: {
"@${src}" = "@${dst}";
})
cfg.virtualDomainAliases;
in
attrsToLookupTable alias_domains;
extra_virtual_aliases = attrsToLookupTable cfg.extraVirtualAliases;
all_virtual_aliases = mergeLookupTables [account_virtual_aliases virtual_domain_aliases extra_virtual_aliases];
aliases_file = let
content = lookupTableToString all_virtual_aliases;
in
builtins.toFile "virtual_aliases" content;
# File containing all mappings of authenticated accounts and their sender mail addresses.
virtual_accounts_file = let
content = lookupTableToString all_virtual_aliases;
in
builtins.toFile "virtual_accounts" content;
virtual_domains_file = builtins.toFile "virtual_domains" (lib.concatStringsSep "\n" cfg.domains);
submission_header_cleanup_rules = pkgs.writeText "submission_header_cleanup_rules" ''
# Removes sensitive headers from mails handed in via the submission port.
@ -48,15 +74,14 @@ with (import ./common.nix {inherit config pkgs;}); let
tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
in {
config = lib.mkIf cfg.enable {
assertions = let
isDomain = value: !lib.hasInfix "@" value;
aliasedDomains = builtins.filter isDomain (builtins.attrNames cfg.virtualAliases);
in
map (domain: {
assertion = builtins.elem domain cfg.domains;
message = "The domain to be aliased (${domain}) must be managed by the mailserver.";
})
aliasedDomains;
assertions =
lib.mapAttrsToList (
src: dst: {
assertion = (builtins.elem src cfg.domains) && (builtins.elem dst cfg.domains);
message = "Both aliased domain (${src}) and actual domain (${dst}) need to be managed by the mailserver.";
}
)
cfg.virtualDomainAliases;
services.postfix = {
enable = true;
@ -68,8 +93,10 @@ in {
enableSubmissions = true;
# TODO: create function to simplify this?
mapFiles."virtual_aliases" = aliases_file;
mapFiles."denied_recipients" = denied_recipients_file;
mapFiles."virtual_accounts" = virtual_accounts_file;
virtual = lookupTableToString all_virtual_aliases;
submissionsOptions = {
smtpd_tls_security_level = "encrypt";
@ -79,7 +106,8 @@ in {
smtpd_sasl_security_options = "noanonymous";
smtpd_sasl_local_domain = "$myhostname";
smtpd_client_restrictions = "permit_sasl_authenticated,reject";
smtpd_sender_login_maps = mappedFile "virtual_aliases";
# use mappedFile -> different path?
smtpd_sender_login_maps = "hash:/etc/postfix/virtual_accounts";
smtpd_sender_restrictions = "reject_sender_login_mismatch";
smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject";
cleanup_service_name = "submission-header-cleanup";
@ -96,9 +124,6 @@ in {
virtual_gid_maps = "static:${toString cfg.vmailUID}";
virtual_mailbox_base = cfg.mailDirectory;
virtual_mailbox_domains = virtual_domains_file;
virtual_alias_maps = [
(mappedFile "virtual_aliases")
];
virtual_mailbox_maps = [
(mappedFile "virtual_aliases")
];
@ -115,9 +140,11 @@ in {
"permit_sasl_authenticated"
"reject_unauth_destination"
];
smtpd_recipient_restrictions = [
"check_recipient_access ${mappedFile "denied_recipients"}"
];
# quota checking # TODO: wo ist hier quota??
# smtpd_recipient_restrictions = [
# "check_policy_service inet:localhost:12340" # XXX
# ];
# TLS settings, inspired by https://github.com/jeaye/nix-files
# Submission by mail clients is handled in submissionOptions
@ -182,11 +209,6 @@ in {
};
};
systemd.services.postfix-setup = {
preStart = ''
${genPostmapsScript}
'';
};
systemd.services.postfix = {
wants = sslCertService;
after =

View file

@ -4,14 +4,14 @@
pkgs,
...
}:
with (import ./common.nix {inherit config pkgs;}); let
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem;
roundcubeCfg = config.mailsystem.roundcube;
in {
options.mailsystem.roundcube = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
default = true;
description = "Whether to enable roundcube in order to provide a webmail interface";
};
hostName = lib.mkOption {
@ -32,9 +32,8 @@ in {
hostName = roundcubeCfg.hostName;
plugins = ["managesieve" "password"];
extraConfig = ''
// Use implicitly encrypted communications for imap and imap (implicit tls)
$config['imap_host'] = "ssl://${cfg.fqdn}";
$config['smtp_host'] = "ssl://${cfg.fqdn}";
// Use starttls for authentication
$config['smtp_host'] = "tls://${cfg.fqdn}";
$config['smtp_user'] = "%u";
$config['smtp_pass'] = "%p";
@ -47,8 +46,8 @@ in {
// Enables saving the new password even if it machtes the old password. Useful
// for upgrading the stored passwords after the encryption scheme has changed.
$config['password_force_save'] = true;
$config['password_dovecot_passwdfile_path'] = "${dovecotDynamicPasswdFile}";
$config['password_dovecotpw'] = "${pkgs.dovecot}/bin/doveadm pw";
$config['password_dovecot_passwdfile_path'] = "${pkgs.dovecot}/bin/doveadm pw";
$config['password_dovecotpw'] = "${dovecotDynamicPasswdFile}";
$config['password_dovecotpw_method'] = "${roundcubeCfg.passwordHashingAlgorithm}";
$config['password_dovecotpw_with_method'] = true;
'';

View file

@ -4,15 +4,14 @@
pkgs,
...
}:
with (import ./common.nix {inherit config pkgs;}); let
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem;
dovecot2Cfg = config.services.dovecot2;
nginxCfg = config.services.nginx;
nginxcfg = config.services.nginx;
postfixCfg = config.services.postfix;
redisCfg = config.services.redis.servers.rspamd;
rspamdCfg = config.services.rspamd;
genSystemdSocketCfg = name: socketPath: additionalUsers: {
genSystemdSocketCfg = name: socketPath: additionalUser: {
description = "rspamd ${name} worker socket";
listenStreams = [socketPath];
requiredBy = ["rspamd.service"];
@ -21,48 +20,31 @@ with (import ./common.nix {inherit config pkgs;}); let
SocketUser = rspamdCfg.user;
SocketMode = 0600;
ExecStartPost =
lib.mkIf (additionalUsers != [])
(pkgs.writeShellScript "set-systemd-socket-permissions"
(lib.concatMapStringsSep "\n" (user: ''${pkgs.acl.bin}/bin/setfacl -m "u:${user}:rw" "${socketPath}"'')
additionalUsers));
lib.mkIf (additionalUser != "")
''${pkgs.acl.bin}/bin/setfacl -m "u:${additionalUser}:rw" "${socketPath}"'';
};
};
in {
options.mailsystem.rspamd.webUi = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
default = true;
description = "Whether to enable the rspamd webui on `https://${config.mailsystem.fqdn}/rspamd`";
};
basicAuthFile = lib.mkOption {
type = lib.types.str;
description = "Path to basic auth file (entries can be generated using htpasswd)";
description = "Path to basic auth file";
};
};
config = lib.mkIf cfg.enable {
assertions =
[
assertions = [
{
assertion = !cfg.rspamd.webUi.enable || cfg.rspamd.webUi.basicAuthFile != null;
message = "Setting basicAuthFile is required if rspamd's web interface is enabled";
}
]
++ lib.mapAttrsToList (
domain: dkimList: {
assertion = builtins.elem domain cfg.domains;
message = "Domain ${domain} as per `config.mailsystem.dkimSettings` needs to be managed by the mailserver.";
}
)
cfg.dkimSettings
++ lib.mapAttrsToList (
domain: dkimList: {
assertion = dkimList != [];
message = "Entry ${domain} as per `config.mailsystem.dkimSettings` must not be an empty list.";
}
)
cfg.dkimSettings;
];
services.rspamd = {
enable = true;
@ -75,38 +57,6 @@ in {
}
'';
};
"dkim_signing.conf" = let
genDkimSelectorList = entry: ''
{
path: "${entry.keyFile}";
selector: "${entry.selector}";
}
'';
genDkimDomainCfg = domain: domainSettings: ''
${domain} {
selectors [
${lib.concatStringsSep "\n" (map genDkimSelectorList domainSettings)}
]
}
'';
in {
text =
''
sign_authenticated = true;
use_esld = true;
use_domain = "header";
check_pubkey = true;
allow_username_mismatch = true;
allow_hdrfrom_mismatch = true;
allow_hdrfrom_mismatch_sign_networks = true;
''
+ lib.optionalString (cfg.dkimSettings != {}) ''
domain {
${lib.concatStringsSep "\n" (lib.mapAttrsToList genDkimDomainCfg cfg.dkimSettings)}
}
'';
};
"milter_headers.conf" = {
text = ''
# Add headers related to spam-detection
@ -152,8 +102,10 @@ in {
};
systemd.sockets = {
rspamd-proxy = genSystemdSocketCfg "proxy" rspamdProxySocket [postfixCfg.user];
rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket ([dovecot2Cfg.mailUser] ++ lib.optional cfg.rspamd.webUi.enable nginxCfg.user);
rspamd-proxy = genSystemdSocketCfg "proxy" rspamdProxySocket postfixCfg.user;
rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket (
lib.optionalString cfg.rspamd.webUi.enable nginxCfg.user
);
};
systemd.services.rspamd = {
@ -168,10 +120,6 @@ in {
locations."/rspamd" = {
proxyPass = "http://unix:${rspamdControllerSocket}:/";
basicAuthFile = cfg.rspamd.webUi.basicAuthFile;
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
'';
};
sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath;
sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath;

View file

@ -4,7 +4,7 @@
lib,
...
}:
with (import ./common.nix {inherit config pkgs;}); let
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem;
in {
config = lib.mkIf (cfg.enable && cfg.certificateScheme == "selfsigned") {

806
pkgs/mailnix/Cargo.lock generated
View file

@ -1,806 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
[[package]]
name = "anstyle-parse"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "autocfg"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bumpalo"
version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]]
name = "cc"
version = "1.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7"
dependencies = [
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
]
[[package]]
name = "clap"
version = "4.5.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "colorchoice"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "deranged"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "iana-time-zone"
version = "0.1.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown 0.15.3",
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "js-sys"
version = "0.3.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libyml"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980"
dependencies = [
"anyhow",
"version_check",
]
[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "mailnix"
version = "0.1.0"
dependencies = [
"clap",
"regex",
"serde",
"serde_with",
"serde_yml",
]
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rustversion"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
dependencies = [
"base64",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.9.0",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yml"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
dependencies = [
"indexmap 2.9.0",
"itoa",
"libyml",
"memchr",
"ryu",
"serde",
"version_check",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "time"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

View file

@ -1,11 +0,0 @@
[package]
name = "mailnix"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.5.28", features = ["derive"] }
regex = "1.11.1"
serde = { version = "1.0.217", features = ["derive", "rc"] }
serde_with = "3.12.0"
serde_yml = "0.0.12"

View file

@ -1,22 +0,0 @@
use clap::{Parser, Subcommand};
#[derive(Subcommand, Debug)]
pub enum Commands {
Check,
GenerateUserdb,
GenerateStaticPassdb,
UpdateDynamicPassdb { path: String },
GenerateAliases,
GenerateDeniedRecipients,
GenerateDomains,
}
#[derive(Parser, Debug)]
#[command(author, version, about)]
pub struct Cli {
pub config_path: String,
pub additional_config_path: Option<String>,
#[command(subcommand)]
pub command: Commands,
}

View file

@ -1,226 +0,0 @@
use serde::Deserialize;
use serde_with::formats::PreferMany;
use serde_with::{MapPreventDuplicates, OneOrMany, serde_as};
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::fs::{self, File};
use std::io::BufReader;
use std::path::Path;
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub domains: Vec<String>,
#[serde(default)]
#[serde_as(deserialize_as = "MapPreventDuplicates<_, _>")]
pub accounts: HashMap<String, AccountConfig>,
#[serde(default)]
#[serde_as(deserialize_as = "MapPreventDuplicates<_, OneOrMany<_, PreferMany>>")]
pub aliases: HashMap<String, Vec<String>>,
}
fn default_reject_message() -> String {
"This account cannot receive emails.".to_string()
}
#[derive(Clone, Debug, Deserialize)]
pub struct AccountConfig {
#[serde(rename(deserialize = "hashedPassword"))]
#[serde(skip)]
pub hashed_password: String,
#[serde(rename(deserialize = "hashedPassword"))]
hashed_password_: Option<String>,
#[serde(rename(deserialize = "hashedPasswordFile"))]
hashed_password_file_: Option<String>,
#[serde(default, rename(deserialize = "isSystemUser"))]
pub is_system_user: bool,
#[serde(
default = "default_reject_message",
rename(deserialize = "rejectMessage")
)]
pub reject_message: String,
}
fn sanitize_vec(vec: &mut [String]) -> Result<(), Box<dyn Error>> {
let mut seen = HashSet::new();
for item in vec.iter_mut() {
*item = item.to_lowercase();
if !seen.insert(item.clone()) {
return Err(format!("Duplicate entry ({item}) detected, aborting...").into());
}
}
Ok(())
}
fn sanitize_map_keys<T: Clone>(map: &mut HashMap<String, T>) -> Result<(), Box<dyn Error>> {
let mut new_map = HashMap::<String, T>::new();
let mut seen = HashSet::new();
for (k, v) in map.iter() {
let new_k = k.to_lowercase();
new_map.insert(new_k.clone(), v.clone());
if !seen.insert(new_k.clone()) {
return Err(format!("Duplicate entry ({new_k}) detected, aborting...").into());
}
}
*map = new_map;
Ok(())
}
fn is_domain(address: &str) -> bool {
!address.contains("@")
}
fn parse_address(address: &str) -> Result<(String, String), Box<dyn Error>> {
let address_pattern = Regex::new(r"^(?P<local_part>.*)@(?P<domain>.*)$").unwrap();
let caps = address_pattern
.captures(address)
.ok_or("Mail address regex does not match for \"{address}\"")
.unwrap();
Ok((caps["local_part"].to_string(), caps["domain"].to_string()))
}
impl Config {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Config, Box<dyn Error>> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut cfg: Config = serde_yml::from_reader(reader)?;
cfg.sanitize()?;
for (name, acc) in cfg.accounts.iter_mut() {
match (
acc.hashed_password_.clone(),
acc.hashed_password_file_.clone(),
) {
(Some(_hash), Some(_file)) => {
return Err("Account {name} has both HashedPassword and HashedPasswordFile set, aborting...".into());
}
(Some(hash), None) => {
acc.hashed_password = hash;
}
(None, Some(file)) => {
let hash = fs::read_to_string(file.clone()).unwrap_or_else(|err| {
panic!("Account ({name}): Reading {file} failed: {err:?}")
});
acc.hashed_password = hash.trim().to_string();
}
(None, None) => {
return Err("Account {name} is missing HashedPassword or HashedPasswordFile, aborting...".into());
}
}
}
Ok(cfg)
}
fn sanitize(&mut self) -> Result<(), Box<dyn Error>> {
// standardize capitalization, ...
sanitize_vec(&mut self.domains).unwrap_or_else(|err| panic!("domain: {err:?}"));
sanitize_map_keys(&mut self.accounts).unwrap_or_else(|err| panic!("accounts: {err:?}"));
sanitize_map_keys(&mut self.aliases).unwrap_or_else(|err| panic!("aliases: {err:?}"));
for (from, dests) in self.aliases.iter_mut() {
sanitize_vec(&mut *dests).unwrap_or_else(|err| panic!("aliases ({from}): {err:?}"));
}
Ok(())
}
fn is_valid_destination(&self, address: &String) -> bool {
if self.accounts.contains_key(address) {
return true;
}
// There is no explicit account matching 'address'. However, 'address' may still be
// implicitly valid due to the existence of a catchall-alias or the domain being aliased.
let (local_part, domain) = parse_address(address).unwrap();
if let Some(dests) = self.aliases.get(&domain) {
if dests.iter().filter(|dest| !is_domain(dest)).count() > 0 {
// At least one explicit catchall-address exists!
return true;
}
// At this point, we need to (recursively) iterate over domain-aliases to check whether
// there is an exact match (or a catchall-alias for the aliased domain).
for dest in dests.iter().filter(|addr| is_domain(addr)) {
let aliased_addr = format!("{local_part}@{dest}");
// FIXME: This current implementation may not terminate if the configured aliases
// contain a loop!
if self.is_valid_destination(&aliased_addr) {
return true;
}
}
}
// alias destination is not valid
false
}
pub fn check(&self) -> Result<(), Box<dyn Error>> {
// check whether all account domains exist
for name in self.accounts.keys() {
let (_, domain) = parse_address(name).unwrap();
if !self.domains.contains(&domain) {
panic!("Domain of account \"{name}\" does not exist");
}
}
// check whether aliases have corresponding accounts/domains
for (from, dests) in self.aliases.iter() {
if is_domain(from) && !self.domains.contains(from) {
panic!("Aliased from-domain \"{from}\" does not exist");
}
for dest in dests.iter() {
if is_domain(dest) && !self.domains.contains(dest) {
panic!("Aliased dest-domain \"{dest}\" does not exist");
} else if !is_domain(dest) && !self.is_valid_destination(dest) {
panic!("Aliased dest-account \"{dest}\" does not exist");
}
}
}
Ok(())
}
pub fn merge<P: AsRef<Path>>(&mut self, path: P) -> Result<(), Box<dyn Error>> {
let mut other = Config::load(path).unwrap();
for domain in self.domains.iter() {
if other.domains.contains(domain) {
return Err(
"domains: Duplicate entry ({domain}) during merge detected, aborting..."
.to_string()
.into(),
);
}
}
self.domains.append(&mut other.domains);
for name in self.accounts.keys() {
if other.accounts.contains_key(name) {
return Err(
"accounts: Duplicate entry ({name}) during merge detected, aborting..."
.to_string()
.into(),
);
}
}
self.accounts.extend(other.accounts);
for alias in self.aliases.keys() {
if other.aliases.contains_key(alias) {
return Err(
"aliases: Duplicate entry ({alias}) during merge detected, aborting..."
.to_string()
.into(),
);
}
}
self.aliases.extend(other.aliases);
Ok(())
}
}

View file

@ -1,67 +0,0 @@
use regex::Regex;
use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::path::Path;
use crate::config::{AccountConfig, Config};
pub fn generate_userdb(cfg: Config) {
for name in cfg.accounts.into_keys() {
println!("{}:::::::", name);
}
}
pub fn generate_static_passdb(cfg: Config) {
let system_accounts = cfg
.accounts
.into_iter()
.filter(|(_, acc)| acc.is_system_user);
for (name, acc) in system_accounts {
println!("{}:{}::::::", name, acc.hashed_password);
}
}
fn try_load_passdb<P: AsRef<Path>>(path: P) -> Result<HashMap<String, String>, Box<dyn Error>> {
let mut curr_dynamic_users = HashMap::new();
if path.as_ref().exists() {
let re = Regex::new(r"^(?P<name>.*):(?P<hashed_password>.*)::::::$").unwrap();
let curr_passdb = fs::read_to_string(path.as_ref()).unwrap();
for line in curr_passdb.lines() {
let caps = re
.captures(line)
.unwrap_or_else(|| panic!("Regex does not match line: {line}"));
curr_dynamic_users.insert(
caps["name"].to_string(),
caps["hashed_password"].to_string(),
);
}
eprintln!("current passdb entries: {curr_dynamic_users:#?}");
}
Ok(curr_dynamic_users)
}
pub fn update_dynamic_passdb<P: AsRef<Path>>(cfg: Config, path: P) -> Result<(), Box<dyn Error>> {
// create hashmap of all accounts with their initial passdb-lines
let mut accounts: HashMap<String, AccountConfig> = cfg
.accounts
.into_iter()
.filter(|(_, acc)| !acc.is_system_user)
.collect();
eprintln!("settings: {:#?}", accounts);
// load current passdb and update account password hashes
let curr_dynamic_users = try_load_passdb(path)?;
for (name, hashed_password) in curr_dynamic_users.into_iter() {
accounts.entry(name).and_modify(|e| {
e.hashed_password = hashed_password;
});
}
for (name, acc) in accounts.into_iter() {
println!("{}:{}::::::", name, acc.hashed_password);
}
Ok(())
}

View file

@ -1,33 +0,0 @@
mod cli;
mod config;
mod dovecot;
mod postfix;
use crate::{
cli::{Cli, Commands},
config::Config,
};
use clap::Parser;
fn main() {
let args = Cli::parse();
let mut cfg = Config::load(args.config_path).unwrap();
if let Some(additional_cfg_path) = args.additional_config_path {
cfg.merge(additional_cfg_path).unwrap();
}
let cfg = cfg;
cfg.check().unwrap();
match &args.command {
Commands::Check => println!("Check: {:#?}", cfg),
Commands::GenerateUserdb => dovecot::generate_userdb(cfg),
Commands::GenerateStaticPassdb => dovecot::generate_static_passdb(cfg),
Commands::UpdateDynamicPassdb { path } => {
dovecot::update_dynamic_passdb(cfg, path).unwrap()
}
Commands::GenerateAliases => postfix::generate_aliases(cfg),
Commands::GenerateDeniedRecipients => postfix::generate_denied_recipients(cfg),
Commands::GenerateDomains => postfix::generate_domains(cfg),
}
}

View file

@ -1,32 +0,0 @@
use crate::config::Config;
fn prefix_domain(value: String) -> String {
if !value.contains("@") {
return format!("@{value}");
}
value
}
pub fn generate_aliases(cfg: Config) {
for name in cfg.accounts.into_keys() {
println!("{} {}", name, name);
}
for (src, dests) in cfg.aliases.into_iter() {
let dests: Vec<_> = dests.into_iter().map(prefix_domain).collect();
println!("{} {}", prefix_domain(src), dests.join(", "));
}
}
pub fn generate_denied_recipients(cfg: Config) {
let system_accounts = cfg
.accounts
.into_iter()
.filter(|(_, acc)| acc.is_system_user);
for (name, acc) in system_accounts {
println!("{} REJECT {}", name, acc.reject_message);
}
}
pub fn generate_domains(cfg: Config) {
println!("{}", cfg.domains.into_iter().collect::<Vec<_>>().join("\n"));
}

View file

@ -1,6 +1,5 @@
{pkgs, ...}:
with (import ./common/lib.nix {inherit pkgs;}); let
lib = pkgs.lib;
accounts = {
"normal" = {
address = "user1@example.com";
@ -12,23 +11,32 @@ with (import ./common/lib.nix {inherit pkgs;}); let
};
"alias" = {
address = "user3@example.com";
aliases = ["alias@example.com"];
password = "secret-password3";
};
"extra-alias" = {
address = "user4@example.com";
password = "secret-password4;";
};
"multi-alias1" = {
address = "multi-alias1@example.com";
password = "secret-password4;";
aliases = ["multi-alias@example.com"];
password = "secret-password5;";
};
"multi-alias2" = {
address = "multi-alias2@example.com";
password = "secret-password5;";
aliases = ["multi-alias@example.com"];
password = "secret-password6;";
};
"catchall" = {
address = "catchall@example.com";
password = "secret-password6;";
aliases = ["@example.com"];
password = "secret-password7;";
};
"otherdomain" = {
address = "otherdomain@example.com";
password = "secret-password7;";
aliases = ["user@otherdomain.com"];
password = "secret-password8;";
};
};
in
@ -42,14 +50,11 @@ in
fqdn = "mail.example.com";
domains = ["example.com" "aliased.com" "otherdomain.com"];
accounts = mkAccounts accounts;
virtualAliases = {
# domain aliases
virtualDomainAliases = {
"aliased.com" = "example.com";
# account aliases
"alias@example.com" = accounts."alias".address;
"multi-alias@example.com" = lib.map (x: accounts.${x}.address) ["multi-alias1" "multi-alias2"];
"example.com" = accounts."catchall".address;
"user@otherdomain.com" = accounts."otherdomain".address;
};
extraVirtualAliases = {
"extra-alias@example.com" = accounts."extra-alias".address;
};
};
};
@ -66,6 +71,7 @@ in
};
sendMail = mkSendMail smtpSettings accounts;
recvMail = mkRecvMail serverAddr accounts;
cfg = nodes.server.mailsystem;
in ''
start_all()
@ -177,6 +183,18 @@ in
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "otherdomain"} >&2")
with subtest("mail incoming on extraVirtualAlias"):
client.succeed("${sendMail "normal" "" "extra-alias@example.com" ''
Subject: extraVirtualAliases-Test
Hello User4,
this is mail is sent to you by using an extraVirtualAlias as recipient.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "extra-alias"} >&2")
with subtest("receiving mail on aliased domain using normal account"):
client.succeed("${sendMail "normal" "" "user2@aliased.com" ''
Subject: aliasedDomain with normal account

View file

@ -9,11 +9,6 @@ with (import ./common/lib.nix {inherit pkgs;}); let
address = "user2@example.com";
password = "secret-password2";
};
"system" = {
address = "system@example.com";
password = "secret-password3";
isSystemUser = true;
};
};
in
pkgs.nixosTest {
@ -82,27 +77,6 @@ in
I'm pretending to be someotheraddress@example.com and the mailserver should reject this attempt.
''}")
with subtest("send succeeds for system user"):
client.succeed("${sendMail "system" "" accounts."normal".address ''
Subject: Testmail2
Hello User1,
this is some text!
''}")
# give the mail server some time to process the mail
server.wait_until_fails('${pendingPostqueue}')
with subtest("mail can be retrieved via imap"):
client.succeed("${recvMail "normal"} >&2")
with subtest("mail sent to system-account is rejected"):
client.fail("${sendMail "normal" "someotheraddress@example.com" accounts."system".address ''
Subject: Mail to system-account
Hello System user,
this mail should never reach you as it should be rejected by postfix.
''}")
with subtest("server issues no warnings nor errors"):
${checkLogs "server"}
'';

View file

@ -2,7 +2,7 @@
lib = pkgs.lib;
in rec {
waitForRspamd = node: let
inherit (import ../../mailsystem/common.nix {inherit (node) config pkgs;}) rspamdProxySocket;
inherit (import ../../mailsystem/common.nix {inherit (node) config;}) rspamdProxySocket;
in "set +e; timeout 1 ${node.nixpkgs.pkgs.netcat}/bin/nc -U ${rspamdProxySocket} < /dev/null; [ $? -eq 124 ]";
mkHashedPasswordFile = password:

View file

@ -1,26 +1,13 @@
{lib, ...}: {
{...}: {
imports = [./../../mailsystem];
config = {
virtualisation.memorySize = 1024;
mailsystem = {
enable = true;
roundcube.enable = false;
rspamd.webUi.enable = false;
certificateScheme = "selfsigned";
};
# Prevent tests deadlocking on DNS requests which will never succeed.
services.rspamd.locals."options.inc".text = ''
dns {
nameservers = ["127.0.0.1"];
timeout = 0.0s;
retransmits = 0;
}
'';
# Enable more verbose logging (required for, e.g., sieve testing)
services.dovecot2.extraConfig = lib.mkAfter ''
mail_debug = yes
auth_debug = yes
verbose_ssl = yes
'';
};
}

View file

@ -1,178 +0,0 @@
{pkgs, ...}:
with (import ./common/lib.nix {inherit pkgs;}); let
lib = pkgs.lib;
accounts = {
"normal" = {
address = "user@example.com";
password = "secret-password1";
};
"normal2" = {
address = "user@example.org";
password = "secret-password2;";
};
};
genDkimSecret = domain: name: type:
pkgs.runCommand "mk-dkim-secrets-${domain}-${selector}" {
buildInputs = [pkgs.rspamd];
inherit domain name type;
} ''
rspamadm dkim_keygen -d $domain -s $name -t $type ${lib.optionalString (type == "rsa") "-b 2048"} -k $out
'';
mkDkimSettings = domains: selectors:
lib.listToAttrs (
map (domain:
lib.nameValuePair domain (map (entry: {
selector = entry.name;
keyFile = genDkimSecret domain entry.name entry.type;
})
selectors))
domains
);
in
pkgs.nixosTest {
name = "rspamd";
nodes = {
server = {pkgs, ...}: {
imports = [./common/server.nix];
mailsystem = {
fqdn = "mail.example.com";
domains = ["example.com" "example.org"];
accounts = mkAccounts accounts;
dkimSettings = mkDkimSettings ["example.com" "example.org"] [
{
name = "elliptic";
type = "ed25519";
}
{
name = "selector";
type = "rsa";
}
];
};
};
client = {...}: {
imports = [./common/client.nix];
};
};
testScript = {nodes, ...}: let
cfg = nodes.server.mailsystem;
serverAddr = nodes.server.networking.primaryIPAddress;
clientAddr = nodes.client.networking.primaryIPAddress;
smtpSettings = {
address = serverAddr;
port = 465;
};
sendMail = mkSendMail smtpSettings accounts;
recvMail = mkRecvMail serverAddr accounts;
test-mark-spam = accountName:
pkgs.writeScript "imap-mark-spam" ''
#!${pkgs.python3.interpreter}
import imaplib
with imaplib.IMAP4_SSL('${serverAddr}') as imap:
imap.login('${accounts."${accountName}".address}', '${accounts."${accountName}".password}')
imap.select()
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.copy(','.join(msg_ids), 'Junk')
for num in msg_ids:
imap.store(num, '+FLAGS', '\\Deleted')
imap.expunge()
imap.select('Junk')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.close()
'';
test-mark-ham = accountName:
pkgs.writeScript "imap-mark-ham" ''
#!${pkgs.python3.interpreter}
import imaplib
with imaplib.IMAP4_SSL('${serverAddr}') as imap:
imap.login('${accounts."${accountName}".address}', '${accounts."${accountName}".password}')
imap.select('Junk')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.copy(','.join(msg_ids), 'INBOX')
for num in msg_ids:
imap.store(num, '+FLAGS', '\\Deleted')
imap.expunge()
imap.select('INBOX')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.close()
'';
in ''
start_all()
server.wait_for_unit("multi-user.target")
client.wait_for_unit("multi-user.target")
server.wait_until_succeeds("${waitForRspamd nodes.server}")
with subtest("rspamd configuration is valid"):
server.succeed("${pkgs.rspamd}/bin/rspamadm configtest >&2")
with subtest("rspamd rejects spam"):
client.fail("${sendMail "normal" "" accounts."normal2".address ''
Subject: GTUBE-Test
Hello User2,
this is a mail containing a GTUBE pattern that should result in the rejection of this mail.
XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X
''}")
with subtest("imap sieve junk trainer"):
client.succeed("${sendMail "normal" "" accounts."normal2".address ''
Subject: Testmail
Hello User2,
this is a testmail.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.succeed("${test-mark-spam "normal2"} >&2")
server.wait_until_succeeds("journalctl -u dovecot2 | grep -i learn-spam.sh >&2")
server.fail("journalctl -u dovecot2 | grep -i learn-spam.sh | grep -i error >&2")
client.succeed("${test-mark-ham "normal2"} >&2")
server.wait_until_succeeds("journalctl -u dovecot2 | grep -i learn-ham.sh >&2")
server.fail("journalctl -u dovecot2 | grep -i learn-ham.sh | grep -i error >&2")
with subtest("dkim signing"):
client.succeed("${sendMail "normal2" "" accounts."normal".address ''
Subject: Testmail
Hello User1,
this is also a testmail.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "normal"} >&2")
client.succeed("cat ~/mail/* >&2")
# make sure the mail has all configured dkim signatures
client.succeed("grep ${(builtins.elemAt cfg.dkimSettings."example.com" 0).selector} ~/mail/*")
client.succeed("grep ${(builtins.elemAt cfg.dkimSettings."example.com" 1).selector} ~/mail/*")
'';
}