{
config,
lib,
pkgs,
...
}: let
cfg = config.vhack.stalwart-mail;
signaturesByDomain =
(builtins.map ({name, ...}: {
"if" = "sender_domain = '${name}'";
"then" = "'${name}'";
})
(lib.attrsToList cfg.security.dkimKeys))
++ [{"else" = false;}];
maybeVerificationMode =
if cfg.security != null
then cfg.security.verificationMode
else "disable";
in {
config.services.stalwart-mail.settings = lib.mkIf cfg.enable {
# https://www.rfc-editor.org/rfc/rfc6376.html#section-3.3
signature = let
signatures =
lib.mapAttrs (keyDomain: keyConfig: {
private-key = "%{file:${config.age.secrets."stalwartMail${keyDomain}".path}}%";
domain = keyDomain;
selector = "mail";
headers = ["From" "To" "Cc" "Date" "Subject" "Message-ID" "Organization" "MIME-Version" "Content-Type" "In-Reply-To" "References" "List-Id" "User-Agent" "Thread-Topic" "Thread-Index"];
algorithm = keyConfig.keyAlgorithm;
# How do we canonicalize the headers/body?
# https://www.rfc-editor.org/rfc/rfc6376.html#section-3.4
canonicalization = "simple/simple";
expire = "50d";
report = true;
})
cfg.security.dkimKeys;
in
lib.mkIf (cfg.security != null) signatures;
auth = let
# NOTE(@bpeetz): We disable all the checks if the `listener` is submissions, because the
# user's email client will obviously not have the right IP address to pass SPF or
# IPREV. It will also not be able to sign the message with DKIM (as we has to key). <2025-02-25>
ifNotSmpt = valueTrue: valueFalse: [
{
"if" = "listener != 'submissions'";
"then" = valueTrue;
}
{"else" = valueFalse;}
];
in {
iprev = {
verify = ifNotSmpt maybeVerificationMode "disable";
};
spf = {
verify = {
ehlo = ifNotSmpt maybeVerificationMode "disable";
mail-from = ifNotSmpt maybeVerificationMode "disable";
};
};
dmarc = {
verify = ifNotSmpt maybeVerificationMode "disable";
};
arc = {
seal = lib.mkIf (cfg.security != null) signaturesByDomain;
verify = ifNotSmpt maybeVerificationMode "disable";
};
dkim = {
verify = ifNotSmpt maybeVerificationMode "disable";
# Ignore insecure dkim signed messages (i.e., messages containing both
# signed and appended not-signed content.)
strict = true;
sign =
lib.mkIf (cfg.security != null) signaturesByDomain;
};
};
report = {
domain = "${cfg.fqdn}";
submitter = "'${cfg.fqdn}'";
analysis = {
addresses = ["dmarc@*" "abuse@*"];
forward = true;
store = "30d";
};
tls.aggregate = {
from-name = "'TLS Report'";
from-address = "'noreply-tls@${cfg.fqdn}'";
org-name = "'Foss Syndicate Mail Handling'";
contact-info = "'${cfg.admin}'";
send = "daily";
max-size = 26214400; # 25 MiB
sign = lib.mkIf (cfg.security != null) "'${cfg.fqdn}'";
};
dmarc = {
aggregate = {
from-name = "'DMARC Report'";
from-address = "'noreply-dmarc@${cfg.fqdn}'";
org-name = "'Foss Syndicate Mail Handling'";
contact-info = "'${cfg.admin}'";
send = "weekly";
max-size = 26214400; # 25MiB
sign = lib.mkIf (cfg.security != null) "'${cfg.fqdn}'";
};
from-name = "'Report Subsystem'";
from-address = "'noreply-dmarc@${cfg.fqdn}'";
subject = "'DMARC Authentication Failure Report'";
send = "1/1d";
sign = lib.mkIf (cfg.security != null) signaturesByDomain;
};
spf = {
from-name = "'Report Subsystem'";
from-address = "'noreply-spf@${cfg.fqdn}'";
subject = "'SPF Authentication Failure Report'";
send = "1/1d";
sign = lib.mkIf (cfg.security != null) signaturesByDomain;
};
dkim = {
from-name = "'Report Subsystem'";
from-address = "'noreply-dkim@${cfg.fqdn}'";
subject = "'DKIM Authentication Failure Report'";
send = "1/1d";
sign = lib.mkIf (cfg.security != null) signaturesByDomain;
};
dsn = {
from-name = "'Mail Delivery Subsystem'";
from-address = "'MAILER-DAEMON@${cfg.fqdn}'";
sign = lib.mkIf (cfg.security != null) signaturesByDomain;
};
};
queue = {
schedule = {
retry = "[2m, 5m, 10m, 15m, 30m, 1h, 2h]";
notify = "[2h, 7h, 1d, 3d]";
expire = "5d";
};
outbound = {
tls = {
starttls =
if maybeVerificationMode == "strict"
then "require"
else "optional";
allow-invalid-certs = false;
ip-strategy = "ipv6_then_ipv4";
mta-sts =
if maybeVerificationMode == "strict"
then "require"
else "optional";
};
};
};
resolver = {
type = "system";
preserve-intermediates = true;
concurrency = 2;
timeout = "5s";
attempts = 2;
try-tcp-on-error = true;
public-suffix = [
"file://${pkgs.publicsuffix-list}/share/publicsuffix/public_suffix_list.dat"
];
};
spam-filter = {
enable = true;
header = {
status = {
enable = true;
name = "X-Spam-Status";
};
result = {
enable = true;
name = "X-Spam-Result";
};
};
bayes = {
enable = true;
# Learn from users putting mail into JUNK or taking mail out of it.
account = {
enable = true;
};
};
# Fetch the newest spam-filter rules not from github, but from the nix
# package.
resource = "file://${cfg.package.passthru.spamfilter}/spam-filter.toml";
auto-update = false;
};
webadmin = {
# Fetch the newest webadmin bundle not from github, but from the nix
# package.
resource = "file://${cfg.package.passthru.webadmin}/webadmin.zip";
auto-update = false;
path = "/var/cache/stalwart-mail";
};
session = {
milter = {
# TODO: Add this <2025-02-07>
# "clamav" = {
# enable = true;
# hostname = "127.0.0.1";
# port = 15112;
# tls = false;
# allow-invalid-certs = false;
# };
};
ehlo = {
require = true;
};
rcpt = {
directory = "'in-memory'";
catch-all = true;
subaddressing = true;
};
data = {
spam-filter = true;
add-headers = {
received = true;
received-spf = true;
auth-results = true;
message-id = true;
date = true;
return-path = true;
delivered-to = true;
};
auth = {
mechanisms = ["LOGIN" "PLAIN"];
directory = "'in-memory'";
require = true;
must-match-sender = true;
errors = {
total = 3;
wait = "5s";
};
};
};
extensions = {
pipelining = true;
chunking = true;
requiretls = true;
no-soliciting = "";
dsn = [
{
"if" = "!is_empty(authenticated_as)";
"then" = true;
}
{"else" = false;}
];
future-release = [
{
"if" = "!is_empty(authenticated_as)";
"then" = "7d";
}
{"else" = false;}
];
deliver-by = [
{
"if" = "!is_empty(authenticated_as)";
"then" = "15d";
}
{"else" = false;}
];
mt-priority = [
{
"if" = "!is_empty(authenticated_as)";
"then" = "mixer";
}
{"else" = false;}
];
vrfy = [
{
"if" = "!is_empty(authenticated_as)";
"then" = true;
}
{"else" = false;}
];
expn = [
{
"if" = "!is_empty(authenticated_as)";
"then" = true;
}
{"else" = false;}
];
};
};
jmap = {
account = {
purge.frequency = "0 0 *";
};
protocol = {
changes.max-history = "14d";
};
email = {
# NOTE(@bpeetz): We probably want to enable the auto-deletion of emails in
# the "Junk" and "Deleted" items mail folders, but this should be
# communicated to the users. <2025-02-07>
auto-expunge = false;
};
mailbox = {
max-depth = 50;
max-name-length = 255;
};
folders = let
mkFolder = name: {
inherit name;
create = true;
subscribe = true;
};
in {
inbox = mkFolder "INBOX";
drafts = mkFolder "DRAFTS";
sent = mkFolder "SENT";
trash = mkFolder "TRASH";
archive = mkFolder "ARCHIVE";
junk = mkFolder "JUNK";
shared = {name = "SHARED";};
};
};
imap = {
auth = {
# Allow password login over non tls connection
allow-plain-text = false;
};
};
server = {
hostname = cfg.fqdn;
listener = {
# TODO(@bpeetz): Add this <2025-02-08>
# # HTTP (used for jmap)
# "http" = {
# bind = ["[::]:8080"];
# protocol = "http";
# tls.implicit = true;
# };
# IMAP
"imap" = {
bind = ["[::]:993"];
protocol = "imap";
tls.implicit = true;
};
# SMTP
"submissions" = {
bind = ["[::]:465"];
protocol = "smtp";
tls.implicit = true;
};
"input" = {
bind = ["[::]:25"];
protocol = "smtp";
tls = {
enable = true;
# Require an explicit `STARTTLS`
implicit = false;
};
};
# # POP3 (should be disabled, unless there is a real reason to use it)
# "pop3" = {
# bind = ["[::]:995"];
# protocol = "pop3";
# tls.implicit = true;
# };
# # LMTP
# "lmtp" = {
# bind = ["[::]:24"];
# protocol = "lmtp";
# };
# ManageSieve
"managesieve" = {
bind = ["[::]:4190"];
protocol = "managesieve";
tls.implicit = true;
};
};
tls = {
enable = true;
# Expect the client connection to be encrypted from the start (i.e.,
# without STARTTLS)
implicit = true;
certificate = "default";
};
# TODO(@bpeetz): Configure that <2025-02-07>
# http = {
# url = "";
# allowed-endpoint = ["404"];
# };
auto-ban = {
# Ban if the same IP fails to login 10 times in a day
rate = "10/1d";
# Ban the login for an user account, if different IP-Addresses tried and
# failed to login 100 times in single day
auth.rate = "100/1d";
abuse.rate = "35/1d";
loiter.rate = "150/1d";
scan.rate = "150/1d";
};
cache = let
MiB = 1024 * 1024;
in {
access-token.size = 10 * MiB;
http-auth.size = 1 * MiB;
permission.size = 5 * MiB;
account.size = 10 * MiB;
mailbox.size = 10 * MiB;
thread.size = 10 * MiB;
bayes.size = 10 * MiB;
dns = {
txt.size = 5 * MiB;
mx.size = 5 * MiB;
ptr.size = 1 * MiB;
ipv4.size = 5 * MiB;
ipv6.size = 5 * MiB;
tlsa.size = 1 * MiB;
mta-sts.size = 1 * MiB;
rbl.size = 5 * MiB;
};
};
};
tracer = {
# NOTE(@bpeetz):
# We are using the console logger, because that has nice color output.
# Simply using the console should be fine, as systemd pipes that to the journal
# either way. <2025-02-08>
console = {
enable = true;
ansi = true;
level = "info";
type = "console";
};
};
store = {
"rocksdb-data" = {
type = "rocksdb";
path = "${cfg.dataDirectory}/storage/data";
compression = "lz4";
# Perform “maintenance” every day at 3 am local time.
purge.frequency = "0 3 *";
};
"rocksdb-full-text-search" = {
type = "rocksdb";
path = "${cfg.dataDirectory}/storage/full-text-search";
compression = "lz4";
# Perform “maintenance” every day at 2 am local time.
purge.frequency = "0 2 *";
};
"file-system" = {
type = "fs";
path = "${cfg.dataDirectory}/storage/blobs";
depth = 2;
compression = "lz4";
# Perform “maintenance” every day at 5:30 am local time.
purge.frequency = "30 5 *";
};
"redis" = {
type = "redis";
redis-type = "single";
urls = "unix://${config.services.redis.servers."stalwart-mail".unixSocket}";
timeout = "10s";
# Perform “maintenance” every day at 2:30 am local time.
purge.frequency = "30 2 *";
};
};
storage = {
# PostgreSQL is an option, but this is recommended for single node
# configurations.
data = "rocksdb-data";
# We could also re-use the data storage backend for that.
blob = "file-system";
full-text.default-language = "en";
fts = "rocksdb-full-text-search";
directory = "in-memory";
lookup = "redis";
# NOTE(@bpeetz): This will encrypt all emails with the users pgp key (if it
# can be determined.) This is a wonderful feature, but quite tiresome, if
# the user intends to read their email without a their pgp key present (for
# example via their smartphone.) <2025-02-07>
encryption.enable = false;
};
directory."in-memory" = {
type = "memory";
inherit (cfg) principals;
};
certificate = {
"default" = {
cert = "%{file:${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem}%";
private-key = "%{file:${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem}%";
default = true;
};
};
};
}