Files
webmail-client/backend/tests/plugins/test_antispam_policy.py
T
lukasandClaude Opus 5.5 b88855931e M6 hosted mailboxes (off by default); dark message mode; fixes
Hosted mailboxes (SPEC App. D), behind HOSTED_MAILBOXES (off: unchanged):
- Stalwart mail server, provisioned by `python -m app.hosted.provision`;
  directory read from a Postgres view, app passwords only for mail apps
- native account per user, the worker signs in as master for the user
- raw messages of hosted mailboxes only cached, fetched back on demand
- migrations 0025 (accounts.kind, directory view), 0026 (IMAP ids bigint)

Mail:
- dark message mode switch in the reading pane (Thunderbird's algorithm)
- moves and folder creation quote the folder name ("Deleted Items"):
  swipe-to-delete / Trash no longer bounce back
- thread unread counters: lock before recount, sorted lock order,
  UIDVALIDITY re-import recounts, nightly repair_threads
- sidebar accounts sorted alphabetically, more space above headings

Settings: Security headings renamed; antispam accounts are explicit
(none ticked = none filtered), input widths, Activity hidden.
Plugin bundles served with Cache-Control: no-cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:25:10 +02:00

145 lines
4.6 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=None,
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:
"""None means every account; a list is an allow-list — empty, none."""
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")
nowhere = Settings(
enabled_accounts=[],
move_threshold=12.0,
banner_threshold=6.0,
network_checks=False,
learn_from_provider_junk=True,
)
assert not nowhere.covers("acc-1")
# --- 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"
)