Compare commits

..

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
23 changed files with 1751 additions and 267 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

61
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": {
@ -42,15 +57,14 @@
"gitignore": "gitignore", "gitignore": "gitignore",
"nixpkgs": [ "nixpkgs": [
"nixpkgs" "nixpkgs"
], ]
"nixpkgs-stable": "nixpkgs-stable"
}, },
"locked": { "locked": {
"lastModified": 1733318908, "lastModified": 1747372754,
"narHash": "sha256-SVQVsbafSM1dJ4fpgyBqLZ+Lft+jcQuMtEL3lQWx2Sk=", "narHash": "sha256-2Y53NGIX2vxfie1rOW0Qb86vjRZ7ngizoo+bnXU9D9k=",
"owner": "cachix", "owner": "cachix",
"repo": "git-hooks.nix", "repo": "git-hooks.nix",
"rev": "6f4e2a2112050951a314d2733a994fbab94864c6", "rev": "80479b6ec16fefd9c1db3ea13aeb038c60530f46",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -82,38 +96,23 @@
}, },
"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",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1730741070,
"narHash": "sha256-edm8WG19kWozJ/GqyYx2VjW99EdhjKwbY3ZwdlPAAlo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d063c1dd113c91ab27959ba540c0d9753409edf3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"git-hooks-nix": "git-hooks-nix", "git-hooks-nix": "git-hooks-nix",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
@ -127,11 +126,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1733440889, "lastModified": 1748243702,
"narHash": "sha256-qKL3vjO+IXFQ0nTinFDqNq/sbbnnS5bMI1y0xX215fU=", "narHash": "sha256-9YzfeN8CB6SzNPyPm2XjRRqSixDopTapaRsnTpXUEY8=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "50862ba6a8a0255b87377b9d2d4565e96f29b410", "rev": "1f3f7b784643d488ba4bf315638b2b0a4c5fb007",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

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

View file

@ -1,4 +1,8 @@
{config, ...}: let {
config,
pkgs,
...
}: let
cfg = config.mailsystem; cfg = config.mailsystem;
in rec { in rec {
certificateDirectory = "/var/certs"; certificateDirectory = "/var/certs";
@ -17,6 +21,17 @@ in rec {
then ["acme-finished-${cfg.fqdn}.target"] then ["acme-finished-${cfg.fqdn}.target"]
else ["mailsystem-selfsigned-certificate.service"]; 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"; dovecotDynamicStateDir = "/var/lib/dovecot";
dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd"; dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd";

View file

@ -92,27 +92,6 @@ in {
''; '';
}; };
aliases = lib.mkOption {
type = with lib.types; listOf types.str;
example = ["abuse@example.com" "postmaster@example.com"];
default = [];
description = ''
A list of aliases of this login account.
Note: Use list entries like "@example.com" to create a catchAll
that allows sending from all email addresses in these domain.
'';
};
quota = lib.mkOption {
type = with lib.types; nullOr types.str;
default = null;
example = "2G";
description = ''
Sets quota for the this login account. The size has to be suffixed with `k/M/G/T`.
Not setting a quota results in a standard quota of `100G`.
'';
};
isSystemUser = lib.mkOption { isSystemUser = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = false; default = false;
@ -122,7 +101,17 @@ in {
account will be rejected. 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; config.name = lib.mkDefault name;
})); }));
example = { example = {
@ -133,54 +122,102 @@ in {
hashedPassword = "$6$oE0ZNv2n7Vk9gOf$9xcZWCCLGdMflIfuA0vR1Q1Xblw6RZqPrP94mEit2/81/7AKj2bqUai5yPyWE.QYPyv6wLMHZvjw3Rlg7yTCD/"; hashedPassword = "$6$oE0ZNv2n7Vk9gOf$9xcZWCCLGdMflIfuA0vR1Q1Xblw6RZqPrP94mEit2/81/7AKj2bqUai5yPyWE.QYPyv6wLMHZvjw3Rlg7yTCD/";
}; };
}; };
description = "All available login account for the mailsystem."; description = "All available accounts for the mailsystem.";
default = {}; default = {};
}; };
virtualDomainAliases = lib.mkOption { virtualAliases = lib.mkOption {
type = with lib.types; attrsOf str;
example = {
"@aliasdomain.com" = "@domain.com";
};
description = ''
Virtual aliasing of domains. A virtual alias `"@aliasdomain.com" = "@domain.com"`
means that all mail directed at `aliasdomain.com` are forwarded to `domain.com`.
This also entails, that any account or alias of `domain.com` is partially valid
for `aliasdomain.com`. For example, `user@domain.com` can receive mails at
`user@aliasdomain.com`. However, if `user@domain.com` shall be able to dispatch
mails using `user@aliasdomain.com`, an explicit alias needs to be configured.
'';
default = {};
};
extraVirtualAliases = lib.mkOption {
type = let type = let
isAccount = value: builtins.elem value (builtins.attrNames cfg.accounts);
isDomain = value: !(lib.hasInfix "@" value) && (builtins.elem value cfg.domains);
account = lib.mkOptionType { account = lib.mkOptionType {
name = "Login Account"; name = "Mail Account";
check = account: builtins.elem account (builtins.attrNames cfg.accounts); check = isAccount;
};
accountOrDomain = lib.mkOptionType {
name = "Mail Account or Domain";
check = value: (isAccount value) || (isDomain value);
}; };
in in
with lib.types; attrsOf (either account (nonEmptyListOf account)); with lib.types; attrsOf (either (nonEmptyListOf account) accountOrDomain);
example = { example = {
"info@example.com" = "user1@example.com"; "info@example.com" = "user1@example.com";
"postmaster@example.com" = "user1@example.com"; "postmaster@example.com" = "user1@example.com";
"abuse@example.com" = "user1@example.com"; "abuse@example.com" = "user1@example.com";
"multi@example.com" = ["user1@example.com" "user2@example.com"]; "multi@example.com" = ["user1@example.com" "user2@example.com"];
"aliasdomain.com" = "domain.com";
}; };
description = '' description = ''
Virtual Aliases. A virtual alias `"info@example.com" = "user1@example.com"` means that Virtual account and domain aliases. A virtual alias means, that all mail directed
all mail to `info@example.com` is forwarded to `user1@example.com`. Note at a given target are forwarded to the specified other destinations, too.
that it is expected that `postmaster@example.com` and `abuse@example.com` is
forwarded to some valid email address. (Alternatively you can create login For account aliases, this means that, e.g., `"user1@example.com"` receives all mail
accounts for `postmaster` and (or) `abuse`). Furthermore, it also allows sent to `"info@example.com"`. In addition, `"user1@example.com"` is also able to
the user `user1@example.com` to send emails as `info@example.com`. impersonate `"info@example.com"` when sending mails. It is also possible to create
It's also possible to create an alias for multiple accounts. In this an alias for multiple accounts. In this example, all mails for `"multi@example.com"`
example all mails for `multi@example.com` will be forwarded to both will be forwarded to both
`user1@example.com` and `user2@example.com`. `"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 = {}; 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 { certificateScheme = lib.mkOption {
type = lib.types.enum ["acme" "selfsigned"]; type = lib.types.enum ["acme" "selfsigned"];
default = "acme"; default = "acme";

View file

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

View file

@ -4,19 +4,22 @@
lib, lib,
... ...
}: }:
with (import ./common.nix {inherit config;}); let with (import ./common.nix {inherit config pkgs;}); let
cfg = config.mailsystem; cfg = config.mailsystem;
in { in {
config = config =
lib.mkIf cfg.enable { lib.mkIf cfg.enable {
services.nginx = { services.nginx = {
enable = true; enable = true;
virtualHosts."${cfg.fqdn}" = { virtualHosts."${cfg.fqdn}" =
forceSSL = true; {
enableACME = cfg.certificateScheme == "acme"; forceSSL = true;
sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath; enableACME = cfg.certificateScheme == "acme";
sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath; }
}; // 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];

View file

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

View file

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

View file

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

View file

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

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"));
}

View file

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

View file

@ -9,6 +9,11 @@ with (import ./common/lib.nix {inherit pkgs;}); let
address = "user2@example.com"; address = "user2@example.com";
password = "secret-password2"; password = "secret-password2";
}; };
"system" = {
address = "system@example.com";
password = "secret-password3";
isSystemUser = true;
};
}; };
in in
pkgs.nixosTest { pkgs.nixosTest {
@ -77,6 +82,27 @@ in
I'm pretending to be someotheraddress@example.com and the mailserver should reject this attempt. 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"): with subtest("server issues no warnings nor errors"):
${checkLogs "server"} ${checkLogs "server"}
''; '';

View file

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

View file

@ -1,13 +1,26 @@
{...}: { {lib, ...}: {
imports = [./../../mailsystem]; imports = [./../../mailsystem];
config = { config = {
virtualisation.memorySize = 1024; virtualisation.memorySize = 1024;
mailsystem = { mailsystem = {
enable = true; enable = true;
roundcube.enable = false;
rspamd.webUi.enable = false;
certificateScheme = "selfsigned"; 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
'';
}; };
} }

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/*")
'';
}