662 lines
22 KiB
Nix
662 lines
22 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
priv_domain = config.secrets.secrets.global.domains.mail_private;
|
|
domain = config.secrets.secrets.global.domains.mail_public;
|
|
mailDomains = [
|
|
priv_domain
|
|
domain
|
|
];
|
|
mailBackupDir = "/var/cache/mail-backup";
|
|
dataDir = "/var/lib/stalwart-mail";
|
|
in
|
|
{
|
|
age.secrets.resticpasswd = {
|
|
generator.script = "alnum";
|
|
};
|
|
age.secrets.stalwartHetznerSshKey = {
|
|
generator.script = "ssh-ed25519";
|
|
};
|
|
services.restic.backups = {
|
|
main = {
|
|
user = "root";
|
|
timerConfig = {
|
|
OnCalendar = "06:00";
|
|
Persistent = true;
|
|
RandomizedDelaySec = "3h";
|
|
};
|
|
initialize = true;
|
|
passwordFile = config.age.secrets.resticpasswd.path;
|
|
hetznerStorageBox = {
|
|
enable = true;
|
|
inherit (config.secrets.secrets.global.hetzner) mainUser;
|
|
inherit (config.secrets.secrets.global.hetzner.users.stalwart-mail) subUid path;
|
|
sshAgeSecret = "stalwartHetznerSshKey";
|
|
};
|
|
paths = [
|
|
mailBackupDir
|
|
];
|
|
#pruneOpts = [
|
|
# "--keep-daily 10"
|
|
# "--keep-weekly 7"
|
|
# "--keep-monthly 12"
|
|
# "--keep-yearly 75"
|
|
#];
|
|
};
|
|
};
|
|
systemd.services.backup-mail = {
|
|
description = "Mail backup";
|
|
environment = {
|
|
STALWART_DATA = dataDir;
|
|
IDMAIL_DATA = config.services.idmail.dataDir;
|
|
BACKUP_DIR = mailBackupDir;
|
|
};
|
|
serviceConfig = {
|
|
SyslogIdentifier = "backup-mail";
|
|
Type = "oneshot";
|
|
User = "stalwart-mail";
|
|
Group = "stalwart-mail";
|
|
ExecStart = lib.getExe (
|
|
pkgs.writeShellApplication {
|
|
name = "backup-mail";
|
|
runtimeInputs = [ pkgs.sqlite ];
|
|
text = ''
|
|
sqlite3 "$STALWART_DATA/database.sqlite3" ".backup '$BACKUP_DIR/database.sqlite3'"
|
|
sqlite3 "$IDMAIL_DATA/database.sqlite3" ".backup '$BACKUP_DIR/idmail.db'"
|
|
cp -r "$STALWART_DATA/dkim" "$BACKUP_DIR/"
|
|
'';
|
|
}
|
|
);
|
|
ReadWritePaths = [
|
|
dataDir
|
|
config.services.idmail.dataDir
|
|
mailBackupDir
|
|
];
|
|
Restart = "no";
|
|
};
|
|
requiredBy = [ "restic-backups-main.service" ];
|
|
before = [ "restic-backups-main.service" ];
|
|
};
|
|
|
|
age.secrets.stalwart-admin-pw = {
|
|
generator.script = "alnum";
|
|
mode = "000";
|
|
intermediary = true;
|
|
};
|
|
|
|
age.secrets.stalwart-admin-hash = {
|
|
generator.dependencies = [ config.age.secrets.stalwart-admin-pw ];
|
|
generator.script = "argon2id";
|
|
mode = "440";
|
|
group = "stalwart-mail";
|
|
};
|
|
|
|
users.groups.acme.members = [ "stalwart-mail" ];
|
|
|
|
networking.firewall.allowedTCPPorts = [
|
|
25 # smtp
|
|
465 # submission tls
|
|
# 587 # submission starttls
|
|
993 # imap tls
|
|
# 143 # imap starttls
|
|
4190 # manage sieve
|
|
];
|
|
environment.persistence."/persist".directories = [
|
|
{
|
|
directory = dataDir;
|
|
user = "stalwart-mail";
|
|
group = "stalwart-mail";
|
|
mode = "0700";
|
|
}
|
|
];
|
|
|
|
# Needed so we don't run out of tmpfs space for large backups.
|
|
# Technically this could be cleared each boot but whatever.
|
|
environment.persistence."/state".directories = [
|
|
{
|
|
directory = mailBackupDir;
|
|
user = "stalwart-mail";
|
|
group = "stalwart-mail";
|
|
mode = "0700";
|
|
}
|
|
];
|
|
services.nginx = {
|
|
enable = true;
|
|
recommendedSetup = true;
|
|
upstreams.stalwart = {
|
|
servers."127.0.0.1:8080" = { };
|
|
extraConfig = ''
|
|
zone stalwart 64k;
|
|
keepalive 2;
|
|
'';
|
|
};
|
|
virtualHosts =
|
|
{
|
|
${domain} = {
|
|
forceSSL = true;
|
|
useACMEHost = domain;
|
|
extraConfig = ''
|
|
client_max_body_size 512M;
|
|
'';
|
|
locations."/" = {
|
|
proxyPass = "http://stalwart";
|
|
proxyWebsockets = true;
|
|
};
|
|
};
|
|
}
|
|
// lib.genAttrs
|
|
[
|
|
"autoconfig.${domain}"
|
|
"autodiscover.${domain}"
|
|
]
|
|
(_: {
|
|
forceSSL = true;
|
|
useACMEHost = domain;
|
|
locations."/".proxyPass = "http://stalwart";
|
|
});
|
|
};
|
|
systemd.services.stalwart-mail =
|
|
let
|
|
cfg = config.services.stalwart-mail;
|
|
configFormat = pkgs.formats.toml { };
|
|
configFile = configFormat.generate "stalwart-mail.toml" cfg.settings;
|
|
in
|
|
{
|
|
preStart = lib.mkAfter (
|
|
''
|
|
cat ${configFile} > /run/stalwart-mail/config.toml
|
|
cat ${config.age.secrets.stalwart-admin-hash.path} \
|
|
| tr -d '\n' > /run/stalwart-mail/admin-hash
|
|
|
|
mkdir -p /var/lib/stalwart-mail/dkim
|
|
''
|
|
# Generate DKIM keys if necessary
|
|
+ lib.concatLines (
|
|
lib.forEach mailDomains (domain: ''
|
|
if [[ ! -e /var/lib/stalwart-mail/dkim/rsa-${domain}.key ]]; then
|
|
echo "Generating DKIM key for ${domain} (rsa)"
|
|
${lib.getExe pkgs.openssl} genrsa -traditional -out /var/lib/stalwart-mail/dkim/rsa-${domain}.key 2048
|
|
fi
|
|
if [[ ! -e /var/lib/stalwart-mail/dkim/ed25519-${domain}.key ]]; then
|
|
echo "Generating DKIM key for ${domain} (ed25519)"
|
|
${lib.getExe pkgs.openssl} genpkey -algorithm ed25519 -out /var/lib/stalwart-mail/dkim/ed25519-${domain}.key
|
|
fi
|
|
'')
|
|
)
|
|
);
|
|
|
|
serviceConfig = {
|
|
RuntimeDirectory = "stalwart-mail";
|
|
ReadWritePaths = [ config.services.idmail.dataDir ];
|
|
ExecStart = lib.mkForce [
|
|
""
|
|
"${cfg.package}/bin/stalwart-mail --config=/run/stalwart-mail/config.toml"
|
|
];
|
|
RestartSec = "60"; # Retry every minute
|
|
};
|
|
};
|
|
|
|
services.stalwart-mail = {
|
|
enable = true;
|
|
settings =
|
|
let
|
|
ifthen = field: data: {
|
|
"if" = field;
|
|
"then" = data;
|
|
};
|
|
otherwise = value: { "else" = value; };
|
|
is-smtp = ifthen "listener = 'smtp'";
|
|
is-authenticated = data: {
|
|
"if" = "!is_empty(authenticated_as)";
|
|
"then" = data;
|
|
};
|
|
in
|
|
lib.mkForce {
|
|
config.local-keys = [
|
|
"store.*"
|
|
"directory.*"
|
|
"tracer.*"
|
|
"server.*"
|
|
"!server.blocked-ip.*"
|
|
"!server.allowed-ip.*"
|
|
"authentication.fallback-admin.*"
|
|
"cluster.node-id"
|
|
"storage.data"
|
|
"storage.blob"
|
|
"storage.lookup"
|
|
"storage.fts"
|
|
"storage.directory"
|
|
"lookup.default.hostname"
|
|
"certificate.*"
|
|
"auth.dkim.*"
|
|
"signature.*"
|
|
];
|
|
|
|
authentication.fallback-admin = {
|
|
user = "admin";
|
|
secret = "%{file:/run/stalwart-mail/admin-hash}%";
|
|
};
|
|
|
|
tracer.stdout = {
|
|
# Do not use the built-in journal tracer, as it shows much less auxiliary
|
|
# information for the same loglevel
|
|
type = "stdout";
|
|
level = "info";
|
|
ansi = false; # no colour markers to journald
|
|
enable = true;
|
|
};
|
|
|
|
store.db = {
|
|
type = "sqlite";
|
|
path = "${dataDir}/database.sqlite3";
|
|
};
|
|
|
|
store.idmail = {
|
|
type = "sqlite";
|
|
path = "${config.services.idmail.dataDir}/idmail.db";
|
|
query =
|
|
let
|
|
# Remove comments from SQL and make it single-line
|
|
toSingleLineSql =
|
|
sql:
|
|
lib.concatStringsSep " " (
|
|
lib.forEach (lib.flatten (lib.split "\n" sql)) (
|
|
line: lib.optionalString (builtins.match "^[[:space:]]*--.*" line == null) line
|
|
)
|
|
);
|
|
in
|
|
{
|
|
# "SELECT name, type, secret, description, quota FROM accounts WHERE name = ?1 AND active = true";
|
|
name = toSingleLineSql ''
|
|
SELECT
|
|
m.address AS name,
|
|
'individual' AS type,
|
|
m.password_hash AS secret,
|
|
m.address AS description,
|
|
0 AS quota
|
|
FROM mailboxes AS m
|
|
JOIN domains AS d ON m.domain = d.domain
|
|
JOIN users AS u ON m.owner = u.username
|
|
WHERE m.address = ?1
|
|
AND m.active = true
|
|
AND d.active = true
|
|
AND u.active = true
|
|
'';
|
|
# "SELECT member_of FROM group_members WHERE name = ?1";
|
|
members = "";
|
|
# "SELECT name FROM emails WHERE address = ?1";
|
|
recipients = toSingleLineSql ''
|
|
-- It is important that we return only one value here, but in theory three UNIONed
|
|
-- queries are guaranteed to be distinct. This is because a mailbox address
|
|
-- and alias address can never be the same, their cross-table uniqueness is guaranteed on insert.
|
|
-- The catch-all union can also only return something if @domain.tld is given as a parameter,
|
|
-- which is invalid for aliases and mailboxes.
|
|
--
|
|
-- Nonetheless, it may be beneficial to allow an alias to override an existing mailbox,
|
|
-- so we can have send-only mailboxes which have their incoming mail redirected somewhere else.
|
|
-- Therefore, we make sure to order the query by (aliases -> mailboxes -> catch all) and only return the
|
|
-- highest priority one.
|
|
SELECT name FROM (
|
|
-- Select the target of a matching alias (if any)
|
|
-- but make sure that all related parts are active.
|
|
SELECT a.target AS name, 1 AS rowOrder
|
|
FROM aliases AS a
|
|
JOIN domains AS d ON a.domain = d.domain
|
|
JOIN (
|
|
-- To check whether the owner is active we need to make a subquery
|
|
-- because the owner could be a user or mailbox
|
|
SELECT username
|
|
FROM users
|
|
WHERE active = true
|
|
UNION
|
|
SELECT m.address AS username
|
|
FROM mailboxes AS m
|
|
JOIN users AS u ON m.owner = u.username
|
|
WHERE m.active = true
|
|
AND u.active = true
|
|
) AS u ON a.owner = u.username
|
|
WHERE a.address = ?1
|
|
AND a.active = true
|
|
AND d.active = true
|
|
-- Select the primary mailbox address if it matches and
|
|
-- all related parts are active.
|
|
UNION
|
|
SELECT m.address AS name, 2 AS rowOrder
|
|
FROM mailboxes AS m
|
|
JOIN domains AS d ON m.domain = d.domain
|
|
JOIN users AS u ON m.owner = u.username
|
|
WHERE m.address = ?1
|
|
AND m.active = true
|
|
AND d.active = true
|
|
AND u.active = true
|
|
-- Finally, select any catch_all address that would catch this.
|
|
-- Again make sure everything is active.
|
|
UNION
|
|
SELECT d.catch_all AS name, 3 AS rowOrder
|
|
FROM domains AS d
|
|
JOIN mailboxes AS m ON d.catch_all = m.address
|
|
JOIN users AS u ON m.owner = u.username
|
|
WHERE ?1 = ('@' || d.domain)
|
|
AND d.active = true
|
|
AND m.active = true
|
|
AND u.active = true
|
|
ORDER BY rowOrder, name ASC
|
|
LIMIT 1
|
|
)
|
|
'';
|
|
# "SELECT address FROM emails WHERE name = ?1 AND type != 'list' ORDER BY type DESC, address ASC";
|
|
emails = toSingleLineSql ''
|
|
-- Return first the primary address, then any aliases.
|
|
SELECT address FROM (
|
|
-- Select primary address, if active
|
|
SELECT m.address AS address, 1 AS rowOrder
|
|
FROM mailboxes AS m
|
|
JOIN domains AS d ON m.domain = d.domain
|
|
JOIN users AS u ON m.owner = u.username
|
|
WHERE m.address = ?1
|
|
AND m.active = true
|
|
AND d.active = true
|
|
AND u.active = true
|
|
-- Select any active aliases
|
|
UNION
|
|
SELECT a.address AS address, 2 AS rowOrder
|
|
FROM aliases AS a
|
|
JOIN domains AS d ON a.domain = d.domain
|
|
JOIN (
|
|
-- To check whether the owner is active we need to make a subquery
|
|
-- because the owner could be a user or mailbox
|
|
SELECT username
|
|
FROM users
|
|
WHERE active = true
|
|
UNION
|
|
SELECT m.address AS username
|
|
FROM mailboxes AS m
|
|
JOIN users AS u ON m.owner = u.username
|
|
WHERE m.active = true
|
|
AND u.active = true
|
|
) AS u ON a.owner = u.username
|
|
WHERE a.target = ?1
|
|
AND a.active = true
|
|
AND d.active = true
|
|
-- Select the catch-all marker, if we are the target.
|
|
UNION
|
|
-- Order 2 is correct, it counts as an alias
|
|
SELECT ('@' || d.domain) AS address, 2 AS rowOrder
|
|
FROM domains AS d
|
|
JOIN mailboxes AS m ON d.catch_all = m.address
|
|
JOIN users AS u ON m.owner = u.username
|
|
WHERE d.catch_all = ?1
|
|
AND d.active = true
|
|
AND m.active = true
|
|
AND u.active = true
|
|
ORDER BY rowOrder, address ASC
|
|
)
|
|
'';
|
|
# "SELECT address FROM emails WHERE address LIKE '%' || ?1 || '%' AND type = 'primary' ORDER BY address LIMIT 5";
|
|
verify = toSingleLineSql ''
|
|
SELECT m.address AS address
|
|
FROM mailboxes AS m
|
|
JOIN domains AS d ON m.domain = d.domain
|
|
JOIN users AS u ON m.owner = u.username
|
|
WHERE m.address LIKE '%' || ?1 || '%'
|
|
AND m.active = true
|
|
AND d.active = true
|
|
AND u.active = true
|
|
UNION
|
|
SELECT a.address AS address
|
|
FROM aliases AS a
|
|
JOIN domains AS d ON a.domain = d.domain
|
|
JOIN (
|
|
-- To check whether the owner is active we need to make a subquery
|
|
-- because the owner could be a user or mailbox
|
|
SELECT username
|
|
FROM users
|
|
WHERE active = true
|
|
UNION
|
|
SELECT m.address AS username
|
|
FROM mailboxes AS m
|
|
JOIN users AS u ON m.owner = u.username
|
|
WHERE m.active = true
|
|
AND u.active = true
|
|
) AS u ON a.owner = u.username
|
|
WHERE a.address LIKE '%' || ?1 || '%'
|
|
AND a.active = true
|
|
AND d.active = true
|
|
ORDER BY address
|
|
LIMIT 5
|
|
'';
|
|
# "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = ?1 AND l.type = 'list' ORDER BY p.address LIMIT 50";
|
|
# XXX: We don't actually expand, but return the same address if it exists since we don't support mailing lists
|
|
expand = toSingleLineSql ''
|
|
SELECT m.address AS address
|
|
FROM mailboxes AS m
|
|
JOIN domains AS d ON m.domain = d.domain
|
|
JOIN users AS u ON m.owner = u.username
|
|
WHERE m.address = ?1
|
|
AND m.active = true
|
|
AND d.active = true
|
|
AND u.active = true
|
|
UNION
|
|
SELECT a.address AS address
|
|
FROM aliases AS a
|
|
JOIN domains AS d ON a.domain = d.domain
|
|
JOIN (
|
|
-- To check whether the owner is active we need to make a subquery
|
|
-- because the owner could be a user or mailbox
|
|
SELECT username
|
|
FROM users
|
|
WHERE active = true
|
|
UNION
|
|
SELECT m.address AS username
|
|
FROM mailboxes AS m
|
|
JOIN users AS u ON m.owner = u.username
|
|
WHERE m.active = true
|
|
AND u.active = true
|
|
) AS u ON a.owner = u.username
|
|
WHERE a.address = ?1
|
|
AND a.active = true
|
|
AND d.active = true
|
|
ORDER BY address
|
|
LIMIT 50
|
|
'';
|
|
# "SELECT 1 FROM emails WHERE address LIKE '%@' || ?1 LIMIT 1";
|
|
domains = toSingleLineSql ''
|
|
SELECT domain
|
|
FROM domains
|
|
WHERE domain = ?1
|
|
'';
|
|
};
|
|
};
|
|
|
|
storage = {
|
|
data = "db";
|
|
fts = "db";
|
|
lookup = "db";
|
|
blob = "db";
|
|
directory = "idmail";
|
|
};
|
|
|
|
directory.idmail = {
|
|
type = "sql";
|
|
store = "idmail";
|
|
columns = {
|
|
name = "name";
|
|
description = "description";
|
|
secret = "secret";
|
|
email = "email";
|
|
#quota = "quota";
|
|
class = "type";
|
|
};
|
|
};
|
|
|
|
resolver = {
|
|
type = "system";
|
|
public-suffix = [
|
|
"file://${pkgs.publicsuffix-list}/share/publicsuffix/public_suffix_list.dat"
|
|
];
|
|
};
|
|
|
|
config.resource.spam-filter = "file://${config.services.stalwart-mail.package}/etc/stalwart/spamfilter.toml";
|
|
config.resource.webadmin = "file://${config.services.stalwart-mail.package.webadmin}/webadmin.zip";
|
|
webadmin.path = "/var/cache/stalwart-mail";
|
|
|
|
certificate.default = {
|
|
cert = "%{file:${config.security.acme.certs.${domain}.directory}/fullchain.pem}%";
|
|
private-key = "%{file:${config.security.acme.certs.${domain}.directory}/key.pem}%";
|
|
default = true;
|
|
};
|
|
|
|
lookup.default.hostname = domain;
|
|
server = {
|
|
tls = {
|
|
certificate = "default";
|
|
ignore-client-order = true;
|
|
};
|
|
socket = {
|
|
nodelay = true;
|
|
reuse-addr = true;
|
|
};
|
|
listener = {
|
|
smtp = {
|
|
protocol = "smtp";
|
|
bind = "[::]:25";
|
|
};
|
|
submissions = {
|
|
protocol = "smtp";
|
|
bind = "[::]:465";
|
|
tls.implicit = true;
|
|
};
|
|
imaps = {
|
|
protocol = "imap";
|
|
bind = "[::]:993";
|
|
tls.implicit = true;
|
|
};
|
|
http = {
|
|
# jmap, web interface
|
|
protocol = "http";
|
|
bind = "[::]:8080";
|
|
url = "https://${domain}";
|
|
use-x-forwarded = true;
|
|
};
|
|
sieve = {
|
|
protocol = "managesieve";
|
|
bind = "[::]:4190";
|
|
tls.implicit = true;
|
|
};
|
|
};
|
|
};
|
|
|
|
imap = {
|
|
request.max-size = 52428800;
|
|
auth = {
|
|
max-failures = 3;
|
|
allow-plain-text = false;
|
|
};
|
|
timeout = {
|
|
authentication = "30m";
|
|
anonymous = "1m";
|
|
idle = "30m";
|
|
};
|
|
rate-limit = {
|
|
requests = "20000/1m";
|
|
concurrent = 32;
|
|
};
|
|
};
|
|
|
|
auth.dkim.sign = [
|
|
(ifthen "is_local_domain('*', sender_domain)" "['rsa-' + sender_domain, 'ed25519-' + sender_domain]")
|
|
(otherwise false)
|
|
];
|
|
|
|
signature = lib.mergeAttrsList (
|
|
lib.forEach mailDomains (domain: {
|
|
"ed25519-${domain}" = {
|
|
private-key = "%{file:/var/lib/stalwart-mail/dkim/ed25519-${domain}.key}%";
|
|
inherit domain;
|
|
selector = "ed_default";
|
|
headers = [
|
|
"From"
|
|
"To"
|
|
"Date"
|
|
"Subject"
|
|
"Message-ID"
|
|
];
|
|
algorithm = "ed25519-sha256";
|
|
canonicalization = "relaxed/relaxed";
|
|
set-body-length = false;
|
|
report = true;
|
|
};
|
|
"rsa-${domain}" = {
|
|
private-key = "%{file:/var/lib/stalwart-mail/dkim/rsa-${domain}.key}%";
|
|
inherit domain;
|
|
selector = "rsa_default";
|
|
headers = [
|
|
"From"
|
|
"To"
|
|
"Date"
|
|
"Subject"
|
|
"Message-ID"
|
|
];
|
|
algorithm = "rsa-sha256";
|
|
canonicalization = "relaxed/relaxed";
|
|
set-body-length = false;
|
|
report = true;
|
|
};
|
|
})
|
|
);
|
|
|
|
session.extensions = {
|
|
pipelining = true;
|
|
chunking = true;
|
|
requiretls = true;
|
|
no-soliciting = "";
|
|
dsn = false;
|
|
expn = [
|
|
(is-authenticated true)
|
|
(otherwise false)
|
|
];
|
|
vrfy = [
|
|
(is-authenticated true)
|
|
(otherwise false)
|
|
];
|
|
future-release = [
|
|
(is-authenticated "30d")
|
|
(otherwise false)
|
|
];
|
|
deliver-by = [
|
|
(is-authenticated "365d")
|
|
(otherwise false)
|
|
];
|
|
mt-priority = [
|
|
(is-authenticated "mixer")
|
|
(otherwise false)
|
|
];
|
|
};
|
|
|
|
# needs certificate for all domain
|
|
# Dane is better anyway
|
|
session.mta-sts.mode = "none";
|
|
session.ehlo = {
|
|
require = true;
|
|
reject-non-fqdn = [
|
|
(is-smtp true)
|
|
(otherwise false)
|
|
];
|
|
};
|
|
|
|
session.rcpt = {
|
|
catch-all = true;
|
|
relay = [
|
|
(is-authenticated true)
|
|
(otherwise false)
|
|
];
|
|
max-recipients = 25;
|
|
};
|
|
};
|
|
};
|
|
}
|