diff --git a/backend/tests/plugins/test_antispam_learning.py b/backend/tests/plugins/test_antispam_learning.py index c14fde4..e89bef2 100644 --- a/backend/tests/plugins/test_antispam_learning.py +++ b/backend/tests/plugins/test_antispam_learning.py @@ -328,3 +328,34 @@ async def test_one_failing_account_does_not_stop_the_sweep( monkeypatch.setattr(jobs, "bootstrap_account", _one) await jobs.bootstrap_sweep({}) assert done == [str(accounts[0][1]), str(accounts[2][1])] + + +def test_compose_gives_the_app_the_controller_password() -> None: + """The learning loop needs the controller password; classification does not. + + Rspamd splits over two ports: the scan worker takes /checkv2 anonymously, + the controller demands a password for /stat and /learnspam. Ship the password + to rspamd but not to the api/worker containers and the result is a filter + that looks healthy — mail is still classified — while every learn is refused + 401 and swallowed by the plugin's fail-open path. The only visible trace is + `"bayes": null` on the stats endpoint. + + That shipped, so it is worth a test that reads the compose files rather than + trusting review to catch the missing line again. + """ + import pathlib + + root = pathlib.Path(__file__).resolve().parents[3] + for rel, anchor in ( + ("docker-compose.yml", "x-backend-env:"), + ("docker/docker-compose.yml", "environment: &worker-env"), + ): + text = (root / rel).read_text() + assert anchor in text, f"{rel}: shared env block moved; update this test" + start = text.index(anchor) + # The shared block runs to the next top-level key. + block = text[start : text.index("\nservices:", start) if "\nservices:" in text[start:] else len(text)] + assert "RSPAMD_CONTROLLER_PASSWORD" in block, ( + f"{rel}: the app containers do not receive RSPAMD_CONTROLLER_PASSWORD — " + "antispam will classify but never learn" + ) diff --git a/deploy/webmail.sh b/deploy/webmail.sh index e6a09f8..3dfaddd 100755 --- a/deploy/webmail.sh +++ b/deploy/webmail.sh @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 7da75c3..ff839c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,12 @@ x-backend-env: &backend-env SEARCH_BACKEND: ${SEARCH_BACKEND:-pg} MEILI_URL: ${MEILI_URL:-http://meilisearch:7700} MEILI_KEY: ${MEILI_MASTER_KEY:-} + # The antispam engine splits across two ports: the scan worker (:11333) takes + # anonymous /checkv2, the controller (:11334) demands this password for /stat + # and /learnspam. Without it here, classification works and LEARNING SILENTLY + # DOES NOT — the controller answers 401, the plugin fails open, and the only + # visible symptom is "bayes": null on the stats endpoint. + RSPAMD_CONTROLLER_PASSWORD: ${RSPAMD_CONTROLLER_PASSWORD} LOG_LEVEL: ${LOG_LEVEL:-INFO} UNDO_SECONDS: ${UNDO_SECONDS:-10} SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1b482e3..7c3f170 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -48,6 +48,8 @@ services: REDIS_URL: redis://redis:6379/0 MASTER_KEY: CHANGE_ME_base64_32_bytes BLOB_ROOT: /var/blobs + # Must match the rspamd service below, or the controller 401s every learn. + RSPAMD_CONTROLLER_PASSWORD: ${RSPAMD_CONTROLLER_PASSWORD:-devpass} volumes: [blobs:/var/blobs] ulimits: { nofile: 65536 } depends_on: