Antispam plugin (M7 phase C1): classify, file, and stay out of the way

Second-pass spam filtering per SPEC Appendix C. The engine is Rspamd in its own
container; this plugin is glue and policy, never a spam engine of its own.

Classification (§C.4) hooks message.new, acts only on inbox arrivals of enabled
accounts, and runs deny list -> allow list -> engine -> thresholds. Every
failure path ends in "deliver normally, unclassified": an unreachable engine, a
missing blob, an account with no Junk folder, a failed verdict write. Nothing
here may delay or lose mail, so no service declares depends_on: rspamd.

Two containers, started with everything else rather than behind a profile:
rspamd, and a redis of its own. The separate redis is not incidental — the app's
redis holds sync leases and arq queues under noeviction, and an ever-growing
Bayes corpus must never be able to fail a job enqueue. This one takes RDB
snapshots and evicts, because its data is derived and retrainable.

Network checks (RBL/SURBL/fuzzy) are off: they leak message-derived hashes and
URLs to third parties, which a self-hosted server's users did not ask for. The
engine makes no outbound connections in the default configuration.

The controller password is generated into .env like MASTER_KEY. It is internal;
humans will reach Rspamd through the admin panel, authenticated as the ordinary
administrator, rather than typing it.

local.d/groups.conf zeroes six SMTP-time symbols — DATE_IN_PAST,
PREVIOUSLY_DELIVERED, HFILTER_HOSTNAME_UNKNOWN, RCVD_NO_TLS_LAST, ARC_NA,
DMARC_NA — and greylisting is disabled. Measured against 398 real delivered
messages, those checks were firing on 44-71% of ordinary mail and pushing 11.3%
of it over the banner threshold; scoring mail at rest for facts true of every
delivered message is simply the wrong question. After: median 3.09 -> 0.70,
p95 7.00 -> 3.40, banner rate 11.3% -> 1.0%. The spec's 12.0/6.0 thresholds were
never the problem and are unchanged. Symbols are zeroed rather than removed so
verdicts stay explainable, and they matter again at the M6 edge (§C.7).

Still to come in C1: the §C.6 router and UI slots. C2 adds the learning loop —
until Bayes is trained, genuine scams and legitimate bulk mail overlap in the
5-8 band and no threshold separates them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-16 22:14:43 +02:00
co-authored by Claude Opus 5
parent 428ec58c71
commit 95ca1b49f6
21 changed files with 810 additions and 3 deletions
@@ -0,0 +1,6 @@
# Per-user Bayes, keyed by the Deliver-To header the plugin sends (SPEC §C.5).
# This is what makes acceptance 4 hold: one user's training cannot move another
# user's scores.
per_user = true;
autolearn = false; # only explicit/passive teaching from the app (C2)
backend = "redis";
+10
View File
@@ -0,0 +1,10 @@
# DISABLED BY DEFAULT (SPEC §C.2, acceptance 6).
# fuzzy_check performs third-party lookups derived from message content — hashes
# of the body, the URLs it contains, the sending IP. For a self-hosted personal
# server that is a privacy leak the user did not ask for, so the module is off
# and the engine makes no outbound connections at all.
#
# Turning this on is an operator decision, not a per-message one: set
# enabled = true here, restart rspamd, and only then does the plugin's
# network_checks setting have anything to enable.
enabled = false;
+5
View File
@@ -0,0 +1,5 @@
# Greylisting is an SMTP-time tactic: defer, see if the sender retries. We scan
# mail that has ALREADY been delivered and fetched over IMAP, so there is nothing
# to defer — the symbol fired on 62.6% of a real-mail sample and only added noise.
# It belongs at the M6 edge (§C.7), not in the second-pass filter.
enabled = false;
+34
View File
@@ -0,0 +1,34 @@
# Second-pass reality check (SPEC §C.1: this filter runs on mail the provider has
# already accepted and delivered).
#
# Every symbol below is a legitimate SMTP-time signal that is meaningless — or
# actively wrong — when scoring a message at rest, days after delivery, fetched
# over IMAP. Measured on a 398-message sample of real delivered mail, they fired
# on 44-71% of it and pushed 11.3% of ordinary mail over the banner threshold.
#
# They are zeroed, not deleted: they still appear in the symbol list, so a verdict
# stays explainable, they just no longer contribute score. When M6 lands, the edge
# scanner (§C.7) sees real SMTP state and these matter again — there, not here.
#
# Measured on 398 real delivered messages, before -> after this file:
# median 3.09 -> 0.70
# p95 7.00 -> 3.40
# >= 6.0 11.3% -> 1.0% (the banner threshold)
# max 11.66 -> 9.15 (move threshold is 12.0, so: headroom)
# The thresholds themselves were left at the spec's 12.0/6.0 — they were never
# the problem. Re-measure after the C2 learning loop has a trained Bayes: until
# then genuine scams and legitimate bulk mail overlap in the 5-8 band, and no
# threshold can separate them.
symbols {
# "This message is older than the scan" — always true for us.
"DATE_IN_PAST" { score = 0.0; }
# "Already delivered once" — by definition, for a second-pass filter.
"PREVIOUSLY_DELIVERED" { score = 0.0; }
# Properties of the connecting host, which we never saw.
"HFILTER_HOSTNAME_UNKNOWN" { score = 0.0; }
"RCVD_NO_TLS_LAST" { score = 0.0; }
# Absence-of-authentication penalties. We cannot verify SPF/DKIM/DMARC/ARC at
# rest anyway, so "not available" says nothing about this message.
"ARC_NA" { score = 0.0; }
"DMARC_NA" { score = 0.0; }
}
+5
View File
@@ -0,0 +1,5 @@
# No outbound DNS at all unless a network module asks for it. Belt to the braces
# of disabling the modules themselves (see rbl.conf, surbl.conf, fuzzy_check.conf).
dns {
enable_dnssec = false;
}
+10
View File
@@ -0,0 +1,10 @@
# DISABLED BY DEFAULT (SPEC §C.2, acceptance 6).
# phishing performs third-party lookups derived from message content — hashes
# of the body, the URLs it contains, the sending IP. For a self-hosted personal
# server that is a privacy leak the user did not ask for, so the module is off
# and the engine makes no outbound connections at all.
#
# Turning this on is an operator decision, not a per-message one: set
# enabled = true here, restart rspamd, and only then does the plugin's
# network_checks setting have anything to enable.
enabled = false;
+10
View File
@@ -0,0 +1,10 @@
# DISABLED BY DEFAULT (SPEC §C.2, acceptance 6).
# rbl performs third-party lookups derived from message content — hashes
# of the body, the URLs it contains, the sending IP. For a self-hosted personal
# server that is a privacy leak the user did not ask for, so the module is off
# and the engine makes no outbound connections at all.
#
# Turning this on is an operator decision, not a per-message one: set
# enabled = true here, restart rspamd, and only then does the plugin's
# network_checks setting have anything to enable.
enabled = false;
+4
View File
@@ -0,0 +1,4 @@
# Bayes and fuzzy storage live in the DEDICATED redis (SPEC §C.2), never the
# app's: that one holds sync leases and arq queues under `noeviction`, and an
# ever-growing Bayes corpus must never be able to fail a job enqueue.
servers = "redis-rspamd:6379";
+10
View File
@@ -0,0 +1,10 @@
# DISABLED BY DEFAULT (SPEC §C.2, acceptance 6).
# surbl performs third-party lookups derived from message content — hashes
# of the body, the URLs it contains, the sending IP. For a self-hosted personal
# server that is a privacy leak the user did not ask for, so the module is off
# and the engine makes no outbound connections at all.
#
# Turning this on is an operator decision, not a per-message one: set
# enabled = true here, restart rspamd, and only then does the plugin's
# network_checks setting have anything to enable.
enabled = false;