Antispam: the app could never authenticate to the learning engine

Rspamd answers on two ports and only one of them asks for a password:
the scan worker takes /checkv2 anonymously, the controller demands
`Password:` for /stat and /learnspam. `webmail.sh init` generates
RSPAMD_CONTROLLER_PASSWORD and docker-compose hands it to the rspamd
container — but the shared x-backend-env block that feeds api and
worker-* never carried it. The plugin reads it from the environment,
found "", and sent no header.

So classification worked and learning never did. Every learn returned
401 and the plugin swallowed it, as it is built to: an engine problem
must not lose mail. Bootstrap logged "engine unavailable", passive
learning from Inbox->Spam moves taught nothing, and the single visible
trace was `"bayes": null` on GET /api/plugins/antispam/stats, on an
install whose verdict counts were ticking up normally.

Verified against a live engine: with no password /stat and /learnspam
both 401 and rspamd.learn() returns False; with it, stats returns and
learn returns True.

  - pass RSPAMD_CONTROLLER_PASSWORD to the app containers, in both the
    production and dev compose files
  - doctor now makes the real call from inside the api container, with
    the password that container actually holds, and says plainly that
    nothing will be learned if it fails
  - a test reads both compose files, because this is a missing line that
    review already let through once

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-21 22:17:29 +02:00
co-authored by Claude Opus 5
parent 9f9e959a05
commit 059fa0ade3
4 changed files with 70 additions and 0 deletions
+31
View File
@@ -364,6 +364,37 @@ cmd_doctor() {
fi
fi
# Antispam splits over two ports and only one of them needs a password, so a
# missing password looks like a working filter: mail still gets classified by
# the scan worker while every learn is refused by the controller. Prove the
# app containers can actually reach it rather than trusting that the variable
# is set somewhere.
case " ${ENABLED_PLUGINS:-} " in
*" antispam "*|" ")
if [ -z "${RSPAMD_CONTROLLER_PASSWORD:-}" ]; then
red " RSPAMD_CONTROLLER_PASSWORD is empty — antispam will classify but never learn"
echo " fix: webmail.sh init regenerates it, then: webmail.sh update"; ok=1
elif compose ps --status running --services 2>/dev/null | grep -qx rspamd; then
# From inside the api container, with the password it really has:
# that is the call the learning loop makes.
if compose exec -T api python -c "
import os, sys, urllib.request
req = urllib.request.Request(
os.environ.get('RSPAMD_CONTROLLER_URL', 'http://rspamd:11334') + '/stat',
headers={'Password': os.environ.get('RSPAMD_CONTROLLER_PASSWORD', ''),
'Deliver-To': 'doctor'})
urllib.request.urlopen(req, timeout=5).read()
" >/dev/null 2>&1; then
grn " antispam engine reachable and learning is authorised"
else
red " the api container cannot authenticate to the antispam controller."
red " Mail will still be classified, but NOTHING will be learned."
echo " usually RSPAMD_CONTROLLER_PASSWORD reaching rspamd but not api/worker-*."
echo " fix: webmail.sh update (v1.3.1+ passes it to both)"; ok=1
fi
fi ;;
esac
if command -v ss >/dev/null 2>&1; then
local p
for p in "WEB_PORT:${WEB_PORT:-8088}" "ADMIN_PORT:${ADMIN_PORT:-8089}"; do