Files
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

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=None,
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}"