Antispam plugin (M7 phase C1): classify, file, and stay out of the way
Second-pass spam filtering per SPEC Appendix C. The engine is Rspamd in its own container; this plugin is glue and policy, never a spam engine of its own. Classification (§C.4) hooks message.new, acts only on inbox arrivals of enabled accounts, and runs deny list -> allow list -> engine -> thresholds. Every failure path ends in "deliver normally, unclassified": an unreachable engine, a missing blob, an account with no Junk folder, a failed verdict write. Nothing here may delay or lose mail, so no service declares depends_on: rspamd. Two containers, started with everything else rather than behind a profile: rspamd, and a redis of its own. The separate redis is not incidental — the app's redis holds sync leases and arq queues under noeviction, and an ever-growing Bayes corpus must never be able to fail a job enqueue. This one takes RDB snapshots and evicts, because its data is derived and retrainable. Network checks (RBL/SURBL/fuzzy) are off: they leak message-derived hashes and URLs to third parties, which a self-hosted server's users did not ask for. The engine makes no outbound connections in the default configuration. The controller password is generated into .env like MASTER_KEY. It is internal; humans will reach Rspamd through the admin panel, authenticated as the ordinary administrator, rather than typing it. local.d/groups.conf zeroes six SMTP-time symbols — DATE_IN_PAST, PREVIOUSLY_DELIVERED, HFILTER_HOSTNAME_UNKNOWN, RCVD_NO_TLS_LAST, ARC_NA, DMARC_NA — and greylisting is disabled. Measured against 398 real delivered messages, those checks were firing on 44-71% of ordinary mail and pushing 11.3% of it over the banner threshold; scoring mail at rest for facts true of every delivered message is simply the wrong question. After: median 3.09 -> 0.70, p95 7.00 -> 3.40, banner rate 11.3% -> 1.0%. The spec's 12.0/6.0 thresholds were never the problem and are unchanged. Symbols are zeroed rather than removed so verdicts stay explainable, and they matter again at the M6 edge (§C.7). Still to come in C1: the §C.6 router and UI slots. C2 adds the learning loop — until Bayes is trained, genuine scams and legitimate bulk mail overlap in the 5-8 band and no threshold separates them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user