Files
webmail-client/backend/tests/plugins/test_antispam_policy.py
T
lukasandClaude Opus 5 fe471dbf01 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
2026-09-17 08:09:24 +02:00

136 lines
4.3 KiB
Python

"""Antispam C1 decision logic (SPEC §C.4) — pure, no database, no engine."""
from __future__ import annotations
import pytest
from webmail_plugin_antispam.policy import Settings, decide, matches
SETTINGS = Settings(
enabled_accounts=[],
move_threshold=12.0,
banner_threshold=6.0,
network_checks=False,
learn_from_provider_junk=True,
)
NO_RULES: dict[str, set[str]] = {"allow": set(), "deny": set()}
def _decide(sender: str = "s@example.com", rules=None, score=None, settings=SETTINGS) -> str:
return decide(sender=sender, rules=rules or NO_RULES, score=score, settings=settings)
@pytest.mark.parametrize(
("score", "expected"),
[
(0.0, "none"),
(5.9, "none"),
(6.0, "banner"),
(11.9, "banner"),
(12.0, "moved"),
(99.0, "moved"),
],
)
def test_thresholds_are_inclusive_lower_bounds(score: float, expected: str) -> None:
assert _decide(score=score) == expected
def test_unreachable_engine_fails_open() -> None:
"""score=None is "could not classify" — mail must still be delivered plainly."""
assert _decide(score=None) == "none"
def test_deny_list_moves_without_the_engine() -> None:
"""Acceptance 5: a deny-listed sender is filed with no engine involvement."""
rules = {"allow": set(), "deny": {"spammer@bad.test"}}
assert _decide(sender="spammer@bad.test", rules=rules, score=None) == "deny_listed"
def test_allow_list_beats_a_high_score() -> None:
"""Acceptance 5: an allow-listed sender survives even an overwhelming score."""
rules = {"allow": {"boss@work.test"}, "deny": set()}
assert _decide(sender="boss@work.test", rules=rules, score=100.0) == "allow_listed"
def test_deny_beats_allow() -> None:
"""Both lists naming the same sender: the refusal wins."""
rules = {"allow": {"x@y.test"}, "deny": {"x@y.test"}}
assert _decide(sender="x@y.test", rules=rules, score=0.0) == "deny_listed"
def test_domain_patterns_cover_every_sender_there() -> None:
rules = {"allow": set(), "deny": {"@bad.test"}}
assert _decide(sender="anyone@bad.test", rules=rules) == "deny_listed"
assert _decide(sender="anyone@good.test", rules=rules) == "none"
@pytest.mark.parametrize(
("patterns", "email", "hit"),
[
({"a@b.test"}, "A@B.TEST", True), # case-insensitive
({"a@b.test"}, " a@b.test ", True), # tolerant of stray whitespace
({"@b.test"}, "anyone@b.test", True),
({"@b.test"}, "anyone@other.test", False),
({"a@b.test"}, "", False), # no sender at all
(set(), "a@b.test", False),
],
)
def test_pattern_matching(patterns: set[str], email: str, hit: bool) -> None:
assert matches(patterns, email) is hit
def test_per_account_enablement() -> None:
"""Empty list means every account; a populated one is an allow-list."""
everywhere = SETTINGS
assert everywhere.covers("any-account-id")
scoped = Settings(
enabled_accounts=["acc-1"],
move_threshold=12.0,
banner_threshold=6.0,
network_checks=False,
learn_from_provider_junk=True,
)
assert scoped.covers("acc-1")
assert not scoped.covers("acc-2")
# --- contacts are never filed as spam ----------------------------------------
def test_a_contact_is_warned_about_but_never_moved() -> None:
"""Mail from someone in the address book must not vanish into Junk."""
assert _decide(score=99.0) == "moved" # same score, not a contact
assert (
decide(
sender="friend@example.com",
rules=NO_RULES,
score=99.0,
settings=SETTINGS,
contact=True,
)
== "banner"
)
def test_a_contact_below_the_move_threshold_is_unaffected() -> None:
for score, expected in ((0.0, "none"), (6.0, "banner"), (11.9, "banner")):
assert (
decide(
sender="friend@example.com",
rules=NO_RULES,
score=score,
settings=SETTINGS,
contact=True,
)
== expected
)
def test_an_explicit_deny_still_beats_being_a_contact() -> None:
"""Deny-listing someone you also have saved is a deliberate act — honour it."""
rules = {"allow": set(), "deny": {"ex@example.com"}}
assert (
decide(sender="ex@example.com", rules=rules, score=0.0, settings=SETTINGS, contact=True)
== "deny_listed"
)