> ## Documentation Index
> Fetch the complete documentation index at: https://docs.oximail.ch/llms.txt
> Use this file to discover all available pages before exploring further.

# Anti-spam

> The inbound spam pipeline of the community build: authentication verdicts, DNSBL, heuristic and Bayesian content scoring, the disposition rules, and how to train and tune it.

Inbound mail on port 25 runs through a scored pipeline before it reaches anyone's Inbox. This page covers the pipeline as the community (AGPL) build ships it, the knobs that tune it, and the feedback loop that trains it. The delivery consequences of a verdict — which folder, which tag, which SMTP reply — are described on [the SMTP layer](../architecture/oximail-smtp); this page is about how the verdict is formed.

<Note>
  The community build scores with authentication, network, heuristic, and Bayesian signals. More advanced content-analysis stages exist as a commercial extension and are deliberately absent here — the fields they would fill stay empty rather than being faked.
</Note>

## The pipeline, in order

1. **Trusted-peer check** (ADR-031). Loopback and `[security] trusted_ips` skip SPF, DNSBL, and DMARC-policy rejection — but never content analysis, because a compromised local application can still spam.
2. **Authentication verdicts.** SPF, DKIM, DMARC, and ARC results (from the inbound verification pass) feed the score. A DMARC `p=reject` failure is rejected outright with `550 5.7.26` before content scoring — that is an authentication decision, not a spam score.
3. **DNSBL.** The connecting IP is checked against the blocklists you configure (`[spam] dnsbl_servers`, empty by default). A listing weighs into the score.
4. **Content signals.** Heuristics (URL-shortener chains, header forgery, structural anomalies) and a **Bayesian classifier** trained per organization. Two guardrails keep the Bayes honest: it *trains on exactly the representation it scores* (no train/score drift), and votes in its statistical dead zone are discarded rather than coerced into a verdict.
5. **Disposition** (ADR-080). One shared function maps the final verdict to what happens: `Reject` → `550`; `Defer` → `451`; **`Junk` → the Junk folder**; *suspected* and *bulk* → **the Inbox, tagged** via `X-Spam-Status`, never junked. Only a confident verdict moves mail out of sight.

Two special flows sit alongside:

* **Trusted-reply whitelist.** A correspondent you have replied to is whitelisted — but the whitelist is only honored when the inbound envelope is **authenticated** (SPF/DKIM aligned), so a forged `From:` of a known contact gains nothing.
* **Spamtrap capture** (ADR-077). Mail to a designated trap recipient is accepted and force-filed as `$junk` ground truth for the corpus, rather than rejected — the trap exists to collect exactly that mail.

## No greylisting

There is **no greylisting step**, and no configuration turns one on. The `RCPT`-time gate — temp-fail an unknown `(IP, sender, recipient)` triple with `451` and wait for a retry — was retired (ADR-128), because the assumption it rested on stopped holding: modern botnets retry, large legitimate senders answer from IP pools that never re-present the same triple, and the cost fell on exactly the correspondents you most want to reach you, the ones writing to you for the first time. It also meant accepting a database write before authentication.

Authentication enforcement and the scored pipeline carry that load instead. If you are migrating from a server where greylisting was doing visible work, the equivalents here are DNSBL weighting, the authentication verdicts, and the Bayesian corpus — not a knob to re-enable.

<Note>
  A **score-conditional** variant is specified — greylist only in the ambiguous score band, where a retry is evidence rather than a toll. It is not active in this build and is not operator-settable; the only thing running today is the janitor that ages out the old triple table. Nothing on this page depends on it.
</Note>

## Training and feedback

The Bayes corpus learns from user actions: moving a message to Junk trains it as spam, rescuing one from Junk trains it as ham (with a cap on repeated ham votes for the same message class, so one enthusiastic user cannot skew the model). The corpus is versioned by **epoch**: when the token representation changes, the model is rebuilt from the stored corpus rather than mixing incompatible generations:

```bash theme={null}
oximail spam rebuild        # rebuild the model from the stored corpus
oximail spam ...            # inspect stats, import a labelled corpus, manage it
```

To ask the filter what it thinks **now**, rather than what it thought at ingest:

```bash theme={null}
oximail spam check --account alice@example.com --limit 20
```

That scores the account's most recent messages (Inbox and Junk) against the current corpus and prints the live verdict next to the score stored at ingest. It writes nothing. The distinction is the point: stored scores are frozen when the message arrived, so after any training or rebuild they describe a model that no longer exists — which is exactly the moment someone asks "would this still be junked today?".

The hourly retrainer fails **loud once** rather than logging an error every hour forever. Over the admin API, `/admin/v1/spam/stats`, `/spam/test`, `/spam/feedback/{tenant}`, and `/spam/retrain/{tenant}` expose the same operations for tooling. `POST /spam/retrain/{tenant}` runs a real retrain and returns its result (accuracy, samples used, model version); when no retrainer is configured — `spam.retrain_enabled` defaults to `false` since no box ships a training script — it answers **503 naming the two settings to change**, never a success for work that did not happen.

## Configuration

```toml theme={null}
[spam]
dnsbl_servers = ["zen.spamhaus.org"]   # empty by default

[security]
trusted_ips = ["10.0.0.0/8"]           # skip network checks, never content
```

Runtime-tunable thresholds are settable as [config overrides](./configuration) without a restart.

## Anti-abuse: rate limiting and fail2ban

Protection against brute force and floods is **in the binary** — do not install the fail2ban daemon next to OxiMail:

* **Rate limiter**: token bucket per IP (`[rate_limit]`), covering HTTP and SMTP surfaces.
* **Fail2ban**: authentication failures on every protocol (JMAP, IMAP, SMTP AUTH, DAV, ManageSieve) are tracked per IP; crossing `[security] fail2ban_max_attempts` bans the IP with escalation (60 min → 6 h → 24 h). Behind a proxy, the real client IP is read from `X-Forwarded-For` (only from `[server] trusted_proxies`) or the PROXY protocol, so bans hit the attacker, not your proxy.
* **Operator surface**: `oximail unban <ip>`, `oximail ban list|add`, and `GET/DELETE /admin/v1/bans` — see [Operations](./operations).

`[security] trusted_ips` bypasses both mechanisms for your own infrastructure.

## Reading the verdict on a message

Every scored message carries an `X-Spam-Status` header with the verdict and the contributing signals — the first place to look when a user asks "why was this junked" (or "why was this *not* junked"). The delivery decision it produced is in the server logs with full context.
