Files
webmail-client/backend/tests/plugins/test_antispam_corpus.py
T
lukasandClaude Opus 5 5aa133e18c Antispam: plugin docs, the learning acceptance test, and a bootstrap that refuses bad corpora
docs/plugins.md described an SDK that no longer exists. It now covers
messages.raw/sample, actions.move_to_special, the envelope's account_id /
mailbox_special_use / account_state, the message.changed transition payload, the
message.actions.menu slot, Slot.title and .wm-menu-item — with the reasons, not
just the signatures. M3's acceptance is that a plugin can be written from this
file alone, and it had drifted.

Acceptance 4 (§C.8): a test that teaches 20 spam + 20 ham to a real engine and
checks the classifier generalises to held-out mail, keeps ham under the banner
threshold, and does not leak between users. It skips when no engine is reachable.
It does NOT assert the literal move_threshold — a synthetic fixture cannot reach
12.0 once the SMTP-time symbols are zeroed, which is the whole point of the
tuning. Recorded as OQ-8 rather than quietly asserting something weaker.

Bayes ignores training until min_learns, stock 200. For a personal mailbox that
means every correction appears to be ignored for months, so it is now 20.

The bootstrap would have made this filter worse. Accounts do not sync their
provider's spam folder by default, so the real corpus here is 3 junk beside 1,294
aged inbox — teaching both as found trains "everything is ham". It now refuses
below 10 spam samples (leaving the account unmarked so a later run can do it
properly) and otherwise caps ham at 3x the spam count.

Run against the live account, it correctly declined:
  "only 3 spam samples (need 10); enable spam-folder sync to build a corpus"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 20:34:50 +02:00

168 lines
6.9 KiB
Python

"""SPEC §C.8 acceptance 4 — the learning loop actually learns.
"After 20 spam + 20 ham teachings on a fixture corpus, a held-out obvious-spam
fixture scores above move_threshold for that user and a ham fixture below
banner_threshold (per-user isolation: a second user's scores are unaffected)."
This is the only test that proves the loop *works* rather than merely runs, so it
talks to a real Rspamd. It skips when there is no engine — on a machine without
one that is a missing check, not a failure.
**Deviation from the literal wording, recorded in docs/open-questions.md OQ-8.**
The held-out spam does not cross move_threshold (12.0) and cannot: a synthetic
fixture has no delivery history, and this deployment deliberately zeroes the
SMTP-time symbols that would otherwise pile score onto exactly such a message
(DATE_IN_PAST, HFILTER_HOSTNAME_UNKNOWN, ARC_NA, DMARC_NA...). Those were the
false-positive engine on real mail. Asserting 12.0 here would mean either
un-tuning the filter or writing fixtures that fake delivery headers.
What is asserted instead is the substance the criterion exists to check: after
training, Bayes recognises held-out spam, held-out ham stays under the banner
threshold, and the same message scores materially higher for the user who taught
than for one who did not.
"""
from __future__ import annotations
import uuid
import pytest
from webmail_plugin_antispam import policy, rspamd
pytestmark = pytest.mark.asyncio
SETTINGS = policy.Settings(
enabled_accounts=[],
move_threshold=12.0,
banner_threshold=6.0,
network_checks=False,
learn_from_provider_junk=True,
)
_SPAM_PHRASES = [
"CONGRATULATIONS! You have WON the international lottery jackpot",
"Urgent business proposal: transfer of $45,000,000 USD to your account",
"Cheap pills online, no prescription needed, discreet worldwide shipping",
"Your account will be SUSPENDED unless you verify your password immediately",
"Work from home and earn $5000 per week with this one weird trick",
"Hot singles in your area are waiting to meet you tonight",
"Claim your free iPhone now, limited offer, click here immediately",
"I am the widow of a late oil magnate seeking a trustworthy partner",
"Bitcoin investment doubling scheme, guaranteed returns, act now",
"FINAL NOTICE: your payment is overdue, wire funds within 24 hours",
]
_HAM_PHRASES = [
"Attached are the notes from this morning's planning meeting",
"Could you review the deployment checklist before Thursday please",
"The invoice for last month's hosting is ready for your approval",
"Thanks for sending the render over, the lighting looks much better",
"Reminder: the project retrospective is at 14:00 in the small room",
"I have pushed the fix to the branch, it needs a second pair of eyes",
"Here is the schedule for next week's shoot, let me know about Tuesday",
"The client approved the storyboard, we can start animation on Monday",
"Quick question about the colour grade on shot 42, do you have a minute",
"Please find the signed contract attached, scanned as a PDF",
]
def _message(subject: str, body: str, n: int) -> bytes:
return (
f"From: sender{n}@example.test\r\n"
f"To: recipient@example.test\r\n"
f"Subject: {subject}\r\n"
f"Message-ID: <corpus-{n}-{uuid.uuid4()}@example.test>\r\n"
"\r\n"
f"{body}\r\n"
).encode()
def _corpus(phrases: list[str], count: int) -> list[bytes]:
"""`count` messages built from the phrase pool, each worded differently."""
out = []
for i in range(count):
a, b = phrases[i % len(phrases)], phrases[(i + 3) % len(phrases)]
out.append(_message(a, f"{a}. {b}. Reference {i}.", i))
return out
async def _engine_available() -> bool:
return await rspamd.check(_message("ping", "hello", 0), user_key="probe") is not None
@pytest.fixture
async def taught_user() -> str:
"""A user whose Bayes has seen 20 spam and 20 ham, and nobody else's."""
if not await _engine_available():
pytest.skip("no Rspamd engine reachable (RSPAMD_URL)")
user = f"corpus-{uuid.uuid4().hex[:12]}"
for raw in _corpus(_SPAM_PHRASES, 20):
assert await rspamd.learn(raw, user_key=user, spam=True)
for raw in _corpus(_HAM_PHRASES, 20):
assert await rspamd.learn(raw, user_key=user, spam=False)
return user
async def _score(raw: bytes, user: str) -> float:
verdict = await rspamd.check(raw, user_key=user, network_checks=False)
assert verdict is not None, "engine went away mid-test"
return verdict.score
# Held out: never taught, but plainly of each kind.
HELD_OUT_SPAM = _message(
"WINNER! Claim your lottery prize money now",
"Congratulations, you have won the jackpot. Urgent: wire your bank details "
"immediately to claim $10,000,000 USD. Free pills included, act now.",
999,
)
HELD_OUT_HAM = _message(
"Notes from the planning meeting",
"Attached are the notes from this morning. Could you review the checklist "
"before Thursday? The invoice is ready for approval when you have a minute.",
998,
)
async def _symbols(raw: bytes, user: str) -> list[str]:
verdict = await rspamd.check(raw, user_key=user, network_checks=False)
assert verdict is not None, "engine went away mid-test"
return verdict.symbols
async def test_training_makes_bayes_recognise_held_out_spam(taught_user: str) -> None:
"""The classifier generalises: it has never seen this message."""
assert "BAYES_SPAM" in await _symbols(HELD_OUT_SPAM, taught_user)
async def test_training_does_not_make_bayes_cry_spam_at_ham(taught_user: str) -> None:
assert "BAYES_SPAM" not in await _symbols(HELD_OUT_HAM, taught_user)
async def test_held_out_ham_stays_below_the_banner_threshold(taught_user: str) -> None:
score = await _score(HELD_OUT_HAM, taught_user)
assert score < SETTINGS.banner_threshold, (
f"taught ham corpus, held-out ham scored {score} "
f"(needs < {SETTINGS.banner_threshold} to pass without a warning)"
)
async def test_one_users_training_does_not_move_anothers_scores(taught_user: str) -> None:
"""Per-user isolation — the reason Deliver-To is sent on every scan."""
stranger = f"untaught-{uuid.uuid4().hex[:12]}"
taught_score = await _score(HELD_OUT_SPAM, taught_user)
stranger_score = await _score(HELD_OUT_SPAM, stranger)
assert "BAYES_SPAM" not in await _symbols(HELD_OUT_SPAM, stranger), (
"an untaught user sees BAYES_SPAM: training is leaking between users"
)
assert stranger_score < taught_score, (
f"untaught user scored {stranger_score} vs the taught user's {taught_score}"
)
async def test_spam_and_ham_are_separated_by_training(taught_user: str) -> None:
"""The gap is the point: a threshold anywhere between them files correctly."""
spam_score = await _score(HELD_OUT_SPAM, taught_user)
ham_score = await _score(HELD_OUT_HAM, taught_user)
assert spam_score > ham_score, f"spam {spam_score} did not outscore ham {ham_score}"