Skip to content

feat: add ML-based antispam classifier (v0.2) - #388

Open
rafpigna wants to merge 19 commits into
maathimself:mainfrom
rafpigna:feature/spam-ml-v0.2
Open

feat: add ML-based antispam classifier (v0.2)#388
rafpigna wants to merge 19 commits into
maathimself:mainfrom
rafpigna:feature/spam-ml-v0.2

Conversation

@rafpigna

Copy link
Copy Markdown
Contributor

Summary

This PR implements the v0.2 ML classifier for the antispam filter, following the plan discussed in #156 (v0.1 manual feedback → v0.2 Bayesian ML → v0.3 SpamAssassin). It builds on the spam_training_log infrastructure shipped in v0.1 (PR #181): when a user marks a message as spam/ham, the message features are now captured at mark time (Solution C — no JOIN on the messages table) and a per-user Multinomial Naive Bayes model is trained and used to auto-classify incoming messages into the Junk folder.

The classifier is deliberately a hybrid 3-layer system, designed so that v0.3 (SpamAssassin) can slot into the same interface later:

  1. Manual user override — always wins, never overridden by the model.
  2. Per-user MNB model — active once a user has ≥ 50 training records.
  3. 14-rule heuristic engine — always on, and the only scorer below 50 records.

Because this is a self-hosted product with the user's own mail data, the design puts privacy and auditability first (see the threat model in Testing).

Changes

Backend

  • Schema (migrations 0052–0055, all after the current upstream max 0048):
    • 0052_spam_training_features — captures subject/body/token counts/flags/sender/attachments at mark time (Solution C).
    • 0053_spam_models — per-user model state: JSONB vocabulary, weighted totals (REAL, since the decay retrain produces fractional counts), priors, decay window, model version.
    • 0054_email_accounts_antispam_enabled — per-account toggle (default OFF).
    • 0055_spam_training_deletions — GDPR audit trail for training-data resets.
  • Core ML (src/services/spam*.js):
    • spamTokenizer — bag-of-words (subject ×2, HTML strip, unicode CJK/Cyrillic, URL-host tokens, stop-words in 7 languages, heuristic flag features, message fingerprint).
    • spamParserAuthentication-Results parsing per RFC 7601/8601.
    • spamRules — the 14 heuristic rules approved in the design doc (attachment dedup uses max, not sum).
    • spamModel — MNB scoring in log-space: priors derived from the training set, fixed asymmetric auth weights (dkim/spf fail +0.7, dmarc fail +0.5, pass −0.2/−0.1/−0.1), chi-square feature selection (cap 10k vocab), confidence blending.
    • spamModelStore — DB I/O with a 5-minute in-process cache; spamScheduler — hourly retrain with per-user staggered bucket (hash(user_id) % 24) and configurable exponential decay (default 90 days, range 7–365).
    • spamPipeline — classifies new messages on ingest (hook in imapManager), auto-moves to Junk at ≥ 0.95 only when the ML is active, persists verdict/spam_details. Auto-verdicts are never written back to the training log (prevents self-poisoning; only manual feedback trains).
  • API (src/routes/spam.js): status, per-user thresholds (GET/PATCH, admin), decay threshold (7–365), admin retrain-now, master enable, GDPR reset (per-account + global, with explicit confirm), deletions audit, and a Why? explain endpoint (fired rules + top ML tokens). Two-level enable: per-user master switch + per-account toggle; the pipeline does a JOIN users and skips when the master is off.

Frontend

  • New Antispam tab in the Admin panel: model status, score-slider thresholds, decay control, master switch, admin retrain, GDPR reset with confirm dialog.
  • SpamExplainModal ("Why?" ) — shows which rules fired and the top ML tokens behind a verdict.
  • SpamBadge — clickable spam 98% badge on message rows/panes (shown only for auto verdicts, so a manual override never looks like a model decision).
  • Per-account antispam_enabled toggle in the account form.
  • i18n strings for all 7 locales (en/it/de/es/fr/ru/zhCN).

Testing

  • Unit: backend 1275/1275, frontend 1644/1644 (incl. 1373 i18n), lint clean on both.
  • Coverage on new files (measured per-file — vitest aggregates were unreliable with cross-file mocks): spamModel 97%, tokenizer 96%, rules 96%, parser 98%, store 100%, pipeline 100%, scheduler 89%, routes 85%.
  • Performance (local benchmark): tokenize+flags 0.018 ms/msg, classify ~2 ms/msg @ 10–20k vocab, retrain 500 records ~3 ms, prune 10 ms.
  • E2E (greenmail): fresh-install (rules-only, no move) + active-user (blended ≥ 0.95 → auto-move to Junk) — both pass.
  • Migration upgrade path (tested from v3.1.3): I bootstrapped a clean Postgres with the 47 upstream migrations (0001–0047) plus a realistic v0.1 dataset (user, 2 accounts, spam_training_log rows, messages), then ran my migration set on top — 00520055 applied cleanly, existing data intact (features stay NULL for legacy rows), and the backend boots with the spam routes live and antispam_enabled defaulting to false.
  • Manual UI walkthrough (2 rounds, desktop + responsive/mobile): antispam tab, thresholds/decay persistence, GDPR confirm, badge in list + pane + thread, "Why?" modal, per-account toggle. (Screenshots: see the zip linked below.)
  • Security / threat model for /api/spam/*: none of the endpoints are public — 5 layers: (1) global CSRF guard on /api (X-Requested-With, 403 without it); (2) requireAuth on every spam router; (3) requireAdmin on threshold PATCH + retrain-now; (4) ownership check on explain and per-account reset; (5) explicit confirm:true on GDPR resets. Residual, intentional: a non-admin may read their own status and delete their own training data (a GDPR right). Future hardening: per-user rate-limiting on resets/retrain-now; IP address is already audited in 0055.
  • Migration numbers were re-validated against upstream immediately before the push (upstream max = 0048; mine are 0052–0055).

Design decisions: antispam toggle semantics (edge cases)

This section pins down what enabling/disabling the antispam feature does, so the
behavior in these edge cases is explicit and reviewed rather than discovered.

Two-level enablement. There are two independent switches, both gating
automatic classification: a per-user master switch
(users.preferences.spamEnabled, default on) and a per-account toggle
(email_accounts.antispam_enabled, default off / opt-in). The pipeline returns
skipped before scoring if either is off.

A toggle controls the whole automatic pipeline, not just auto-move. Turning
antispam off disables automatic scoring, auto-move to the spam folder, and the
auto-spam badge on new messages. It does not disable manual spam/ham marks —
those still train the model on any account.

The badge is historical, not a live toggle mirror. SpamBadge renders from
the persisted messages.spam_verdict written at analysis time. So:

Situation Already-present messages New messages after the change
Per-account toggle turned OFF Keep badges on ones already tagged spam (history). No re-analysis. Not analyzed → no badge, no auto-move. Other accounts unaffected.
Master switch turned OFF Keep badges on already-tagged spam (history). Not analyzed → no badge, no auto-move in any account.
Turned back ON Keep existing verdict/history; no backfill. Analyzed from now on → new badges/auto-move as confidence dictates.
Manual spam/ham mark Sets spam_user_override; badge hidden for that message. Same; manual marks always feed training regardless of toggles.

I deliberately chose the historical behavior: it does not "re-legitimize" months
of auto-filtered mail because a toggle was briefly flipped, does not erase
history on disable, and does not retroactively "pop" badges on re-enable
(spam_verdict is a property of the message, like category). If you'd prefer
the badge to mirror the live toggle state instead, that's a small isolated
frontend change — happy to switch on request.


Note: screenshots and user documentation

The UI walkthrough screenshots are shared as a public zip rather than embedded
inline, to keep the diff focused on code:

📦 antispam-v0.2-screenshots.zip

They cover the Antispam tab, thresholds/decay, the per-account toggle, the
auto-spam badge (list + pane), the "Why?" explain modal, and the GDPR reset
confirm dialog.

On documentation: I noticed this repository currently has no docs/ directory
or end-user/help documentation
— the only markdown beyond the code is
README.md, CONTRIBUTING.md, ROADMAP.md and CLA.md, even though the
official site does ship user docs at https://mailflow.sh/docs. Rather than
introducing the repository's first end-user docs as part of this (already large)
feature PR, I kept the antispam "user guide" out of scope. I'd welcome a quick
steer on whether you'd like the antispam UI documented in the docs site and/or
as a new e.g. docs/antispam.md here — happy to prepare that in a small
separate PR if you confirm the preferred location/format.


Contributor License Agreement

By submitting this pull request I confirm that:

  • I have read and agree to the Contributor License Agreement.
  • My contribution is my own original work (or I have identified any third-party material and confirmed it is compatible with the CLA).
  • I have the right to submit this contribution under the terms of the CLA.

On AI assistance

AI tools and LLMs were used to speed up development, scaffolding, code generation,
bug fixing and analysis. All key decisions about architecture, design and project
strategy, as well as the real-world E2E tests on actual usage scenarios, were made
and supervised by a human.


— r.

Renumbered migrations to avoid collisions with upstream:
- 0022 -> 0048 (upstream took 0022 for password_reset_tokens)
- 0035 -> 0049 (upstream took 0035 for oidc_rp_initiated_logout)
- 0036 -> 0050 (upstream took 0036 for unified_inbox_accounts)
Core text pipeline for the v0.2 ML classifier (V2-3):
- stopWords.js: stop-word sets for the 7 UI locales (en, it, de, es,
  fr, ru, zhCN), >=50 words per language
- spamParser.js: Authentication-Results parsing per RFC 7601/8601
  (not RFC 8054, which is NNTP), multi-header and folded-header safe
- spamTokenizer.js: bag-of-words tokenization (subject weighted x2,
  HTML stripping via htmlparser2, Unicode scripts incl. CJK and
  Cyrillic, URL-host tokens), flag feature extraction matching the
  spam_training_log.flag_features schema (migration 0048), and a
  stable token fingerprint for classify-dedupe

Pure functions only; no I/O. Design: spam-classifier-v0.2.md §6.1.
Always-on Layer 1 of the hybrid classifier, per the maintainer-reviewed
rules catalog (spam-rules-detailed.md):
- 14 rules: 9 SpamAssassin-style + 4 best-practice + 1 MailFlow-specific
  (FROM_IN_USER_CONTACTS with Gmail local-part normalization)
- scoreRules() clamps the weighted sum to [0,1]; attachment rules
  (EXECUTABLE + DOUBLE_EXT) are deduplicated in the score but both stay
  visible in the fired list for explainability
- explainRules() drives the future "Why?" modal
- AUTH_*_FAIL rules stay neutral when no Authentication-Results header
  exists at all (a client-side mailbox would otherwise score +1.2 on
  every message and clamp to 1.0); individual methods absent from a
  present header still fire
- Registrable-domain comparison for FROM_REPLYTO_MISMATCH uses a
  pragmatic multi-label suffix list, no new dependency (same approach
  as senderFavicon.js)
Layer 2 of the hybrid classifier — pure math, no I/O (V2-3):
- updateIncremental: per-token count increments + derived priors from
  the training set (total_spam/total_ham proxy per design §6.2), never
  hardcoded 0.5 except cold start
- classifyMessage: log-probability-space scoring with Laplace smoothing
  (alpha=1.0); fixed asymmetric auth weights from ADR-001 v2
  (dkim/spf fail +0.7, dmarc fail +0.5, pass -0.2/-0.1/-0.1) applied as
  token-free log-odds offsets, absent header = neutral
- heuristic flags learned as special __tokens__ (attachment, mismatch,
  all-caps); auth flags NOT stored in the vocabulary (would double-count
  the fixed weights)
- pruneVocabulary: chi-square feature selection, cap 10k, drop count<3
  and tf>0.05 (design §14)
- blendScores: rules-only <50, 60/40 50-500, 20/80 >500 (design §6.3)
- extractTopTokens: top discriminative tokens for the explain modal

Implements the SpamClassifier interface contract (score/train/prune)
so v0.3 can swap the algorithm behind the same boundary.
V2-4 DB integration layer:
- spamModelStore.js: spam_models DB access behind a 5-min in-memory
  cache (same pattern as categorizer.js socialDomainCache). Exposes
  getModelForUser (null on cold start), saveModel (UPSERT + cache
  invalidate), updateIncrementalForUser (feature extraction at mark
  time + spamModel.updateIncremental), getAllUsersWithTraining(Log)
  and retrainUser (full rebuild from spam_training_log, NO JOIN,
  with exponential decay 2^-age/days)
- spamScheduler.js: hourly job that retrains only the current
  staggered bucket (offset = hash(user_id) % 24, design §7.2 fix v4)
  to avoid a 00:00 UTC thundering herd; runFullRetrain() for the
  admin endpoint; stable per-user offset with uniform distribution

Decay is downweighting, never deletion (ADR v2). Retrain reads
pre-tokenized token_counts and falls back to raw text for v0.1 rows.
- spamModel.js: retrainFromRecords(records, decayThresholdDays) rebuilds
  a model from spam_training_log rows with exponential time decay
  (2^-age_days/threshold), supporting pre-tokenized token_counts and
  raw-text fallback for v0.1-era rows; priors derive from the weighted
  totals; pure function with injectable clock for tests
- index.js: startSpamScheduler() at boot next to the CardDAV scheduler
V2-5: wire the /spam and /ham endpoints to the v0.2 ML pipeline:
- extractSpamFeatures: builds token_counts, flag_features, sender_domain
  and attachment_types at mark time (Solution C, migration 0048) from the
  messages row, so the retrain path never joins back to messages
- logSpamTraining: single shared INSERT that now also persists the new
  feature columns, then feeds the per-user Naive Bayes model via
  spamModelStore.updateIncrementalForUser (non-blocking on failure so
  user feedback never breaks the HTTP path)
- both spam and ham flows (no-op already-in-folder and IMAP move path)
  now train the model in <1s
- mail.spam.test.js: integration tests for both routes asserting the
  feature columns are stored and updateIncrementalForUser is called with
  the right label
V2-6: wire the classification pipeline into message ingest:
- spamPipeline.classifyAndTagMessage(messageId): hybrid 3-layer scoring
  for newly-inserted messages (imapManager processMsg is_new branch)
  - user_override always wins (skip)
  - antispam_enabled gate (default OFF — opt-in per account)
  - rules engine always on; MNB model joins when training_records >= 50;
    blend 60/40 (50-500) / 20/80 (>500) per design §6.3
  - verdict spam >= 0.85, auto-move to folder_mappings.spam >= 0.95
    and only with an ML-backed score (rules-only never auto-moves)
  - persists spam_verdict/spam_score_ml/spam_analyzed_at/spam_details
    (JSON: method, blended+rules scores, fired rules, top tokens)
- imapManager hook is fire-and-forget: the sync loop is never blocked,
  failures only log; the IMAP move is delegated through an injected
  facade (no import cycle with routes)
- auto-verdicts never write spam_training_log (only explicit /spam /ham
  trains the model — avoids poisoning)

10 unit tests covering gates, fresh-install no-move, active-user
auto-move, unconfigured spam folder, borderline ham, blend shift and
move-failure resilience.
The greenmail E2E script (e2e_antispam.py) is a local dev tool like
seed_inbox.py; keeping it out of the PR per the v0.2 file convention
documented in .hermes/v0.2-notes/reports.md.
V2-7: /api/spam/* namespace with per-user threshold control, GDPR
erasure and explainability:
- GET /status: model version, training records, last trained, decay
  days, maturity band and per-user/account enable state
- GET/PATCH /thresholds: read/update per-user spam thresholds stored
  in users.preferences.spam_thresholds (PATCH admin-only, range
  spamThreshold 0.5-0.99, autoMoveThreshold 0.7-0.99)
- GET/PATCH /decay-threshold: user-configurable decay window 7-365
  days (upserted on spam_models, invalidates the model cache)
- POST /retrain-now (admin): full staggered retrain
- POST /enable: per-user master switch (users.preferences.spamEnabled)
- POST /reset-training-all + GET /deletions: GDPR right-to-erasure with
  explicit confirm, audit trail row, counts; per-account reset on the
  /api/accounts/:id/spam/reset-training namespace (design §16)
- GET /explain: rules fired + top ML tokens for the Why? modal

Migration 0052: spam_training_deletions audit table (Option B).
- index.js: mount /api/spam router and the per-account GDPR reset
  router on /api/accounts/:id/spam/reset-training
- accounts.js: allow antispam_enabled in the account PUT allowlist
  (per-account toggle for the settings page)
- spamPipeline: skip classification when the new per-user master
  switch users.preferences.spamEnabled is off (JOIN users in the
  ingest query); test for the new skip reason
The account edit form needs the per-account antispam toggle to load its
current state. GET /api/accounts now projects antispam_enabled alongside
categorization_enabled.
V2-8 frontend:
- SpamSettings.jsx: per-user antispam tab in AdminPanel (model status,
  thresholds sliders, decay window, master switch, retrain-now, GDPR
  reset-all with confirm, deletion audit trail)
- SpamExplainModal.jsx: "Why was this marked as spam?" modal showing
  fired rules + top ML tokens (opened from the badge)
- SpamBadge.jsx: inline spam verdict badge (score %) next to the subject
  in MessageRow, ThreadRow and MessagePane; click opens the explain modal
- AdminPanel: new Antispam tab (TAB_GROUPS + TABS + content), per-account
  antispam_enabled toggle in the account edit form
- Modal + badge are wired in MessageList (regular + thread rows) and
  MessagePane

i18n (all 7 locales): top-level spam.* block + admin.tabs.antispam +
admin.accounts.antispamSection/Enabled/EnabledDesc; i18n.test.js updated
with the dynamic keys and intentional same-value groups (Antispam, Spam,
Verdict, Mature, Fresco loanwords).
Top-level spam.* block (settings, explain modal, badge) plus
admin.tabs.antispam and the three admin.accounts.antispam* keys.
i18n.test.js: register the template-literal keys (maturity, deleteScope,
verdict, method) in DYNAMIC_KEYS and the intentional shared loanword
values in SAME_VALUE_ALLOWED (Antispam, Spam, Verdict, Mature, Fresco).
V2-9 coverage: spamModelStore was at 67% lines; retrainUser (the
scheduled/admin full rebuild) had no direct tests. Added:
- no_training_data fast path (no upsert attempted)
- full rebuild from training rows: vocabulary aggregation, decay
  default 90, special-token flags, upsert payload shape
- decay_threshold_days carried from an existing model row

Store coverage now 100% statements/lines. Uses a partial module mock
so the existing query-assertion tests still observe the real upsert.
The full-retrain test seeded training rows with created_at = test-run now;
retrainFromRecords applies 2^-age/90d which, a few ms after seed, yields
0.9999999999 instead of exactly 1.0. Assertions now use toBeCloseTo(…, 5)
for counts — the test is time-independent and no longer flaky in the full
suite run.
Fix four issues surfaced by the v0.2 UI walkthrough:

- Badge: the message list and pane never exposed
  spam_verdict/spam_user_override/spam_score_ml in their SQL projections
  (listMessages threaded + flat, GET /messages/:id, resolve-message COLS),
  so SpamBadge had no data to render. Add the three columns to all of them.
- Feedback: SpamSettings showed saves via an inline box that was nearly
  invisible (var(--accent-dim)). Switch to the app-wide toast system
  (addNotification) so Save / Retrain now / GDPR reset and errors surface
  a self-dismissing popup, matching the manual spam/ham classification UX.
- Contrast: the status box labels used var(--text-tertiary) on --bg-hover;
  use --text-secondary with weight 600.
- Account toggle staleness: antispam_enabled was missing from SAFE_FIELDS
  in safeAccount, so PUT /accounts/:id returned a payload without it and the
  store never refreshed the toggle until a full reload. Add it to SAFE_FIELDS.
Polish from the second UI walkthrough:

- Badge: replace the ambiguous two-star glyph with a clear text label
  (SPAM INDEX / INDICE SPAM / … in all 7 locales) plus the score
  percentage. This applies both in the message pane and the list.
- List visibility: the subject row was not a flex container, so the
  badge sat inside an overflow:hidden + ellipsis box and was clipped
  out of view on long subjects. Make the row flex with the subject
  truncating (flex:1 min-width:0) and the badge shrink-proof.
- Explain modal: section titles and the Verdict / Method / Confidence
  labels used var(--text-tertiary) on the dark card, which was nearly
  invisible. Bump them to var(--text-secondary), same as SpamSettings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant