Compare commits
44 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95d920ae67 | |||
| f5bf117314 | |||
| 27d388e1c8 | |||
| 4c2eed3421 | |||
| 8c96ece585 | |||
| f110705435 | |||
| 2db35aed07 | |||
| 6052072c3f | |||
| 41f8b16a42 | |||
| 01e63f4bab | |||
| 6f8bcdf9c0 | |||
| 79b2ec800e | |||
| 3a9b2c8b59 | |||
| c1b19d6e33 | |||
| 955a0ec8ba | |||
| ce2784e17d | |||
| a1e87f70fa | |||
| faf6f549b0 | |||
| 28796695af | |||
| 30532bbfca | |||
| 55183f5585 | |||
| de330a87a4 | |||
| 88d2b387c7 | |||
| cbdbb94512 | |||
| 1b26a41aaf | |||
| 35aeb19b24 | |||
| 53e2b9f621 | |||
| e6e91b775a | |||
| 5f49caec49 | |||
| 92d0a6e1f8 | |||
| 457d91bcca | |||
| e4fa3bee38 | |||
| 6f1964e6f2 | |||
| d5107df08d | |||
| 617b116f4c | |||
| 84542be242 | |||
| e185d301ff | |||
| 8a64eb9287 | |||
| c8a44b9b48 | |||
| aff4f9117f | |||
| aacf9a9b8c | |||
| 9149f03384 | |||
| b805502099 | |||
| c738037669 |
29 changed files with 3082 additions and 71 deletions
17
.github/workflows/test.yml
vendored
Normal file
17
.github/workflows/test.yml
vendored
Normal 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
116
flake.lock
generated
|
|
@ -1,5 +1,20 @@
|
|||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1748047550,
|
||||
"narHash": "sha256-t0qLLqb4C1rdtiY8IFRH5KIapTY/n3Lqt57AmxEv9mk=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "b718a78696060df6280196a6f992d04c87a16aef",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
|
|
@ -23,11 +38,11 @@
|
|||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1730504689,
|
||||
"narHash": "sha256-hgmguH29K2fvs9szpq2r3pz2/8cJd2LPS+b4tfNFCwE=",
|
||||
"lastModified": 1743550720,
|
||||
"narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "506278e768c2a08bec68eb62932193e341f55c90",
|
||||
"rev": "c621e8422220273271f52058f618c94e405bb0f5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -36,10 +51,32 @@
|
|||
"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": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"pre-commit-hooks-nix",
|
||||
"git-hooks-nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
|
|
@ -59,64 +96,47 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1731755305,
|
||||
"narHash": "sha256-v5P3dk5JdiT+4x69ZaB18B8+Rcu3TIOrcdG4uEX7WZ8=",
|
||||
"lastModified": 1748162331,
|
||||
"narHash": "sha256-rqc2RKYTxP3tbjA+PB3VMRQNnjesrT0pEofXQTrMsS8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "057f63b6dc1a2c67301286152eb5af20747a9cb4",
|
||||
"rev": "7c43f080a7f28b2774f3b3f43234ca11661bf334",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-24.11",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"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": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"flake-parts": "flake-parts",
|
||||
"git-hooks-nix": "git-hooks-nix",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
61
flake.nix
61
flake.nix
|
|
@ -2,11 +2,14 @@
|
|||
description = "An opinionated Nixos Mailsystem";
|
||||
|
||||
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.inputs.nixpkgs-lib.follows = "nixpkgs";
|
||||
pre-commit-hooks-nix.url = "github:cachix/git-hooks.nix";
|
||||
pre-commit-hooks-nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
treefmt-nix.url = "github:numtide/treefmt-nix";
|
||||
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
crane.url = "github:ipetkov/crane";
|
||||
git-hooks-nix.url = "github:cachix/git-hooks.nix";
|
||||
git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
|
|
@ -21,7 +24,9 @@
|
|||
"aarch64-linux"
|
||||
];
|
||||
imports = [
|
||||
inputs.pre-commit-hooks-nix.flakeModule
|
||||
flake-parts.flakeModules.easyOverlay
|
||||
inputs.treefmt-nix.flakeModule
|
||||
inputs.git-hooks-nix.flakeModule
|
||||
];
|
||||
|
||||
perSystem = {
|
||||
|
|
@ -31,10 +36,33 @@
|
|||
pkgs,
|
||||
system,
|
||||
...
|
||||
}: {
|
||||
devShells.default = pkgs.mkShell {
|
||||
}: let
|
||||
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; [
|
||||
alejandra
|
||||
self'.formatter.outPath # Add all formatters to environment
|
||||
mailnix
|
||||
];
|
||||
shellHook = ''
|
||||
${config.pre-commit.installationScript}
|
||||
|
|
@ -42,8 +70,25 @@
|
|||
};
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,40 @@
|
|||
{config, ...}: let
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
...
|
||||
}: let
|
||||
cfg = config.mailsystem;
|
||||
in rec {
|
||||
sslCertPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem";
|
||||
sslKeyPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem";
|
||||
sslCertService = ["acme-finished-${cfg.fqdn}.target"];
|
||||
certificateDirectory = "/var/certs";
|
||||
sslCertPath =
|
||||
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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
{lib, ...}: {
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}: let
|
||||
cfg = config.mailsystem;
|
||||
in {
|
||||
options.mailsystem = {
|
||||
enable = lib.mkEnableOption "nixos-mailsystem";
|
||||
|
||||
|
|
@ -14,6 +20,32 @@
|
|||
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 {
|
||||
type = lib.types.int;
|
||||
default = 5000;
|
||||
|
|
@ -37,10 +69,178 @@
|
|||
default = "/var/vmail";
|
||||
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 = [
|
||||
./dovecot.nix
|
||||
./kresd.nix
|
||||
./nginx.nix
|
||||
./postfix.nix
|
||||
./redis.nix
|
||||
./roundcube.nix
|
||||
./rspamd.nix
|
||||
./selfsigned.nix
|
||||
./user.nix
|
||||
];
|
||||
}
|
||||
|
|
|
|||
281
mailsystem/dovecot.nix
Normal file
281
mailsystem/dovecot.nix
Normal 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
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
15
mailsystem/dovecot/report-ham.sieve
Normal file
15
mailsystem/dovecot/report-ham.sieve
Normal 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}" ];
|
||||
7
mailsystem/dovecot/report-spam.sieve
Normal file
7
mailsystem/dovecot/report-spam.sieve
Normal 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
11
mailsystem/kresd.nix
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}: let
|
||||
cfg = config.mailsystem;
|
||||
in {
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.kresd.enable = true;
|
||||
};
|
||||
}
|
||||
|
|
@ -3,18 +3,31 @@
|
|||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}: let
|
||||
}:
|
||||
with (import ./common.nix {inherit config pkgs;}); let
|
||||
cfg = config.mailsystem;
|
||||
in {
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts."${cfg.fqdn}" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
config =
|
||||
lib.mkIf cfg.enable {
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts."${cfg.fqdn}" =
|
||||
{
|
||||
forceSSL = 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
205
mailsystem/postfix.nix
Normal 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
27
mailsystem/redis.nix
Normal 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
57
mailsystem/roundcube.nix
Normal 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
181
mailsystem/rspamd.nix
Normal 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
33
mailsystem/selfsigned.nix
Normal 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
806
pkgs/mailnix/Cargo.lock
generated
Normal 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
11
pkgs/mailnix/Cargo.toml
Normal 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
22
pkgs/mailnix/src/cli.rs
Normal 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
226
pkgs/mailnix/src/config.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
67
pkgs/mailnix/src/dovecot.rs
Normal file
67
pkgs/mailnix/src/dovecot.rs
Normal 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
33
pkgs/mailnix/src/main.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
32
pkgs/mailnix/src/postfix.rs
Normal file
32
pkgs/mailnix/src/postfix.rs
Normal 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
212
tests/aliases.nix
Normal 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
109
tests/basic.nix
Normal 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
17
tests/common/client.nix
Normal 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
77
tests/common/lib.nix
Normal 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
26
tests/common/server.nix
Normal 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
50
tests/internal.nix
Normal 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
178
tests/rspamd.nix
Normal 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/*")
|
||||
'';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue