Add rspamd, ClamAV and sentinel Valkey to the mail namespace (#542)

The smarthost routes all mail through one rspamd: inbound that survived the edge gets the AI (LiteLLM Haiku) pass and is tagged for Stalwart's Junk filing; outbound from the lab and pods is DKIM-signed.

- add rspamd (milter via ClusterIP only), ClamAV and a 3-node sentinel Valkey
- tag inbound (`X-Spam`, `X-Spam-Status`, `X-Spamd-Result`), reject only viruses, tempfail when ClamAV fails
- score inbound against the hop before the edge relays (`external_relay`)
- DKIM-sign and skip GPT only for the smarthost's mynetworks sources
- elect the highest-offset Valkey on full restart; RDB, 384mb `volatile-ttl`
- read a hashed controller password (`password_hash`)

Requires `password_hash` in kv `mail/default/rspamd-controller`.

Reviewed-on: #542
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
This commit was merged in pull request #542.
This commit is contained in:
2026-10-12 22:36:55 +11:00
committed by BenVincent
parent 70dd1bd775
commit 0ee11a065b
25 changed files with 747 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
reject = 15;
add_header = 6;
greylist = null;
+8
View File
@@ -0,0 +1,8 @@
clamav {
type = "clamav";
servers = "clamav.mail.svc.cluster.local:3310";
symbol = "CLAM_VIRUS";
action = "reject";
scan_mime_parts = true;
log_clean = false;
}
+7
View File
@@ -0,0 +1,7 @@
sign_authenticated = false;
sign_local = true;
sign_inbound = false;
use_domain = "header";
allow_hdrfrom_mismatch = false;
selector = "mail";
path = "/etc/rspamd/dkim/$domain.$selector.key";
@@ -0,0 +1,8 @@
# Edge gateway public IPs (smarthost gateways.cidr): score the first hop before them.
enabled = true;
rules {
EDGE_RELAY {
strategy = "ip_map";
ip_map = ["192.0.2.1/32", "192.0.2.2/32"];
}
}
+7
View File
@@ -0,0 +1,7 @@
rules {
CLAMAV_UNAVAILABLE {
action = "soft reject";
expression = "CLAM_VIRUS_FAIL";
message = "Virus scanner unavailable, try again later";
}
}
+22
View File
@@ -0,0 +1,22 @@
enabled = true;
type = "openai";
url = "http://litellm.litellm.svc.cluster.local:4000/v1/chat/completions";
api_key = "{= env.LITELLM_API_KEY =}";
model = "claude-haiku-4-5";
model_parameters = {
"claude-haiku-4-5" = {
max_tokens = 400,
temperature = 0.0,
}
};
timeout = 10s;
# Inbound only (settings.conf disables it outbound); only uncertain scores (2 <= score < 12).
condition = <<EOD
return function(task)
local res = task:get_metric_result()
if not res then return false, 'no result' end
if res.passthrough then return false, 'passthrough' end
if res.score < 2 or res.score >= 12 then return false, 'score not uncertain' end
return true
end
EOD
+1
View File
@@ -0,0 +1 @@
enabled = false;
+1
View File
@@ -0,0 +1 @@
nrows = 5000;
+30
View File
@@ -0,0 +1,30 @@
# Outbound sources (smarthost mynetworks): DKIM-signed, scored with reject, no GPT.
127.0.0.0/8
::1
10.42.0.0/16
10.10.12.200/32
198.18.1.10/32
198.18.2.160/27
198.18.2.192/27
198.18.13.0/24
198.18.14.0/24
198.18.15.0/24
198.18.16.0/24
198.18.17.0/24
198.18.18.0/24
198.18.19.0/24
198.18.20.0/24
198.18.21.0/24
198.18.22.0/24
198.18.23.0/24
198.18.25.0/24
198.18.26.0/24
198.18.27.0/24
198.18.28.0/24
198.18.29.0/24
198.18.64.0/24
198.18.65.0/24
198.18.66.0/24
198.18.67.0/24
198.18.68.0/24
198.18.69.0/24
@@ -0,0 +1,2 @@
use = ["x-spamd-result", "x-spam-status", "x-spam-level", "authentication-results"];
skip_local = true;
+2
View File
@@ -0,0 +1,2 @@
local_addrs = "/etc/rspamd/local.d/local_networks.map";
task_timeout = 20s;
+4
View File
@@ -0,0 +1,4 @@
sentinels = "rspamd-valkey-sentinel.mail.svc.cluster.local:26379";
sentinel_masters_pattern = "^rspamd$";
sentinel_watch_time = 10s;
servers = "rspamd-valkey-0.rspamd-valkey.mail.svc.cluster.local.:6379";
+19
View File
@@ -0,0 +1,19 @@
outbound {
priority = high;
ip_map = "/etc/rspamd/local.d/local_networks.map";
apply {
symbols_disabled = ["GPT_CHECK"];
}
}
inbound {
priority = high;
ip_map = "/etc/rspamd/local.d/local_networks.map";
inverse = true;
apply {
symbols_disabled = ["DKIM_SIGNED"];
# Tag only; reject stays defined (unreachable by score) so ClamAV's virus reject still applies.
actions {
reject = 1000;
}
}
}
+1
View File
@@ -0,0 +1 @@
ruleset = "/etc/rspamd/sa-rules/*.cf";
@@ -0,0 +1,3 @@
password = "{= env.CONTROLLER_PASSWORD_HASH =}";
enable_password = "{= env.CONTROLLER_PASSWORD_HASH =}";
allow_file_and_shm_inputs = false;
+2
View File
@@ -0,0 +1,2 @@
count = 2;
allow_file_and_shm_inputs = false;
+1
View File
@@ -0,0 +1 @@
allow_file_and_shm_inputs = false;