Files
webmail-client/plugins/webmail_plugin_antispam/webmail_plugin_antispam/jobs.py
T
lukasandClaude Opus 5.5 befa1c2404 Antispam: lessons refused by the engine no longer pass for learned
Found on the NAS, where nothing had been learned in weeks while the UI said
"Marked as spam": the source tree predated v1.3.2, so api/worker-* had no
controller password and every lesson got 401 — and the trail of state that
left behind kept it broken after the password was fixed.

- /learn records a lesson only when rspamd accepted it. Recording refused ones
  made the menu claim "Marked as spam" and its dedup blocked every retry.
- Bootstrap: done-markers move to a new key. The old one may have been written
  by the "-0" bug (a total outage read as a clean run of zero), which left an
  account marked done with nothing taught and never retried; every older
  install re-runs the bootstrap once, harmlessly (rspamd skips what it knows).
- "Learn from my Spam folder now" in Settings > Antispam filtering queues the
  bootstrap for the user's accounts at once, instead of the hourly sweep.
- webmail.sh update now recreates rspamd: it reads the controller password only
  when created, so a changed .env reached the app but never the engine.
- doctor's advice for the 401 names both real causes (a pre-v1.3.2 compose
  file, or an rspamd started with an old password) and the fix for each; the
  old "update v1.3.2" advice could not help.
- docs/nas-install.md: how to refresh the source tree on a tarball install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:25:14 +02:00

193 lines
7.5 KiB
Python

"""Provider-Junk bootstrap (SPEC §C.5).
A per-user Bayes classifier knows nothing on day one, which is exactly when the
filter is asked to judge the most mail. But the user already owns a labelled
corpus: whatever their provider filed as Junk is spam they agreed to lose, and
old Inbox mail they never moved is ham they agreed to keep. Feeding both gives
Bayes a warm start without asking the user to train anything.
Deliberately unhurried. This is a background nicety, not a deadline: it yields
between messages so it cannot monopolise the worker or the engine, and it gives
up quietly on anything that looks wrong rather than retrying into a wall.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import Any
from app.plugins.sdk import sdk
from webmail_plugin_antispam import policy, rspamd
logger = logging.getLogger("plg.antispam")
_SDK = sdk("antispam")
# §C.5: up to 500 of each. Enough to shape a classifier, small enough that the
# whole pass is minutes rather than hours.
SPAM_SAMPLE = 500
HAM_SAMPLE = 500
# Ham comes from mail old enough that the user has plainly not triaged it — a
# message from this morning is not yet evidence of anything.
HAM_MIN_AGE_DAYS = 30
# A breath between messages: the engine is shared with live classification, and
# live mail must never queue behind a training backlog.
PAUSE_SECONDS = 0.05
# A Bayes classifier learns the ratio it is shown. Accounts do not sync their
# provider's Junk folder by default (accounts.sync_spam is off), so the spam side
# of this corpus is routinely tiny while the inbox side is thousands — teaching
# both as found would train "everything is ham" and make the filter worse than
# leaving it untrained. Two guards:
#
# below MIN_SPAM_CORPUS teach nothing at all, and leave the account unmarked
# so a later run can do it properly
# above it teach at most MAX_HAM_RATIO times as much ham as spam
MIN_SPAM_CORPUS = 10
MAX_HAM_RATIO = 3
# Accounts whose warm start is done. A new key on purpose: markers under the old
# one ("_bootstrapped") may have been written by the `-0` bug described in
# _teach — an account marked done after a total engine outage, with nothing
# taught, and never retried. Ignoring them costs every older install one repeat
# bootstrap, which is harmless: rspamd skips messages it has already learned.
_DONE_KEY = "bootstrap_done"
async def _teach(user_id: uuid.UUID, ids: list[uuid.UUID], *, spam: bool) -> int | None:
"""Number of messages learned, or None if the engine stopped accepting them.
None rather than a negative count: the count was once negated to signal
failure, which works for every case except the one that matters. If the
FIRST message is refused, nothing has been taught yet, `-0` is `0`, and the
caller's `< 0` test reads a total engine outage as a clean run of zero — then
marks the account bootstrapped, so it is never retried.
"""
taught = 0
for message_id in ids:
raw = await _SDK.messages.raw(user_id, message_id)
if raw is None:
continue
if not await rspamd.learn(raw, user_key=str(user_id), spam=spam):
# Engine gone. Stop rather than grind through hundreds of failures;
# the account stays unmarked, so a later run picks it up again.
return None
taught += 1
await asyncio.sleep(PAUSE_SECONDS)
return taught
async def bootstrap_account(
ctx: dict[str, Any], *, user_id: str, account_id: str, force: bool = False
) -> None:
"""Teach Bayes from one account's existing mail. Safe to call more than once.
``force`` runs it even for an account already marked done — the "learn from
my Spam folder now" button. The provider-Junk setting still applies: the
user turned that off for a reason.
"""
uid, acc = uuid.UUID(user_id), uuid.UUID(account_id)
settings = await policy.load_settings(uid)
if not settings.learn_from_provider_junk or not settings.covers(account_id):
return
done = await _SDK.kv(uid).get(_DONE_KEY, [])
if account_id in done and not force:
return
spam_ids = await _SDK.messages.sample(
uid, account_id=acc, special_use="junk", limit=SPAM_SAMPLE
)
ham_ids = await _SDK.messages.sample(
uid,
account_id=acc,
special_use="inbox",
limit=HAM_SAMPLE,
older_than_days=HAM_MIN_AGE_DAYS,
)
if len(spam_ids) < MIN_SPAM_CORPUS:
# Nothing to be learned from an empty Junk folder, and plenty of harm in
# learning only the ham beside it. Usually means the account is not
# syncing its provider's spam folder.
logger.info(
"antispam bootstrap for %s skipped: only %d spam samples (need %d); "
"enable spam-folder sync on the account to build a corpus",
account_id,
len(spam_ids),
MIN_SPAM_CORPUS,
)
return
ham_ids = ham_ids[: len(spam_ids) * MAX_HAM_RATIO]
spam_taught = await _teach(uid, spam_ids, spam=True)
if spam_taught is None:
logger.warning(
"antispam bootstrap for %s stopped: engine unavailable", account_id
)
return
ham_taught = await _teach(uid, ham_ids, spam=False)
if ham_taught is None:
logger.warning(
"antispam bootstrap for %s stopped: engine unavailable", account_id
)
return
if spam_taught == 0:
# Every sample was unreadable. Nothing was learned, so do not record a
# warm start that did not happen.
logger.warning(
"antispam bootstrap for %s: no samples could be read", account_id
)
return
if account_id not in done:
await _SDK.kv(uid).set(_DONE_KEY, [*done, account_id])
logger.info(
"antispam bootstrap for %s: learned %d spam, %d ham",
account_id,
spam_taught,
ham_taught,
)
await _SDK.publish(
"plugin.antispam.bootstrapped",
{
"user_id": user_id,
"account_id": account_id,
"spam": spam_taught,
"ham": ham_taught,
},
)
async def bootstrap_sweep(ctx: dict[str, Any]) -> None:
"""Find accounts that still need a warm start, and give them one.
The `account.state_changed` hook catches an account the moment it finishes
importing, which covers accounts added from now on. This covers everything
else, and it is the reason bootstrapping is not a thing anyone has to
remember to do:
* accounts that existed before this plugin did — their transition to `live`
happened long ago and will not happen again;
* accounts whose Junk folder was empty at the time and has since filled, or
which only started syncing the provider's spam folder later;
* a transition missed because the worker was restarting.
Cheap to repeat: `bootstrap_account` returns immediately for an account
already marked done, and declines (without marking) one whose corpus is still
too small to learn from — so this simply retries until there is something
worth learning.
"""
for user_id, account_id in await _SDK.accounts.live():
try:
await bootstrap_account(
ctx, user_id=str(user_id), account_id=str(account_id)
)
except Exception as exc: # noqa: BLE001 - one bad account must not stop the sweep
logger.warning(
"antispam bootstrap sweep failed for %s: %s", account_id, exc
)