Antispam: bootstrap every account, not just the ones we happen to see go live

Warming Bayes hung on a single event — `account.state_changed` reaching "live".
That covers an account added from now on and nothing else. An account that went
live before this plugin existed never gets one; neither does one whose Junk
folder was empty at the time and filled up later, or one whose owner enabled
spam-folder sync afterwards. A worker restarting at the wrong moment loses it
too. The result was a feature that quietly required someone to run it by hand
per account, which is not a feature.

An hourly sweep now walks every live account and offers each a bootstrap. That
works because the per-account job was already cheap to repeat: it returns at once
for an account already done, and declines — without marking it done — when the
corpus is still too small to learn from. So the sweep simply keeps asking until
there is something worth learning, then does it once.

`s.accounts.live()` is the new SDK surface it needs: cross-user by design,
because a sweep runs for the instance rather than a request. Ids and state only,
so reading anything still goes through the user-scoped accessors.

One failing account cannot stop the sweep — the mailbox that raises is logged and
the rest still get their turn.

docs/plugins.md now says this in general terms next to `JobSpec`: a state-change
hook is one-shot, so pair it with a sweep when the work must eventually happen
for everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-17 21:04:38 +02:00
co-authored by Claude Opus 5
parent 566f02a3f4
commit 8eb699a05f
6 changed files with 127 additions and 5 deletions
+20
View File
@@ -162,6 +162,26 @@ class PluginMessages:
}
class PluginAccounts:
"""Cross-user account queries, for scheduled jobs.
Deliberately not user-scoped: a sweep runs on behalf of the instance, not a
request, and needs to find work across every user. Nothing here returns
content — only ids and state — so a plugin still has to go through the
user-scoped accessors to read anything.
"""
async def live(self) -> list[tuple[uuid.UUID, uuid.UUID]]:
"""``(user_id, account_id)`` for every account finished with its initial
import. Accounts still backfilling are excluded: acting on one means
acting on a partial mailbox."""
async with session_scope() as session:
rows = await session.execute(
select(Account.user_id, Account.id).where(Account.state == "live")
)
return [(u, a) for u, a in rows]
class PluginThreads:
"""Read-only, user-scoped thread queries (e.g. self-healing stale references)."""
+11 -1
View File
@@ -28,7 +28,12 @@ from app.identity.deps import current_user, require_csrf
from app.indexer import blobstore
from app.models import User
from app.outbox import service as outbox_service
from app.plugins.actions import PluginActions, PluginMessages, PluginThreads
from app.plugins.actions import (
PluginAccounts,
PluginActions,
PluginMessages,
PluginThreads,
)
from app.plugins.db import plugin_session
from app.plugins.http import PluginHttp
from app.plugins.kv import PluginKvStore
@@ -153,6 +158,11 @@ class PluginSdk:
``move_to_special`` (one message into e.g. Junk)."""
return PluginActions()
@property
def accounts(self) -> PluginAccounts:
"""Cross-user account listing for scheduled jobs (`live()`)."""
return PluginAccounts()
@property
def threads(self) -> PluginThreads:
"""Read-only thread queries (e.g. pruning stale references)."""
@@ -280,3 +280,51 @@ async def test_bootstrap_caps_ham_at_a_multiple_of_spam(monkeypatch: pytest.Monk
assert taught.count(True) == 20
assert taught.count(False) == 60, "ham should be capped at 3x the spam count"
assert marked, "a completed bootstrap must mark the account done"
# --- the sweep: bootstrapping must not depend on catching an event -----------
async def test_sweep_visits_every_live_account(monkeypatch: pytest.MonkeyPatch) -> None:
"""An account that went live before the plugin existed still gets a warm start."""
visited: list[tuple[str, str]] = []
accounts = [(uuid.uuid4(), uuid.uuid4()) for _ in range(3)]
class _Accounts:
async def live(self) -> list[tuple[uuid.UUID, uuid.UUID]]:
return accounts
class _Fake:
accounts = _Accounts()
async def _one(_ctx: object, *, user_id: str, account_id: str) -> None:
visited.append((user_id, account_id))
monkeypatch.setattr(jobs, "_SDK", _Fake())
monkeypatch.setattr(jobs, "bootstrap_account", _one)
await jobs.bootstrap_sweep({})
assert visited == [(str(u), str(a)) for u, a in accounts]
async def test_one_failing_account_does_not_stop_the_sweep(
monkeypatch: pytest.MonkeyPatch,
) -> None:
done: list[str] = []
accounts = [(uuid.uuid4(), uuid.uuid4()) for _ in range(3)]
class _Accounts:
async def live(self) -> list[tuple[uuid.UUID, uuid.UUID]]:
return accounts
class _Fake:
accounts = _Accounts()
async def _one(_ctx: object, *, user_id: str, account_id: str) -> None:
if account_id == str(accounts[1][1]):
raise RuntimeError("that mailbox is on fire")
done.append(account_id)
monkeypatch.setattr(jobs, "_SDK", _Fake())
monkeypatch.setattr(jobs, "bootstrap_account", _one)
await jobs.bootstrap_sweep({})
assert done == [str(accounts[0][1]), str(accounts[2][1])]