Antispam: mirror the engine in dev, and never file a contact as spam
Dev compose gains rspamd + redis-rspamd, matching production. The dev API and worker run on the host rather than in the compose network, so the ports are published on loopback and RSPAMD_URL points at 127.0.0.1 — documented in .env.example. Contacts now cap a verdict at "banner": mail from someone in the user's address book is never moved to Junk. Capped rather than exempted, because the way this fails in practice is a contact's account being compromised and spamming its own address book — the warning still has value even when the move would be wrong. An explicit deny rule still wins; that is the user overruling us deliberately. The lookup reads the contacts plugin's table directly and forgives its absence: no contacts plugin means no contacts to check, not an error. Addresses are stored as the user typed them, so both sides are lower-cased rather than relying on the jsonb containment index, and secondary addresses in the emails array count as well as the primary. It runs only when the verdict would otherwise be a move, so the common path pays nothing. Also fixes a test that asserted stats degrade "without an engine" by relying on no engine being present — true until this commit. It now simulates the absence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
@@ -11,12 +11,18 @@ import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete, text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
# NB: `from webmail_plugin_antispam import router` would bind the APIRouter
|
||||
# object, not the module — __init__ re-exports it. Patch the engine module,
|
||||
# which router.py resolves by attribute at call time.
|
||||
from webmail_plugin_antispam import rspamd as rspamd_mod
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
@@ -145,14 +151,31 @@ async def test_verdict_absent_for_unclassified_message(ctx: _Ctx) -> None:
|
||||
assert (await ctx.client_a.get(f"{BASE}/verdict/{uuid.uuid4()}")).json() is None
|
||||
|
||||
|
||||
async def test_stats_work_without_an_engine(ctx: _Ctx) -> None:
|
||||
"""The stats page must render on a box where Rspamd is not running."""
|
||||
async def test_stats_work_without_an_engine(ctx: _Ctx, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The stats page must render on a box where Rspamd is not running.
|
||||
|
||||
Simulated rather than inferred from the environment: the dev stack now runs
|
||||
an engine, so "no engine" has to be arranged deliberately or this test only
|
||||
passes by accident.
|
||||
"""
|
||||
|
||||
async def _no_engine(**_kw: object) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(rspamd_mod, "stats", _no_engine)
|
||||
body = (await ctx.client_a.get(f"{BASE}/stats")).json()
|
||||
assert body["counts"] == {}
|
||||
assert body["top_symbols"] == []
|
||||
assert body["bayes"] is None # unreachable engine, not an error
|
||||
|
||||
|
||||
async def test_stats_include_engine_state_when_it_is_up(ctx: _Ctx) -> None:
|
||||
"""And when an engine IS present, its numbers come through."""
|
||||
|
||||
body = (await ctx.client_a.get(f"{BASE}/stats")).json()
|
||||
assert body["bayes"] is None or isinstance(body["bayes"], dict)
|
||||
|
||||
|
||||
async def test_ui_bundles_are_served_and_gated(ctx: _Ctx) -> None:
|
||||
for name in ("settings.js", "banner.js", "actions.js"):
|
||||
r = await ctx.client_a.get(f"{BASE}/ui/{name}")
|
||||
|
||||
Reference in New Issue
Block a user