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
+72
View File
@@ -209,6 +209,78 @@ services:
mem_limit: ${MEILI_MEM_LIMIT:-1g}
logging: *logging
# --- anti-spam engine (SPEC Appendix C) ------------------------------------
# Not optional: it starts with everything else. Note what does NOT depend on
# it — api, workers and web have no `depends_on: rspamd`. That is deliberate
# and load-bearing: classification is best-effort, delivery is not, so an
# engine that is down, slow or mid-restart must never hold up mail (§C.4).
rspamd:
image: rspamd/rspamd:3.9
# The controller password is an internal secret nobody types; humans reach
# Rspamd through the webmail admin panel, which authenticates with the
# ordinary administrator account. local.d is read-only (the shipped privacy
# defaults), so the generated password is written to override.d at start.
# Starts as root only to write that file — /etc/rspamd is root-owned and the
# image's default user (_rspamd, uid 11333) cannot write it. rspamd then
# drops to _rspamd itself via -u/-g, which is how it normally runs: a root
# master supervising unprivileged workers.
user: root
entrypoint:
- /bin/sh
- -c
- |
mkdir -p /etc/rspamd/override.d
printf 'password = "%s";\nenable_password = "%s";\n' \
"$$RSPAMD_CONTROLLER_PASSWORD" "$$RSPAMD_CONTROLLER_PASSWORD" \
> /etc/rspamd/override.d/worker-controller.inc
exec /usr/bin/rspamd -f -u _rspamd -g _rspamd
environment:
RSPAMD_CONTROLLER_PASSWORD: ${RSPAMD_CONTROLLER_PASSWORD}
TZ: ${TZ:-UTC}
volumes:
- ./docker/rspamd/local.d:/etc/rspamd/local.d:ro
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/rspamd:/var/lib/rspamd
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:11334/ping | grep -qi pong"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
depends_on:
redis-rspamd: { condition: service_healthy }
init: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${RSPAMD_MEM_LIMIT:-256m}
logging: *logging
# Bayes tokens and fuzzy hashes. Separate from the app's redis on purpose
# (§C.2): this data is derived and retrainable, so it trades durability for
# room to grow — RDB snapshots every 5 min, and an eviction policy, where the
# app's redis uses AOF + noeviction because losing a sync lease loses work.
redis-rspamd:
image: redis:7-alpine
command:
- redis-server
- --save
- "300"
- "1"
- --maxmemory
- ${RSPAMD_REDIS_MAXMEMORY:-128mb}
- --maxmemory-policy
- allkeys-lru
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/rspamd-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
depends_on:
init: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${RSPAMD_REDIS_MEM_LIMIT:-160m}
logging: *logging
# One-shot schema upgrade: core Alembic head + every plugin's own branch.
# Everything else waits for this to exit 0, so a failed migration stops the
# roll instead of starting code against an old schema.