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:
@@ -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)."""
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user