Compare commits

...
Sign in to create a new pull request.

44 commits
quota ... main

Author SHA1 Message Date
95d920ae67 tests: common: server: Prevent tests deadlocking on DNS requests
All checks were successful
Test / tests (push) Successful in 3m39s
2025-08-12 09:38:36 +02:00
f5bf117314 tests: rspamd: Fix sieve tests
Currently, the sieve tests only check whether the sieve scripts were
invoked. However, they could still fail due to wrong permissions, etc.
This commit enables additional logging output for dovecot2 and refines
the sieve tests to check for error messages.
2025-08-12 09:38:36 +02:00
27d388e1c8 mailsystem: rspamd: Make rspamd-controller.socket accessible for dovecot2 user
This is required for functional spam/ham learning.
2025-08-12 09:38:33 +02:00
4c2eed3421 pkgs: mailnix: Cargo.lock: Update dependencies
All checks were successful
Test / tests (push) Successful in 4m12s
2025-05-28 14:46:58 +02:00
8c96ece585 flake.nix: Update NixOS release to 25.05 2025-05-28 14:45:27 +02:00
f110705435 tests: basic: Add tests whether system users can dispatch mails
All checks were successful
Test / tests (push) Successful in 3m26s
2025-03-27 19:29:02 +01:00
2db35aed07 pkgs: mailnix: Include password hash in system-users' passwd to enable sending mails 2025-03-27 19:28:14 +01:00
6052072c3f pkgs: mailnix: Improve alias-validation to also regard catch-all aliases and domain-aliases
All checks were successful
Test / tests (push) Successful in 3m45s
2025-03-23 15:46:55 +01:00
41f8b16a42 pkgs: mailnix: Refactor domain-check and address-parsing into own helper functions 2025-03-23 15:38:07 +01:00
01e63f4bab mailsystem: dovecot: Use expanded variable names
All checks were successful
Test / tests (push) Successful in 3m39s
Starting with dovecot 2.4, short notations for variables are no longer
accepted (see
https://doc.dovecot.org/2.4.0/installation/upgrade/2.3-to-2.4.html#variable-expansion).
2025-03-23 13:27:09 +01:00
6f8bcdf9c0 pkgs: mailnix: Refactor dovecot::update_dynamic_passdb in preparation for unit tests 2025-02-25 17:15:20 +01:00
79b2ec800e pkgs: mailnix: Make errors thrown in config::merge more verbose
All checks were successful
Test / tests (push) Successful in 3m29s
2025-02-23 20:47:32 +01:00
3a9b2c8b59 mailsystem: Add option extraSettingsFile to allow partial encryption of configuration
Some checks failed
Test / tests (push) Failing after 4m45s
2025-02-23 18:11:55 +01:00
c1b19d6e33 mailsystem: Use newly added 'mailnix' package to generate postfix/dovecot files 2025-02-23 18:11:47 +01:00
955a0ec8ba Add package 'mailnix' for assisting in generation of dovecot/postfix files 2025-02-23 18:11:07 +01:00
ce2784e17d flake.lock: Update inputs 2025-02-23 18:11:07 +01:00
a1e87f70fa Merge options virtualAccountAliases and virtualDomainAliases into virtualAliases 2025-02-23 18:11:01 +01:00
faf6f549b0 Remove accounts.<name>.aliases and rename extraVirtualAliases to virtualAccountAliases
Some checks failed
Test / tests (push) Failing after 3m0s
In order to simplify configuration and reduce configuration variability,
this commit removes the option to directly add aliases to each single
mail account. Instead, aliaes should be centrally configured using the
option 'virtualAccountAliases'.
2025-01-06 23:29:40 +01:00
28796695af Add github actions workflow for CI tests 2025-01-06 23:25:59 +01:00
30532bbfca Actually reject mails sent to system accounts and add respective testcase 2025-01-06 23:25:59 +01:00
55183f5585 tests: Add tests for rspamd-related functionality 2025-01-06 23:25:59 +01:00
de330a87a4 mailsystem: Add configuration options for dkim signatures 2025-01-06 23:25:59 +01:00
88d2b387c7 mailsystem: roundcube: Fix mixup with dovecotpw and passwdfile 2025-01-06 23:25:59 +01:00
cbdbb94512 mailsystem: dovecot: Grant roundcube user access to dynamic passwd file 2025-01-06 23:25:59 +01:00
1b26a41aaf mailsystem: rspamd: Ensure proxy headers for webui are configured 2025-01-06 23:25:59 +01:00
35aeb19b24 mailsystem: roundcube: Use imaps and submissions instead of disabled starttls variant 2025-01-06 23:25:59 +01:00
53e2b9f621 mailsystem: nginx: Replace incorrect usage of lib.mkIf with lib.optionalAttrs
Evaluation of lib.mkIf and lib.optionalAttrs is slightly different. In
this specific case, the usage of lib.mkIf resulted in the defined
virtualHost never actually being applied due to an earlier error in the
evaluation order.
2025-01-06 23:25:59 +01:00
e6e91b775a Disable roundcube and rspamd webui by default 2025-01-06 23:25:59 +01:00
5f49caec49 Add configuration option to alias entire domains and respective tests 2025-01-06 23:25:59 +01:00
92d0a6e1f8 tests: Add various tests for alias functionality 2025-01-06 23:25:59 +01:00
457d91bcca tests: Add basic tests for sending/receiving mails and verification of headers 2025-01-06 23:25:59 +01:00
e4fa3bee38 tests: common: Add lib.nix containing various helpers for testing mailsystem behaviour 2025-01-06 23:25:59 +01:00
6f1964e6f2 flake.nix: Add and configure treefmt-nix for nix fmt 2025-01-06 23:25:59 +01:00
d5107df08d flake.nix: Rename pre-commit-hooks-nix into git-hooks-nix
Cachix has renamed their project.
2025-01-06 23:25:59 +01:00
617b116f4c tests: minimal: Configure and verify vmail user/group/uid/gid 2025-01-06 23:25:59 +01:00
84542be242 Add minimal (internal) tests 2025-01-06 23:25:59 +01:00
e185d301ff mailsystem: Add option to use selfsigned certificates in preparation for testing 2025-01-06 23:25:59 +01:00
8a64eb9287 flake.nix: Actually expose mailsystem as nixosModule 2025-01-06 23:25:59 +01:00
c8a44b9b48 mailsystem: Add configuration for roundcube as webmail interface 2025-01-06 23:25:59 +01:00
aff4f9117f mailsystem: rspamd: Add configuration options to make rspamd's web ui accessible 2025-01-06 23:25:50 +01:00
aacf9a9b8c mailsystem: dovecot: Autolearn ham/spam when moving mails 2025-01-06 23:04:45 +01:00
9149f03384 mailsystem: Configure rspamd as spam filter 2025-01-06 23:04:45 +01:00
b805502099 mailsystem: Add basic postfix configuration 2025-01-06 23:04:45 +01:00
c738037669 mailsystem: Add minimal dovecot configuration 2025-01-06 23:04:42 +01:00
29 changed files with 3082 additions and 71 deletions

17
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,17 @@
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

116
flake.lock generated
View file

@ -1,5 +1,20 @@
{ {
"nodes": { "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-compat": {
"flake": false, "flake": false,
"locked": { "locked": {
@ -23,11 +38,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1730504689, "lastModified": 1743550720,
"narHash": "sha256-hgmguH29K2fvs9szpq2r3pz2/8cJd2LPS+b4tfNFCwE=", "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "506278e768c2a08bec68eb62932193e341f55c90", "rev": "c621e8422220273271f52058f618c94e405bb0f5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -36,10 +51,32 @@
"type": "github" "type": "github"
} }
}, },
"git-hooks-nix": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1747372754,
"narHash": "sha256-2Y53NGIX2vxfie1rOW0Qb86vjRZ7ngizoo+bnXU9D9k=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "80479b6ec16fefd9c1db3ea13aeb038c60530f46",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": { "gitignore": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"pre-commit-hooks-nix", "git-hooks-nix",
"nixpkgs" "nixpkgs"
] ]
}, },
@ -59,64 +96,47 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1731755305, "lastModified": 1748162331,
"narHash": "sha256-v5P3dk5JdiT+4x69ZaB18B8+Rcu3TIOrcdG4uEX7WZ8=", "narHash": "sha256-rqc2RKYTxP3tbjA+PB3VMRQNnjesrT0pEofXQTrMsS8=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "057f63b6dc1a2c67301286152eb5af20747a9cb4", "rev": "7c43f080a7f28b2774f3b3f43234ca11661bf334",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-24.11", "ref": "nixos-25.05",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"nixpkgs-stable": {
"locked": {
"lastModified": 1720386169,
"narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "194846768975b7ad2c4988bdb82572c00222c0d7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"pre-commit-hooks-nix": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1732021966,
"narHash": "sha256-mnTbjpdqF0luOkou8ZFi2asa1N3AA2CchR/RqCNmsGE=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "3308484d1a443fc5bc92012435d79e80458fe43c",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"git-hooks-nix": "git-hooks-nix",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"pre-commit-hooks-nix": "pre-commit-hooks-nix" "treefmt-nix": "treefmt-nix"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1748243702,
"narHash": "sha256-9YzfeN8CB6SzNPyPm2XjRRqSixDopTapaRsnTpXUEY8=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "1f3f7b784643d488ba4bf315638b2b0a4c5fb007",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
} }
} }
}, },

View file

@ -2,11 +2,14 @@
description = "An opinionated Nixos Mailsystem"; description = "An opinionated Nixos Mailsystem";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
pre-commit-hooks-nix.url = "github:cachix/git-hooks.nix"; treefmt-nix.url = "github:numtide/treefmt-nix";
pre-commit-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; 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";
}; };
outputs = { outputs = {
@ -21,7 +24,9 @@
"aarch64-linux" "aarch64-linux"
]; ];
imports = [ imports = [
inputs.pre-commit-hooks-nix.flakeModule flake-parts.flakeModules.easyOverlay
inputs.treefmt-nix.flakeModule
inputs.git-hooks-nix.flakeModule
]; ];
perSystem = { perSystem = {
@ -31,10 +36,33 @@
pkgs, pkgs,
system, system,
... ...
}: { }: let
devShells.default = pkgs.mkShell { craneLib = inputs.crane.mkLib pkgs;
pkgs = nixpkgs.legacyPackages.${system}.extend self.overlays.default;
in {
checks = let
tests = ["internal" "basic" "aliases" "rspamd"];
genTest = testName: {
"name" = testName;
"value" = import (./tests + "/${testName}.nix") {inherit pkgs;};
};
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 {
packages = with pkgs; [ packages = with pkgs; [
alejandra self'.formatter.outPath # Add all formatters to environment
mailnix
]; ];
shellHook = '' shellHook = ''
${config.pre-commit.installationScript} ${config.pre-commit.installationScript}
@ -42,8 +70,25 @@
}; };
pre-commit.settings.hooks = { pre-commit.settings.hooks = {
alejandra.enable = true; treefmt.enable = true;
}; };
treefmt = {
programs = {
actionlint.enable = true;
alejandra.enable = true;
rustfmt.enable = true;
};
settings.global.excludes = [
".envrc"
"*.sieve"
];
};
};
flake.nixosModules = rec {
default = mailsystem;
mailsystem = import ./mailsystem;
}; };
}; };
} }

View file

@ -1,7 +1,40 @@
{config, ...}: let {
config,
pkgs,
...
}: let
cfg = config.mailsystem; cfg = config.mailsystem;
in rec { in rec {
sslCertPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem"; certificateDirectory = "/var/certs";
sslKeyPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem"; sslCertPath =
sslCertService = ["acme-finished-${cfg.fqdn}.target"]; if cfg.certificateScheme == "acme"
then "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem"
else "${certificateDirectory}/cert-${cfg.fqdn}.pem";
sslKeyPath =
if cfg.certificateScheme == "acme"
then "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem"
else "${certificateDirectory}/key-${cfg.fqdn}.pem";
sslCertService =
if cfg.certificateScheme == "acme"
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";
rspamdProxySocket = "/run/rspamd-proxy.sock";
rspamdControllerSocket = "/run/rspamd-controller.sock";
} }

View file

@ -1,4 +1,10 @@
{lib, ...}: { {
config,
lib,
...
}: let
cfg = config.mailsystem;
in {
options.mailsystem = { options.mailsystem = {
enable = lib.mkEnableOption "nixos-mailsystem"; enable = lib.mkEnableOption "nixos-mailsystem";
@ -14,6 +20,32 @@
description = "Fully qualified domain name of the mail server."; description = "Fully qualified domain name of the mail server.";
}; };
reverseFqdn = lib.mkOption {
type = lib.types.str;
default = cfg.fqdn;
defaultText = lib.literalMD "{option}`mailsystem.fqdn`";
example = "server.example.com";
description = ''
Fully qualified domain name used by the server to identify
with other servers.
This needs to be set to the same value of the server's IP reverse DNS.
'';
};
domains = lib.mkOption {
type = lib.types.listOf lib.types.str;
example = ["example.com"];
default = [];
description = "List of domains to be served by the mail server";
};
messageSizeLimit = lib.mkOption {
type = lib.types.int;
default = 64 * 1024 * 1024;
description = "Maximum accepted mail size";
};
vmailUID = lib.mkOption { vmailUID = lib.mkOption {
type = lib.types.int; type = lib.types.int;
default = 5000; default = 5000;
@ -37,10 +69,178 @@
default = "/var/vmail"; default = "/var/vmail";
description = "Storage location for all mail."; description = "Storage location for all mail.";
}; };
accounts = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule ({name, ...}: {
options = {
name = lib.mkOption {
type = lib.types.str;
example = "user1@example.com";
description = "Username";
};
hashedPasswordFile = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
example = "/run/secrets/user1-passwordhash";
description = ''
A file containing the user's hashed password. Use `mkpasswd` as follows
```
nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt'
```
'';
};
isSystemUser = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
System users are not allowed to change their password and are
cannot receive any mails (-> send-only). Mails sent to such an
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 = {
user1 = {
hashedPassword = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/";
};
user2 = {
hashedPassword = "$6$oE0ZNv2n7Vk9gOf$9xcZWCCLGdMflIfuA0vR1Q1Xblw6RZqPrP94mEit2/81/7AKj2bqUai5yPyWE.QYPyv6wLMHZvjw3Rlg7yTCD/";
};
};
description = "All available accounts 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;
};
accountOrDomain = lib.mkOptionType {
name = "Mail Account or Domain";
check = value: (isAccount value) || (isDomain value);
};
in
with lib.types; attrsOf (either (nonEmptyListOf account) accountOrDomain);
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.
'';
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";
description = ''
The scheme to use for managing TLS certificates:
1. `acme`: The server retrieves letsencrypt certificates via NixOS's acme module using nginx.
2. `selfsigned`: The server creates self-signed certificates on the fly (intended for testing).
'';
internal = true;
visible = false;
};
}; };
imports = [ imports = [
./dovecot.nix
./kresd.nix
./nginx.nix ./nginx.nix
./postfix.nix
./redis.nix
./roundcube.nix
./rspamd.nix
./selfsigned.nix
./user.nix ./user.nix
]; ];
} }

281
mailsystem/dovecot.nix Normal file
View file

@ -0,0 +1,281 @@
{
config,
lib,
pkgs,
...
}:
with (import ./common.nix {inherit config pkgs;}); let
cfg = config.mailsystem;
postfixCfg = config.services.postfix;
dovecot2Cfg = config.services.dovecot2;
runtimeStateDir = "/run/dovecot2";
staticPasswdFile = "${runtimeStateDir}/passwd";
initialPasswdFile = "${runtimeStateDir}/initial-passwd";
userdbFile = "${runtimeStateDir}/userdb";
# dovecotDynamicStateDir and dovecotDynamicPasswdFile are defined in common.nix
systemUsers = lib.filterAttrs (user: value: value.isSystemUser) cfg.accounts;
normalUsers = lib.filterAttrs (user: value: !value.isSystemUser) cfg.accounts;
genUserdbEntry = user: value: "${user}:::::::";
genPasswdEntry = user: value: "${user}:${"$(head -n 1 ${value.hashedPasswordFile})"}::::::";
genAuthDbsScript = pkgs.writeScript "generate-dovecot-auth-dbs" ''
#!${pkgs.stdenv.shell}
set -euo pipefail
if (! test -d "${runtimeStateDir}"); then
mkdir "${runtimeStateDir}"
chmod 755 "${runtimeStateDir}"
fi
if (! test -d "${dovecotDynamicStateDir}"); then
mkdir "${dovecotDynamicStateDir}"
chmod 755 "${dovecotDynamicStateDir}"
fi
# Ensure passwd files are not world-readable at any time
umask 077
# Prepare static passwd-file for system users
${mailnixCmd} generate-static-passdb > "${staticPasswdFile}"
# Prepare/Update passwd-file for dynamic users
${mailnixCmd} update-dynamic-passdb ${dovecotDynamicPasswdFile} > "${dovecotDynamicPasswdFile}"
${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}"
''}
# Prepare userdb-file
${mailnixCmd} generate-userdb > "${userdbFile}"
'';
genMaildirScript = pkgs.writeScript "generate-maildir" ''
#!${pkgs.stdenv.shell}
# Create mail directory and set permissions accordingly.
umask 007
mkdir -p ${cfg.mailDirectory}
chgrp "${cfg.vmailGroupName}" ${cfg.mailDirectory}
chmod 02770 ${cfg.mailDirectory}
'';
junkMailboxes = builtins.attrNames (lib.filterAttrs (n: v: v ? "specialUse" && v.specialUse == "Junk") dovecot2Cfg.mailboxes);
junkMailboxNumber = builtins.length junkMailboxes;
# The assertion guarantees that there is exactly one Junk mailbox.
junkMailboxName =
if junkMailboxNumber == 1
then builtins.elemAt junkMailboxes 0
else "";
in {
options.mailsystem.dovecot.dhparamSize = lib.mkOption {
type = lib.types.int;
default = 2048;
description = "The bit size for the prime that is used during a Diffie-Hellman key exchange by dovecot.";
};
config = lib.mkIf cfg.enable {
assertions =
[
{
assertion = junkMailboxNumber == 1;
message = "mailnix requires exactly one dovecot mailbox with the 'special use' flag to 'Junk' (${builtins.toString junkMailboxNumber} have been found)";
}
]
++ lib.mapAttrsToList (
user: value: {
assertion = value.hashedPasswordFile != null;
message = "A file containing the hashed password for user ${user} needs to be set.";
}
)
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;
enablePAM = false;
# TODO: enable quota and setup quota warnings
#enableQuota = true;
mailUser = cfg.vmailUserName;
mailGroup = cfg.vmailGroupName;
mailLocation = "maildir:~/Maildir";
sslServerCert = sslCertPath;
sslServerKey = sslKeyPath;
enableLmtp = true;
# enable managesieve
protocols = ["sieve"];
pluginSettings = {
sieve = "file:~/sieve;active=~/.dovecot.sieve";
};
sieve = {
extensions = [
"fileinto"
"mailbox"
];
scripts.after = builtins.toFile "spam.sieve" ''
require "fileinto";
require "mailbox";
if header :is "X-Spam" "Yes" {
fileinto :create "${junkMailboxName}";
stop;
}
'';
pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "learn-ham.sh"
"exec ${pkgs.rspamd}/bin/rspamc -h ${rspamdControllerSocket} learn_ham")
(pkgs.writeShellScriptBin "learn-spam.sh"
"exec ${pkgs.rspamd}/bin/rspamc -h ${rspamdControllerSocket} learn_spam")
];
};
imapsieve.mailbox = [
{
name = junkMailboxName;
causes = ["COPY" "APPEND"];
before = ./dovecot/report-spam.sieve;
}
{
name = "*";
from = junkMailboxName;
causes = ["COPY"];
before = ./dovecot/report-ham.sieve;
}
];
# TODO: move configuration to default.nix?
mailboxes = {
Drafts = {
auto = "subscribe";
specialUse = "Drafts";
};
Junk = {
auto = "subscribe";
specialUse = "Junk";
};
Trash = {
auto = "subscribe";
specialUse = "Trash";
};
Sent = {
auto = "subscribe";
specialUse = "Sent";
};
};
extraConfig = ''
service imap-login {
inet_listener imap {
# Using starttls for encryption shifts the responsibility for
# encrypted communication over to the client in the face of MITM.
# However, it has shown that clients are often not enforcing it.
port = 0 # clients should use starttls instaed
}
inet_listener imaps {
port = 993
ssl = yes
}
}
protocol imap {
mail_max_userip_connections = 100
mail_plugins = $mail_plugins imap_sieve
}
mail_access_groups = ${cfg.vmailGroupName}
ssl = required
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes
service lmtp {
unix_listener dovecot-lmtp {
user = ${postfixCfg.user}
group = ${postfixCfg.group}
mode = 0600
}
}
recipient_delimiter = +
lmtp_save_to_detail_mailbox = no
protocol lmtp {
mail_plugins = $mail_plugins sieve
}
# Passwords stored among two passwd-files: One for users allowed to
# change their password and one for any other user (mostly system
# users) with immutable passwords.
passdb {
driver = passwd-file
args = ${dovecotDynamicPasswdFile}
}
passdb {
driver = passwd-file
args = ${staticPasswdFile}
}
userdb {
driver = passwd-file
args = ${userdbFile}
default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}/%{domain}/%{username}
}
service auth {
unix_listener auth {
user = ${postfixCfg.user}
group = ${postfixCfg.group}
mode = 0660
}
}
auth_mechanisms = plain login
namespace inbox {
separator = /
inbox = yes
}
lda_mailbox_autosubscribe = yes
lda_mailbox_autocreate = yes
'';
};
security.dhparams.params.dovecot2.bits = cfg.dovecot.dhparamSize;
systemd.services.dovecot2 = {
preStart = ''
${genAuthDbsScript}
${genMaildirScript}
'';
wants = sslCertService;
after = sslCertService;
};
systemd.services.postfix.restartTriggers = [genAuthDbsScript];
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
993 # imaps
4190 #managesieve
];
};
};
}

View file

@ -0,0 +1,15 @@
require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"];
if environment :matches "imap.mailbox" "*" {
set "mailbox" "${1}";
}
if string "${mailbox}" "Trash" {
stop;
}
if environment :matches "imap.user" "*" {
set "username" "${1}";
}
pipe :copy "learn-ham.sh" [ "${username}" ];

View file

@ -0,0 +1,7 @@
require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"];
if environment :matches "imap.user" "*" {
set "username" "${1}";
}
pipe :copy "learn-spam.sh" [ "${username}" ];

11
mailsystem/kresd.nix Normal file
View file

@ -0,0 +1,11 @@
{
config,
lib,
...
}: let
cfg = config.mailsystem;
in {
config = lib.mkIf cfg.enable {
services.kresd.enable = true;
};
}

View file

@ -3,18 +3,31 @@
pkgs, pkgs,
lib, lib,
... ...
}: let }:
with (import ./common.nix {inherit config pkgs;}); let
cfg = config.mailsystem; cfg = config.mailsystem;
in { in {
config = lib.mkIf cfg.enable { config =
lib.mkIf cfg.enable {
services.nginx = { services.nginx = {
enable = true; enable = true;
virtualHosts."${cfg.fqdn}" = { virtualHosts."${cfg.fqdn}" =
{
forceSSL = true; forceSSL = true;
enableACME = true; enableACME = cfg.certificateScheme == "acme";
}
// lib.optionalAttrs (cfg.certificateScheme == "selfsigned") {
sslCertificate = sslCertPath;
sslCertificateKey = sslKeyPath;
}; };
}; };
networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443];
}
// lib.mkIf (cfg.enable && cfg.certificateScheme == "acme") {
security.acme.certs."${cfg.fqdn}".reloadServices = [
"postfix.service"
"dovecot2.service"
];
}; };
} }

205
mailsystem/postfix.nix Normal file
View file

@ -0,0 +1,205 @@
{
config,
lib,
pkgs,
...
}:
with (import ./common.nix {inherit config pkgs;}); 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";
genPostmapsScript = pkgs.writeScript "generate-postfix-postmaps" ''
#!${pkgs.stdenv.shell}
set -euo pipefail
if (! test -d "${runtimeDir}"); then
mkdir "${runtimeDir}"
chmod 755 "${runtimeDir}"
fi
${mailnixCmd} "generate-aliases" > "${aliases_file}"
${mailnixCmd} "generate-domains" > "${virtual_domains_file}"
${mailnixCmd} "generate-denied-recipients" > "${denied_recipients_file}"
'';
submission_header_cleanup_rules = pkgs.writeText "submission_header_cleanup_rules" ''
# Removes sensitive headers from mails handed in via the submission port.
# See https://thomas-leister.de/mailserver-debian-stretch/
# Uses "pcre" style regex.
/^Received:/ IGNORE
/^X-Originating-IP:/ IGNORE
/^X-Mailer:/ IGNORE
/^User-Agent:/ IGNORE
/^X-Enigmail:/ IGNORE
# Replace the user's submitted hostname with the server's FQDN to hide the
# user's host/network.
/^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
'';
tls_protocols = "TLSv1.3, TLSv1.2, !TLSv1.1, !TLSv1, !SSLv2, !SSLv3";
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;
services.postfix = {
enable = true;
hostname = "${cfg.reverseFqdn}";
networksStyle = "host";
sslCert = sslCertPath;
sslKey = sslKeyPath;
enableSubmissions = true;
mapFiles."virtual_aliases" = aliases_file;
mapFiles."denied_recipients" = denied_recipients_file;
submissionsOptions = {
smtpd_tls_security_level = "encrypt";
smtpd_sasl_auth_enable = "yes";
smtpd_sasl_type = "dovecot";
smtpd_sasl_path = "/run/dovecot2/auth";
smtpd_sasl_security_options = "noanonymous";
smtpd_sasl_local_domain = "$myhostname";
smtpd_client_restrictions = "permit_sasl_authenticated,reject";
smtpd_sender_login_maps = mappedFile "virtual_aliases";
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";
};
config = {
mydestination = "";
recipient_delimiter = "+";
smtpd_banner = "${cfg.fqdn} ESMTP NO UCE";
disable_vrfy_command = true;
message_size_limit = toString cfg.messageSizeLimit;
virtual_uid_maps = "static:${toString cfg.vmailUID}";
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")
];
virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp";
# Avoid leakage of X-Original-To, X-Delivered-To headers between recipients
lmtp_destination_recipient_limit = "1";
# sasl with dovecot (enforce authentication via dovecot)
smtpd_sasl_type = "dovecot";
smtpd_sasl_path = "/run/dovecot2/auth";
smtpd_sasl_auth_enable = true;
smtpd_relay_restrictions = [
"permit_mynetworks"
"permit_sasl_authenticated"
"reject_unauth_destination"
];
smtpd_recipient_restrictions = [
"check_recipient_access ${mappedFile "denied_recipients"}"
];
# TLS settings, inspired by https://github.com/jeaye/nix-files
# Submission by mail clients is handled in submissionOptions
smtpd_tls_security_level = "may";
# Disable obsolete protocols
smtpd_tls_protocols = tls_protocols;
smtp_tls_protocols = tls_protocols;
smtpd_tls_mandatory_protocols = tls_protocols;
smtp_tls_mandatory_protocols = tls_protocols;
smtp_tls_ciphers = "high";
smtpd_tls_ciphers = "high";
smtp_tls_mandatory_ciphers = "high";
smtpd_tls_mandatory_ciphers = "high";
# Disable deprecated ciphers
smtpd_tls_mandatory_exclude_ciphers = tls_exclude_ciphers;
smtpd_tls_exclude_ciphers = tls_exclude_ciphers;
smtp_tls_mandatory_exclude_ciphers = tls_exclude_ciphers;
smtp_tls_exclude_ciphers = tls_exclude_ciphers;
tls_preempt_cipherlist = true;
# Allowing AUTH on a non-encrypted connection poses a security risk
smtpd_tls_auth_only = true;
# Log only a summary message on TLS handshake completion
smtpd_tls_loglevel = "1";
# Configure a non-blocking source of randomness
tls_random_source = "dev:/dev/urandom";
smtpd_milters = [
"unix:${rspamdProxySocket}"
];
# Also use milter for outgoing mails (for e.g., dkim)
non_smtpd_milters = [
"unix:${rspamdProxySocket}"
];
milter_protocol = "6";
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 = "yes";
smtpd_forbid_bare_newline_exclusions = "$mynetworks";
};
masterConfig = {
"lmtp" = {
# Add headers when delivering, see http://www.postfix.org/smtp.8.html
# D => Delivered-To, O => X-Original-To, R => Return-Path
args = ["flags=O"];
};
"submission-header-cleanup" = {
type = "unix";
private = false;
chroot = false;
maxproc = 0;
command = "cleanup";
args = ["-o" "header_checks=pcre:${submission_header_cleanup_rules}"];
};
};
};
systemd.services.postfix-setup = {
preStart = ''
${genPostmapsScript}
'';
};
systemd.services.postfix = {
wants = sslCertService;
after =
["dovecot2.service" "rspamd.service"]
++ sslCertService;
requires = ["dovecot2.service" "rspamd.service"];
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
25 # smtp
465 # submissions
];
};
};
}

27
mailsystem/redis.nix Normal file
View file

@ -0,0 +1,27 @@
{
config,
lib,
pkgs,
...
}: let
cfg = config.mailsystem;
redisCfg = config.services.redis.servers.rspamd;
rspamdCfg = config.services.rspamd;
in {
config = lib.mkIf cfg.enable {
services.redis.servers.rspamd = {
enable = true;
# Don't accept connections via tcp
port = 0;
unixSocketPerm = 600;
};
# TODO: Run commands as service user instead of as root?
systemd.services.redis-rspamd.serviceConfig.ExecStartPost =
"+"
+ pkgs.writeShellScript "redis-rspamd-postStart" ''
${pkgs.acl.bin}/bin/setfacl -m "u:${rspamdCfg.user}:x" "${builtins.dirOf redisCfg.unixSocket}"
${pkgs.acl.bin}/bin/setfacl -m "u:${rspamdCfg.user}:rw" "${redisCfg.unixSocket}"
'';
};
}

57
mailsystem/roundcube.nix Normal file
View file

@ -0,0 +1,57 @@
{
config,
lib,
pkgs,
...
}:
with (import ./common.nix {inherit config pkgs;}); let
cfg = config.mailsystem;
roundcubeCfg = config.mailsystem.roundcube;
in {
options.mailsystem.roundcube = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable roundcube in order to provide a webmail interface";
};
hostName = lib.mkOption {
type = lib.types.str;
default = cfg.fqdn;
description = "FQDN to be used by roundcube. Defaults to {option}`mailsystem.fqdn`.";
};
passwordHashingAlgorithm = lib.mkOption {
type = lib.types.str;
default = "BLF-CRYPT";
description = "Password hashing algorithm to be used with `doveadm pw`";
};
};
config = lib.mkIf (cfg.enable && roundcubeCfg.enable) {
services.roundcube = {
enable = true;
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}";
$config['smtp_user'] = "%u";
$config['smtp_pass'] = "%p";
$config['managesieve_host'] = "localhost";
$config['password_driver'] = "dovecot_passwdfile";
$config['password_confirm_current'] = true;
$config['password_minimum_length'] = 8;
$config['password_algorithm'] = "dovecot";
// 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_dovecotpw_method'] = "${roundcubeCfg.passwordHashingAlgorithm}";
$config['password_dovecotpw_with_method'] = true;
'';
};
};
}

181
mailsystem/rspamd.nix Normal file
View file

@ -0,0 +1,181 @@
{
config,
lib,
pkgs,
...
}:
with (import ./common.nix {inherit config pkgs;}); let
cfg = config.mailsystem;
dovecot2Cfg = config.services.dovecot2;
nginxCfg = config.services.nginx;
postfixCfg = config.services.postfix;
redisCfg = config.services.redis.servers.rspamd;
rspamdCfg = config.services.rspamd;
genSystemdSocketCfg = name: socketPath: additionalUsers: {
description = "rspamd ${name} worker socket";
listenStreams = [socketPath];
requiredBy = ["rspamd.service"];
socketConfig = {
Service = "rspamd.service";
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));
};
};
in {
options.mailsystem.rspamd.webUi = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
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)";
};
};
config = lib.mkIf cfg.enable {
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;
overrides = {
"classifier-bayes.conf" = {
text = ''
autolearn {
spam_threshold = 6.0 # When to learn spam (score >= threshold)
ham_threshold = -2.0 # When to learn ham (score <= threshold)
}
'';
};
"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
extended_spam_headers = true;
'';
};
"redis.conf" = {
text = ''
servers = "${redisCfg.unixSocket}";
'';
};
"worker-controller.inc" = lib.mkIf cfg.rspamd.webUi.enable {
text = ''
secure_ip = "0.0.0.0/0";
secure_ip = "::/0";
'';
};
};
workers = {
rspamd_proxy = {
bindSockets = ["systemd:rspamd-proxy.socket"];
count = 1; # Do not spawn too many processes of this type
extraConfig = ''
milter = yes; # Enable milter mode
timeout = 120s; # Needed for Milter usually
upstream "local" {
default = yes; # Self-scan upstreams are always default
self_scan = yes; # Enable self-scan
}
'';
};
controller = {
count = 1;
bindSockets = ["systemd:rspamd-controller.socket"];
extraConfig = ''
static_dir = "''${WWWDIR}"; # Serve the web UI static assets
'';
};
};
};
systemd.sockets = {
rspamd-proxy = genSystemdSocketCfg "proxy" rspamdProxySocket [postfixCfg.user];
rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket ([dovecot2Cfg.mailUser] ++ lib.optional cfg.rspamd.webUi.enable nginxCfg.user);
};
systemd.services.rspamd = {
requires = ["redis-rspamd.service"];
after = ["redis-rspamd.service"];
};
services.nginx = lib.mkIf cfg.rspamd.webUi.enable {
enable = true;
virtualHosts."${cfg.fqdn}" = {
forceSSL = true;
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;
};
};
};
}

33
mailsystem/selfsigned.nix Normal file
View file

@ -0,0 +1,33 @@
{
config,
pkgs,
lib,
...
}:
with (import ./common.nix {inherit config pkgs;}); let
cfg = config.mailsystem;
in {
config = lib.mkIf (cfg.enable && cfg.certificateScheme == "selfsigned") {
systemd.services.mailsystem-selfsigned-certificate = {
after = ["local-fs.target"];
script = ''
# Create certificates if they do not exist yet
dir="${certificateDirectory}"
fqdn="${cfg.fqdn}"
[[ $fqdn == /* ]] && fqdn=$(< "$fqdn")
key="${sslKeyPath}"
cert="${sslCertPath}"
if [[ ! -f $key || ! -f $cert ]]; then
mkdir -p "$dir"
(umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 4096) &&
"${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" -days 3650 -out "$cert"
fi
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = true;
};
};
};
}

806
pkgs/mailnix/Cargo.lock generated Normal file
View file

@ -0,0 +1,806 @@
# 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"

11
pkgs/mailnix/Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[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"

22
pkgs/mailnix/src/cli.rs Normal file
View file

@ -0,0 +1,22 @@
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,
}

226
pkgs/mailnix/src/config.rs Normal file
View file

@ -0,0 +1,226 @@
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

@ -0,0 +1,67 @@
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(())
}

33
pkgs/mailnix/src/main.rs Normal file
View file

@ -0,0 +1,33 @@
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

@ -0,0 +1,32 @@
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"));
}

212
tests/aliases.nix Normal file
View file

@ -0,0 +1,212 @@
{pkgs, ...}:
with (import ./common/lib.nix {inherit pkgs;}); let
lib = pkgs.lib;
accounts = {
"normal" = {
address = "user1@example.com";
password = "secret-password1";
};
"normal2" = {
address = "user2@example.com";
password = "secret-password2;";
};
"alias" = {
address = "user3@example.com";
password = "secret-password3";
};
"multi-alias1" = {
address = "multi-alias1@example.com";
password = "secret-password4;";
};
"multi-alias2" = {
address = "multi-alias2@example.com";
password = "secret-password5;";
};
"catchall" = {
address = "catchall@example.com";
password = "secret-password6;";
};
"otherdomain" = {
address = "otherdomain@example.com";
password = "secret-password7;";
};
};
in
pkgs.nixosTest {
name = "aliases";
nodes = {
server = {pkgs, ...}: {
imports = [./common/server.nix];
environment.systemPackages = with pkgs; [netcat];
mailsystem = {
fqdn = "mail.example.com";
domains = ["example.com" "aliased.com" "otherdomain.com"];
accounts = mkAccounts accounts;
virtualAliases = {
# domain aliases
"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;
};
};
};
client = {...}: {
imports = [./common/client.nix];
};
};
testScript = {nodes, ...}: let
serverAddr = nodes.server.networking.primaryIPAddress;
clientAddr = nodes.client.networking.primaryIPAddress;
smtpSettings = {
address = serverAddr;
port = 465;
};
sendMail = mkSendMail smtpSettings accounts;
recvMail = mkRecvMail serverAddr accounts;
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("send mail from aliased address"):
client.succeed("${sendMail "alias" "alias@example.com" accounts."normal".address ''
Subject: Testmail1
Hello User1,
this is a mail dispatched using an alias instead of the normal address.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "normal"} >&2")
with subtest("receive mail on aliased address"):
client.succeed("${sendMail "normal" "" "alias@example.com" ''
Subject: Testmail2
Hello alias-User,
this mail should reach you on your aliased address alias@example.com.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "alias"} >&2")
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 1 when no new mail is available
# (as alias is the more specific address, catchall shouldn't receive the mail)
client.fail("${recvMail "catchall"} >&2")
with subtest("receive mail on all accounts with same alias"):
client.succeed("${sendMail "normal" "" "multi-alias@example.com" ''
Subject: Testmail3
Hello multi-alias-Users,
this mail should reach you on your aliased address multi-alias@example.com.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "multi-alias1"} >&2")
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "multi-alias2"} >&2")
with subtest("send mail to catchAll-alias"):
# send email to non-existent account
client.succeed("${sendMail "normal" "" "somerandomaddress@example.com" ''
Subject: Catchall-Test
Hello Catchall-User,
this is mail is directed at an address with no explicit user-account behind it.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "catchall"} >&2")
with subtest("send mail from catchAll-account with a non-existing account behind"):
# send email from non-existent account
client.succeed("${sendMail "catchall" "somerandomaddress@example.com" accounts."normal2".address ''
Subject: Catchall-Test2
Hello User2,
this is mail is sent from an address with no explicit user-account behind it.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "normal2"} >&2")
with subtest("catchAll-account cannot send mail from an address with an existing account behind"):
# send email to non-existent account
client.fail("${sendMail "catchall" accounts."normal".address accounts."normal2".address ''
Subject: Catchall-Test3
Hello User2,
this is mail should not be possible to be sent as it is dispatched by a catchall-account using an address with a user-account behind it.
''}")
with subtest("send mail from aliased address of other domain"):
client.succeed("${sendMail "otherdomain" "user@otherdomain.com" accounts."normal".address ''
Subject: Dispatch from other domain
Hello User1,
this is a mail dispatched using an alias to a different domain instead of the normal address.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "normal"} >&2")
with subtest("receive mail on aliased address of other domain"):
client.succeed("${sendMail "normal" "" "user@otherdomain.com" ''
Subject: Reception from other domain
Hello otherdomain-User,
this mail should reach you on your aliased address user@otherdomain.com.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "otherdomain"} >&2")
with subtest("receiving mail on aliased domain using normal account"):
client.succeed("${sendMail "normal" "" "user2@aliased.com" ''
Subject: aliasedDomain with normal account
Hello User2,
this is mail is sent to you by using your address @example.org.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "normal2"} >&2")
with subtest("receiving mail on aliased domain using catchall-account"):
client.succeed("${sendMail "normal" "" "somerandomaddress@aliased.com" ''
Subject: aliasedDomain using catchall-account
Hello Catchall-User,
this is mail is sent to you by using an address without any user-account behind it for neither @example.com nor @aliased.com.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "catchall"} >&2")
with subtest("sending mail from aliased domain fails"):
client.fail("${sendMail "normal" "user1@aliased.com" accounts."normal2".address ''
Subject: aliasedDomain
Hello User2,
this mail should not be dispatched to you as I'm using "my" address @aliased.com.
''}")
'';
}

109
tests/basic.nix Normal file
View file

@ -0,0 +1,109 @@
{pkgs, ...}:
with (import ./common/lib.nix {inherit pkgs;}); let
accounts = {
"normal" = {
address = "user1@example.com";
password = "secret-password1";
};
"normal2" = {
address = "user2@example.com";
password = "secret-password2";
};
"system" = {
address = "system@example.com";
password = "secret-password3";
isSystemUser = true;
};
};
in
pkgs.nixosTest {
name = "basic";
nodes = {
server = {pkgs, ...}: {
imports = [./common/server.nix];
environment.systemPackages = with pkgs; [netcat];
mailsystem = {
fqdn = "mail.example.com";
domains = ["example.com"];
accounts = mkAccounts accounts;
};
};
client = {...}: {
imports = [./common/client.nix];
};
};
testScript = {nodes, ...}: let
serverAddr = nodes.server.networking.primaryIPAddress;
clientAddr = nodes.client.networking.primaryIPAddress;
smtpSettings = {
address = serverAddr;
port = 465;
};
sendMail = mkSendMail smtpSettings accounts;
recvMail = mkRecvMail serverAddr accounts;
cfg = nodes.server.mailsystem;
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("imap works and retrieves no new mails"):
# fetchmail returns EXIT_CODE 1 when no new mail is available
client.succeed("${recvMail "normal"} || [ $? -eq 1 ] >&2")
with subtest("send succeeds for normal user"):
client.succeed("${sendMail "normal" "" accounts."normal2".address ''
Message-ID: <123456asdf@host.local.network>
Subject: Testmail1
Hello User2,
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 "normal2"} >&2")
with subtest("mail header contains no sensitive information"):
client.fail("grep '${clientAddr}' $HOME/mail/*")
client.succeed("grep '^Message-ID:.*@${cfg.fqdn}>$' $HOME/mail/*")
with subtest("mail header contains correct fqdn in received from"):
client.succeed("grep 'Received: from ${cfg.fqdn}' $HOME/mail/*")
with subtest("user cannot forge from-address"):
client.fail("${sendMail "normal" "someotheraddress@example.com" accounts."normal2".address ''
Subject: I actually do not own this from-address
Hello User2,
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"}
'';
}

17
tests/common/client.nix Normal file
View file

@ -0,0 +1,17 @@
{pkgs, ...}: {
config = {
# added to the environment for manual verification via interactiveDriver if necessary
environment.systemPackages = with pkgs; [fetchmail msmtp procmail openssl];
systemd.tmpfiles.settings."10-mailtest" = let
dirPerms = {
user = "root";
mode = "0700";
};
in {
"/root/mail".d = dirPerms;
"/root/.procmailrc"."L+".argument = "${pkgs.writeText ".procmailrc" ''
DEFAULT=$HOME/mail
''}";
};
};
}

77
tests/common/lib.nix Normal file
View file

@ -0,0 +1,77 @@
{pkgs, ...}: let
lib = pkgs.lib;
in rec {
waitForRspamd = node: let
inherit (import ../../mailsystem/common.nix {inherit (node) config pkgs;}) rspamdProxySocket;
in "set +e; timeout 1 ${node.nixpkgs.pkgs.netcat}/bin/nc -U ${rspamdProxySocket} < /dev/null; [ $? -eq 124 ]";
mkHashedPasswordFile = password:
pkgs.runCommand "mk-password-hash-${password}" {
buildInputs = [pkgs.mkpasswd];
inherit password;
} ''
echo "$password" | mkpasswd -sm bcrypt > $out
'';
mkAccounts = accounts:
lib.concatMapAttrs (_: account: {
${account.address} =
{
hashedPasswordFile = "${mkHashedPasswordFile account.password}";
}
// builtins.removeAttrs account ["address" "password"];
})
accounts;
mkSendMail = smtpSettings: accounts: accountName: fromAddr: recipient: body: let
account = accounts.${accountName};
senderAddr =
if fromAddr == ""
then account.address
else fromAddr;
msmtprc = pkgs.writeText "msmtprc" ''
account default
auth on
tls on
tls_starttls off
tls_certcheck off
host ${smtpSettings.address}
port ${toString smtpSettings.port}
from ${senderAddr}
user ${account.address}
password ${account.password}
'';
mail = pkgs.writeText "mail-${account.address}-${recipient}" ''
From: <${account.address}>
To: <${recipient}>
${body}
'';
in "${pkgs.msmtp}/bin/msmtp -C ${msmtprc} ${recipient} < ${mail} >&2";
pendingPostqueue = "[ \"$(postqueue -p)\" != \"Mail queue is empty\" ]";
cleanupMail = "rm $HOME/mail/*";
# mkRecvMail requires procmail to be setup correctly. This is ensured by
# importing ./server.nix
mkRecvMail = imapAddr: accounts: accountName: let
mkFetchmailRcScript = imapAddr: account:
pkgs.writeScript "mk-fetchmailrc-${account.address}" ''
umask 077
readonly out=$(mktemp)
cat <<EOF > "$out"
poll ${imapAddr} with proto IMAP
user '${account.address}' there with password '${account.password}' is 'root' here
mda procmail
EOF
echo $out
'';
fetchmailrc = mkFetchmailRcScript imapAddr accounts.${accountName};
in "${pkgs.fetchmail}/bin/fetchmail -f $(${fetchmailrc}) --ssl --nosslcertck -v";
checkLogs = node: ''
${node}.fail("journalctl -u postfix | grep -i error >&2")
${node}.fail("journalctl -u postfix | grep -i warning >&2")
${node}.fail("journalctl -u dovecot2 | grep -i error >&2")
${node}.fail("journalctl -u dovecot2 | grep -i warning >&2")
'';
}

26
tests/common/server.nix Normal file
View file

@ -0,0 +1,26 @@
{lib, ...}: {
imports = [./../../mailsystem];
config = {
virtualisation.memorySize = 1024;
mailsystem = {
enable = true;
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
'';
};
}

50
tests/internal.nix Normal file
View file

@ -0,0 +1,50 @@
{pkgs, ...}:
pkgs.nixosTest {
name = "internal";
nodes.machine = {...}: {
imports = [./common/server.nix];
mailsystem = {
fqdn = "mail.example.com";
domains = ["example.com"];
accounts = {};
vmailUserName = "vmail";
vmailGroupName = "vmail";
vmailUID = 5000;
};
};
testScript = {nodes, ...}: let
pkgs = nodes.machine.nixpkgs.pkgs;
in ''
machine.start()
machine.wait_for_unit("multi-user.target")
with subtest("imap is only available via port 993 and is encrypted"):
machine.wait_for_closed_port(143)
machine.wait_for_open_port(993)
machine.succeed(
"echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:993 | grep 'New, TLS'"
)
with subtest("smtp is only available via port 465 and is encrypted"):
machine.wait_for_closed_port(587)
machine.wait_for_open_port(465)
machine.succeed(
"echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:465 | grep 'New, TLS'"
)
with subtest("`postfix check` succeeds"):
machine.succeed(
"${pkgs.postfix}/bin/postfix check"
)
with subtest("vmail uid is set correctly"):
machine.succeed(
"[ $(getent passwd vmail | cut -d: -f3) -eq 5000 ]"
)
with subtest("vmail gid is set correctly"):
machine.succeed(
"[ $(getent group vmail | cut -d: -f3) -eq 5000 ]"
)
'';
}

178
tests/rspamd.nix Normal file
View file

@ -0,0 +1,178 @@
{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/*")
'';
}