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>
139 lines
4.5 KiB
Python
139 lines
4.5 KiB
Python
"""What the antispam hook refuses to touch (SPEC §C.4).
|
|
|
|
The gating matters more than the scoring: `message.new` fires for imported
|
|
history and for every folder, so a hook that acts on all of it would file
|
|
years-old mail into Junk and stall backfill with inline HTTP.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from webmail_plugin_antispam import hook, policy
|
|
|
|
LIVE_INBOX = {
|
|
"message_id": "m",
|
|
"account_id": "acc-1",
|
|
"mailbox_special_use": "inbox",
|
|
"account_state": "live",
|
|
"from_email": "someone@example.test",
|
|
"subject": "hello",
|
|
"to": [],
|
|
"flags": [],
|
|
}
|
|
|
|
|
|
class _FakeMessages:
|
|
def __init__(self, env: dict[str, Any] | None) -> None:
|
|
self._env = env
|
|
self.raw_calls = 0
|
|
|
|
async def envelope(self, *_a: object, **_k: object) -> dict[str, Any] | None:
|
|
return self._env
|
|
|
|
async def raw(self, *_a: object, **_k: object) -> bytes | None:
|
|
self.raw_calls += 1
|
|
return b"From: someone@example.test\r\n\r\nbody\r\n"
|
|
|
|
|
|
class _FakeActions:
|
|
def __init__(self) -> None:
|
|
self.moves: list[str] = []
|
|
|
|
async def move_to_special(self, _u: object, _m: object, special_use: str) -> bool:
|
|
self.moves.append(special_use)
|
|
return True
|
|
|
|
|
|
class _FakeSdk:
|
|
def __init__(self, env: dict[str, Any] | None) -> None:
|
|
self.messages = _FakeMessages(env)
|
|
self.actions = _FakeActions()
|
|
self.published: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
async def publish(self, event: str, payload: dict[str, Any]) -> None:
|
|
self.published.append((event, payload))
|
|
|
|
|
|
@pytest.fixture
|
|
def wired(monkeypatch: pytest.MonkeyPatch): # type: ignore[no-untyped-def]
|
|
"""Swap the hook's SDK and its database/engine calls for fakes."""
|
|
|
|
recorded: list[dict[str, Any]] = []
|
|
|
|
async def _record(_u: object, _m: object, **kw: Any) -> None:
|
|
recorded.append(kw)
|
|
|
|
async def _settings(_u: object) -> policy.Settings:
|
|
return policy.Settings(
|
|
enabled_accounts=None,
|
|
move_threshold=12.0,
|
|
banner_threshold=6.0,
|
|
network_checks=False,
|
|
learn_from_provider_junk=True,
|
|
)
|
|
|
|
async def _rules(_u: object) -> dict[str, set[str]]:
|
|
return {"allow": set(), "deny": set()}
|
|
|
|
monkeypatch.setattr(hook, "_record", _record)
|
|
monkeypatch.setattr(policy, "load_settings", _settings)
|
|
monkeypatch.setattr(policy, "load_rules", _rules)
|
|
|
|
def _install(env: dict[str, Any] | None, score: float | None = None) -> _FakeSdk:
|
|
fake = _FakeSdk(env)
|
|
monkeypatch.setattr(hook, "_SDK", fake)
|
|
|
|
async def _check(*_a: object, **_k: object) -> object:
|
|
if score is None:
|
|
return None
|
|
return type("V", (), {"score": score, "symbols": ["X"]})()
|
|
|
|
monkeypatch.setattr(hook.rspamd, "check", _check)
|
|
return fake
|
|
|
|
return _install, recorded
|
|
|
|
|
|
async def _fire() -> None:
|
|
await hook.on_message_new({"user_id": str(uuid.uuid4()), "message_id": str(uuid.uuid4())})
|
|
|
|
|
|
async def test_skips_while_the_account_is_backfilling(wired) -> None: # type: ignore[no-untyped-def]
|
|
"""Imported history must not be scored, moved, or slowed down."""
|
|
install, recorded = wired
|
|
fake = install({**LIVE_INBOX, "account_state": "backfilling"}, score=99.0)
|
|
await _fire()
|
|
assert fake.messages.raw_calls == 0 # never even fetched the body
|
|
assert fake.actions.moves == []
|
|
assert recorded == []
|
|
|
|
|
|
@pytest.mark.parametrize("special_use", ["sent", "junk", "trash", "drafts", None])
|
|
async def test_skips_everything_outside_the_inbox(wired, special_use) -> None: # type: ignore[no-untyped-def]
|
|
install, recorded = wired
|
|
fake = install({**LIVE_INBOX, "mailbox_special_use": special_use}, score=99.0)
|
|
await _fire()
|
|
assert fake.actions.moves == []
|
|
assert recorded == []
|
|
|
|
|
|
async def test_classifies_a_live_inbox_arrival(wired) -> None: # type: ignore[no-untyped-def]
|
|
install, recorded = wired
|
|
fake = install(LIVE_INBOX, score=99.0)
|
|
await _fire()
|
|
assert fake.actions.moves == ["junk"]
|
|
assert recorded and recorded[0]["action"] == "moved"
|
|
assert fake.published and fake.published[0][0] == "plugin.antispam.classified"
|
|
|
|
|
|
async def test_unreachable_engine_delivers_normally(wired) -> None: # type: ignore[no-untyped-def]
|
|
"""Acceptance 2: no engine, no interference."""
|
|
install, recorded = wired
|
|
fake = install(LIVE_INBOX, score=None)
|
|
await _fire()
|
|
assert fake.actions.moves == []
|
|
assert recorded and recorded[0]["action"] == "none"
|