2026-09-16 22:14:43 +02:00
|
|
|
"""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")
|
2026-09-17 08:09:24 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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"
|
|
|
|
|
)
|