I think I finally understand how the postfix and dovecot
Some checks failed
/ check (push) Failing after 2m13s
/ dev-shell (push) Successful in 2m41s
/ rust-packages (push) Successful in 14m7s
/ terraform-providers (push) Successful in 13m11s
/ systems (push) Successful in 53m57s

integration works now. Now the regular accounts should work again,
and with proper handling of catch all aliases for domains, as well
as handling postmaster and abuse emails being forwarded to khs
with proper auto tagging of the mails.
This commit is contained in:
Kaare Hoff Skovgaard 2025-08-01 00:53:09 +02:00
parent 9c4a751fe0
commit 6ac55b7e44
Signed by: khs
GPG key ID: C7D890804F01E9F0
9 changed files with 95 additions and 189 deletions

View file

@ -35,9 +35,9 @@ let
runtimeInputs = [ pkgs.systemd ]; runtimeInputs = [ pkgs.systemd ];
text = '' text = ''
chown ${lib.escapeShellArg template.owner}:${lib.escapeShellArg template.group} ${lib.escapeShellArg template.destination} chown ${lib.escapeShellArg template.owner}:${lib.escapeShellArg template.group} ${lib.escapeShellArg template.destination}
${template.exec}
${restartUnits template.restartUnits} ${restartUnits template.restartUnits}
${reloadOrRestartUnits template.reloadOrRestartUnits} ${reloadOrRestartUnits template.reloadOrRestartUnits}
${template.exec}
''; '';
meta = { meta = {
mainProgram = "restart-command"; mainProgram = "restart-command";

View file

@ -34,9 +34,13 @@
]; ];
}; };
"mx.kaareskovgaard.net" = { "mx.kaareskovgaard.net" = {
postmaster = "kaare+postmaster@agerlin-skovgaard.dk";
abuse = "kaare+abuse@agerlin-skovgaard.dk";
domains = [ domains = [
"agerlin-skovgaard.dk" "agerlin-skovgaard.dk"
"agerlinskovgaard.dk" "agerlinskovgaard.dk"
"k.agerlin-skovgaard.dk"
"k.agerlinskovgaard.dk"
]; ];
accounts = import ./users.nix; accounts = import ./users.nix;
}; };

View file

@ -7,22 +7,39 @@
let let
cfg = config.khscodes."mx.kaareskovgaard.net"; cfg = config.khscodes."mx.kaareskovgaard.net";
passDbFile = "/run/secret/dovecot/passwd"; passDbFile = "/run/secret/dovecot/passwd";
isCatchAllAddress = addr: lib.strings.hasPrefix "@" addr;
bogusPasswdFile = pkgs.writeTextFile { bogusPasswdFile = pkgs.writeTextFile {
name = "bogus-passwd"; name = "bogus-passwd";
text = "$6$1234"; text = "$6$1234";
}; };
userDbAccountOpts =
name: account:
lib.strings.concatStringsSep " " (
(lib.lists.optional (account.quota != null) "userdb_quota_rule=*:storage=${account.quota}")
++ [ "userdb_user=${name}" ]
);
userDbLine =
addr: name: account:
"${addr}:::::::${userDbAccountOpts name account}";
userDbFile = pkgs.writeTextFile { userDbFile = pkgs.writeTextFile {
name = "userdb"; name = "userdb";
text = ''''; text = lib.strings.concatStringsSep "\n" (
}; lib.lists.flatten (
accountPrimaryEmail = lib.mapAttrsToList (
name: account: if account.isLdapAccount then lib.lists.head account.aliases else name;
accountAlternativeEmails =
name: account: name: account:
if account.isLdapAccount then [
lib.lists.ifilter0 (idx: _: idx > 0) account.aliases (userDbLine name name account)
else ]
account.aliases; ++ (lib.lists.map (addr: userDbLine addr name account) (
# Don't include catch all addresses, they become aliases in postfix
lib.lists.filter (addr: !(isCatchAllAddress addr)) account.addresses
))
) cfg.accounts
)
);
};
firstNonCatchAllAddress =
account: lib.lists.head (lib.lists.filter (addr: !(isCatchAllAddress addr)) account.addresses);
accountOption = lib.khscodes.mkSubmodule { accountOption = lib.khscodes.mkSubmodule {
description = "mail account"; description = "mail account";
options = { options = {
@ -32,7 +49,7 @@ let
description = "Username"; description = "Username";
}; };
aliases = lib.mkOption { addresses = lib.mkOption {
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
example = [ example = [
"abuse@example.com" "abuse@example.com"
@ -40,31 +57,9 @@ let
]; ];
default = [ ]; default = [ ];
description = '' description = ''
A list of aliases of this login account. A list of addresses for the account.
Note: Use list entries like "@example.com" to create a catchAll Note: Use list entires like "@example.com" to create a catchAll
that allows sending from all email addresses in these domain. that allows sending from an email address in these domains.
'';
};
aliasesRegexp = lib.mkOption {
type = lib.types.listOf lib.types.str;
example = [ ''/^tom\..*@domain\.com$/'' ];
default = [ ];
description = ''
Same as {option}`mailserver.aliases` but using PCRE (Perl compatible regex).
'';
};
catchAll = lib.mkOption {
type = lib.types.listOf (lib.types.enum config.mailserver.domains);
example = [
"example.com"
"example2.com"
];
default = [ ];
description = ''
For which domains should this account act as a catch all?
Note: Does not allow sending from all addresses of these domains.
''; '';
}; };
@ -145,6 +140,31 @@ let
) )
# Just make sure the file is not empty # Just make sure the file is not empty
+ "\n"; + "\n";
catchAllAliases = lib.strings.concatStringsSep "\n" (
lib.lists.flatten (
lib.attrsets.mapAttrsToList (
name: account:
let
regularEmailAddr = firstNonCatchAllAddress account;
catchAlls = lib.lists.filter isCatchAllAddress account.addresses;
in
lib.lists.map (addr: "${addr} ${regularEmailAddr}") catchAlls
) cfg.accounts
)
);
postmasterAliases = lib.strings.concatStringsSep "\n" (
lib.lists.map (domain: "postmaster@${domain} ${cfg.postmaster}") cfg.domains
);
abuseAliases = lib.strings.concatStringsSep "\n" (
lib.lists.map (domain: "abuse@${domain} ${cfg.abuse}") cfg.domains
);
extraVirtualAliases = lib.strings.concatStringsSep "\n" [
catchAllAliases
postmasterAliases
abuseAliases
];
in in
{ {
options.khscodes."mx.kaareskovgaard.net".accounts = lib.mkOption { options.khscodes."mx.kaareskovgaard.net".accounts = lib.mkOption {
@ -156,14 +176,12 @@ in
loginAccounts = lib.attrsets.mapAttrs (name: value: { loginAccounts = lib.attrsets.mapAttrs (name: value: {
inherit (value) inherit (value)
name name
aliasesRegexp
catchAll
quota quota
sieveScript sieveScript
sendOnly sendOnly
sendOnlyRejectMessage sendOnlyRejectMessage
aliases
; ;
aliases = value.addresses;
hashedPasswordFile = bogusPasswdFile; hashedPasswordFile = bogusPasswdFile;
}) cfg.accounts; }) cfg.accounts;
extraVirtualAliases = { }; extraVirtualAliases = { };
@ -228,9 +246,18 @@ in
# with our own. # with our own.
preStart = lib.mkAfter '' preStart = lib.mkAfter ''
cp ${passDbFile} /run/dovecot2/passwd cp ${passDbFile} /run/dovecot2/passwd
# cp ${userDbFile} /run/dovecot2/userdb cp ${userDbFile} /run/dovecot2/userdb
''; '';
}; };
# The stuff simple nixos-mailserver sets up here creates aliases to the accounts listed,
# but I use usernames for the accounts, which ends up creating forwards to stuff like khs@mx.kaareskovgaard.net, which causes issues.
# Instead I just want the entries in virtual_mailbox_maps for all regular addresses, which will leave the addresses alone (ie. not rewrite them)
# and send them off via lmtp to dovecot2. Dovecot2 has a fixed userdb which accepts all regular email addresses for every login and maps it to that
# users mailbox. The only thing not handled this way is catchAll addresses (and regexes, which I do not support at the moment, and hopefully won't need).
services.postfix = {
virtual = lib.mkForce extraVirtualAliases;
config.virtual_alias_maps = lib.mkForce [ "hash:/etc/postfix/virtual" ];
};
# This prevents local usernames without domain names to get rewritten. # This prevents local usernames without domain names to get rewritten.
# services.postfix.submissionOptions = submissionOptions; # services.postfix.submissionOptions = submissionOptions;
# services.postfix.submissionsOptions = submissionOptions; # services.postfix.submissionsOptions = submissionOptions;

View file

@ -1,140 +0,0 @@
{
lib,
accounts,
accountPrimaryEmail,
accountAlternativeEmails,
extraVirtualAliases ? { },
}:
let
# I would like to not use emails as usernames, but this is not something postfix is particularly happy with,
# as far as I can see. I *think* I can do this by randomly selecting an address, ie. the first, as the "primary"
# address, and then map all other addresses to that one. Then create a virtual mailbox map that maps that back
# to the username before delivering it to lmtp. All of this just worked when using ldap, but that meant
# not using code configured accounts set up with opentofu as well, and would also make the email delivery
# take a hard dependency on ldap (and not just the login process).
# Most of these functions are almost the same as what is found inside nixos-simple-mailserver,
# just modified with this in mind.
# Merge several lookup tables. A lookup table is a attribute set where
# - the key is an address (user@example.com) or a domain (@example.com)
# - the value is a list of addresses
mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables;
# lookupTableToString :: Map String [String] -> String
lookupTableToString =
attrs:
let
valueToString = value: lib.concatStringsSep ", " value;
in
lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs
);
# attrsToLookupTable :: Map String (Either String [ String ]) -> Map String [String]
attrsToLookupTable =
aliases:
let
lookupTables = lib.mapAttrsToList (from: to: { "${from}" = to; }) aliases;
in
mergeLookupTables lookupTables;
# valiases_postfix :: Map String [String]
valiases_postfix = mergeLookupTables (
lib.flatten (
lib.mapAttrsToList (
name: value:
let
to = name;
in
map (from: { "${from}" = to; }) (
(accountAlternativeEmails name value) ++ lib.singleton (accountPrimaryEmail name value)
)
) accounts
)
);
# catchAllPostfix :: Map String [String]
catchAllPostfix = mergeLookupTables (
lib.flatten (
lib.mapAttrsToList (
name: value:
let
to = name;
in
map (from: { "@${from}" = to; }) value.catchAll
) accounts
)
);
# mailserverExtraVirtualAliases :: Map String (Map String)
mailserverExtraVirtualAliases = lib.attrsets.mapAttrs (
name: value:
let
value = if lib.lists.isList value then value else [ value ];
to = name;
in
{
name = to;
value = lib.lists.map (
addr:
assert (lib.attrsets.hasAttr addr accounts);
accountPrimaryEmail name accounts."${addr}"
) value;
}
) extraVirtualAliases;
# extra_valiases_postfix :: Map String [String]
extra_valiases_postfix = attrsToLookupTable extraVirtualAliases;
# all_valiases_postfix :: Map String [String]
all_valiases_postfix = mergeLookupTables [
valiases_postfix
extra_valiases_postfix
];
# valiases_file :: Path
valiases_file =
let
content = lookupTableToString (mergeLookupTables [
all_valiases_postfix
catchAllPostfix
]);
in
builtins.toFile "valias" content;
regex_valiases_postfix = mergeLookupTables (
lib.flatten (
lib.mapAttrsToList (
name: value:
let
to = name;
in
map (from: { "${from}" = to; }) value.aliasesRegexp
) accounts
)
);
regex_valiases_file =
let
content = lookupTableToString regex_valiases_postfix;
in
builtins.toFile "regex_valias" content;
# vaccounts_file :: Path
# see
# https://blog.grimneko.de/2011/12/24/a-bunch-of-tips-for-improving-your-postfix-setup/
# for details on how this file looks. By using the same file as valiases,
# every alias is owned (uniquely) by its user.
# The user's own address is already in all_valiases_postfix.
vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix);
regex_vaccounts_file = builtins.toFile "regex_vaccounts" (
lookupTableToString regex_valiases_postfix
);
in
{
inherit
valiases_file
regex_valiases_file
vaccounts_file
regex_vaccounts_file
mailserverExtraVirtualAliases
;
}

View file

@ -14,6 +14,12 @@ in
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
default = [ ]; default = [ ];
}; };
postmaster = lib.mkOption {
type = lib.types.str;
};
abuse = lib.mkOption {
type = lib.types.str;
};
}; };
imports = [ imports = [
inputs.simple-nixos-mailserver.nixosModules.mailserver inputs.simple-nixos-mailserver.nixosModules.mailserver

View file

@ -192,6 +192,8 @@ in
''; '';
restartUnits = [ restartUnits = [
"rspamd.service" "rspamd.service"
# See note on why there's a condition set on postfix for this to make sense.
"postfix.service"
]; ];
} }
]) cfg.domains ]) cfg.domains
@ -222,7 +224,12 @@ in
# without this postfix won't forward the mails to rspamd to be signed. # without this postfix won't forward the mails to rspamd to be signed.
non_smtpd_milters = [ "unix:/run/rspamd/rspamd-milter.sock" ]; non_smtpd_milters = [ "unix:/run/rspamd/rspamd-milter.sock" ];
}; };
systemd.services.rspamd = { systemd.services = {
# Postfix technically does not need these files to exist. But without
# configuring this here, it will attempt to start (ie. getting restarted) many times during startup
# and eventually hit a limit on the number of times. So ensuring it cannot try to start, should prevent this.
postfix.unitConfig.ConditionPathExists = domainKeyPaths;
rspamd = {
unitConfig = { unitConfig = {
ConditionPathExists = domainKeyPaths; ConditionPathExists = domainKeyPaths;
}; };
@ -231,4 +238,5 @@ in
}; };
}; };
}; };
};
} }

View file

@ -16,7 +16,6 @@ in
{{- with secret "kanidm/data/apps/dovecot" -}} {{- with secret "kanidm/data/apps/dovecot" -}}
scope = email openid profile scope = email openid profile
username_attribute = username username_attribute = username
debug = yes
introspection_url = https://dovecot:{{ .Data.data.basic_secret }}@login.kaareskovgaard.net/oauth2/token/introspect introspection_url = https://dovecot:{{ .Data.data.basic_secret }}@login.kaareskovgaard.net/oauth2/token/introspect
introspection_mode = post introspection_mode = post
{{- end -}} {{- end -}}

View file

@ -2,9 +2,11 @@
"khs" = { "khs" = {
isLdapAccount = true; isLdapAccount = true;
name = "Kaare Agerlin Skovgaard"; name = "Kaare Agerlin Skovgaard";
aliases = [ addresses = [
"kaare@agerlin-skovgaard.dk" "kaare@agerlin-skovgaard.dk"
"kaare@agerlinskovgaard.dk" "kaare@agerlinskovgaard.dk"
"@k.agerlinskovgaard.dk"
"@k.agerlin-skovgaard.dk"
]; ];
quota = "10G"; quota = "10G";
}; };

View file

@ -42,6 +42,6 @@ fn pem_private_key_to_sodium_private_key(p: PemPrivateKeyToSodiumPrivateKey) ->
let libsodium_seed = &result[16..48]; let libsodium_seed = &result[16..48];
let keypair = libsodium_rs::crypto_sign::KeyPair::from_seed(libsodium_seed)?; let keypair = libsodium_rs::crypto_sign::KeyPair::from_seed(libsodium_seed)?;
let mut stdout = std::io::stdout(); let mut stdout = std::io::stdout();
stdout.write(keypair.secret_key.as_bytes())?; stdout.write_all(keypair.secret_key.as_bytes())?;
Ok(()) Ok(())
} }