Skip to main content
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; this page is about how the verdict is formed.
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.

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. A backup MX of your own domains is not a trusted peer: the mail it relays is judged on the address of the client it served, read from the Received: line it wrote, with the same SPF, DMARC, DNSBL and score as a direct client (backup MX). Do not list a backup in trusted_ips; oximail doctor warns when one is.
  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. A valid ARC chain lifts a failing DMARC verdict only when its first sealer is listed in [spam] arc_trusted_sealers, empty by default (ARC and the DMARC exception): any domain can seal a chain, and a forger of a p=reject domain used to reach the Inbox by sealing its own.
  3. DNSBL. The connecting IP (or, behind a recognized backup MX, the client it served) 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.
A score-conditional variant exists (the rspamd pattern): after content scoring, a message whose score lands in the grey band (at least 2.0 and under the junk threshold, with no trusted signal, no allowlist or ARC pass, no conversation you started) is deferred 451 once, keyed on a masked network prefix plus the message itself, so an IP-pool sender retrying from a sibling address clears it. A retry in that band is evidence rather than a toll. It is off by default and operator-settable, [spam] greylist_enabled = true with greylist_duration_secs (300); activation is a deliberate per-box step, one canary first, never in the same window as a binary swap. Nothing else on this page depends on it.

Two verdicts, and the axes behind them

A message that tries to steal your credentials and a promotion for kitchen knives used to arrive with the same verdict, “junk”. They carry two now, because they answer two questions, “do you want this?” and “is someone pretending to be someone else?”, and the second is something a client shows rather than files away.
  • The phishing axis is a closed list of check codes whose firing is an assertion of impersonation, never of mere unwantedness: an institution claimed in the message with nothing of the institution’s in it and not one link going home; a sender display name that reads as an institution on a weak identity; and the four link deceptions that bear on the destination of a click (link text naming one domain and leading to another, an address hidden behind @, a bare IP host, a punycode host imitating Latin). It reads links in encoded HTML too (a sender in two encodes the illustrated version, and half the mail used to show no URL to the checks), speaks German as well as French (a fake delivery notice in German used to pass), and no longer mistakes a hotlinked logo for a link home: the hosts that count are the ones a reader can click. The axis is served on the message as phishing, normalised against the junk threshold, 1.0 meaning “this evidence alone was enough to condemn”, with its codes; a filter isPhishing selects on it.
  • The authentication axis (ADR-146) is the same pattern for the checks that fail by construction when a re-emitter without ARC forwards a legitimate message: SPF from the wrong IP, DMARC following, DKIM alignment lost by rewriting. It exists for one operational need: during a migration, the old provider forwards mail to the new mailbox, and that mail used to be condemned by default. oximail account set-forward-tolerance --email X --source mx.old.tld --until YYYY-MM-DD (dry-run by default, --apply writes, --clear removes) declares a tolerance per recipient account on a re-emitting source matched by EHLO domain, with a mandatory expiry of at most one year. The score is never lied to (SPF did fail, the journal says so); at delivery, if the tolerance covers the source and the condemnation comes from the authentication axis alone, the message is re-disposed to the Inbox with a tag instead of Junk, for that recipient only. A published p=reject stays outside the tolerance: ARC is the ecosystem’s sanctioned answer, and bypassing a sender’s policy is not ours. Expired rows are purged, and ARC with a passing original DMARC remains the preferred path, from a sealer listed in [spam] arc_trusted_sealers.

Measured on the fleet, then changed

  • Established correspondents. A domain has an established posture in an account once that account has kept and read one of its messages for at least 14 days (up from 7, measured over 119 000 messages: legitimate mail that needed the posture was identical at 3, 7 and 14 days, and junk from domains holding one fell), and the posture softens a solitary condemnation from the statistical model or a single DNSBL listing; a correspondent you exchange with no longer goes to Junk on the Bayes verdict alone, since that verdict used to weigh exactly the Junk threshold by itself. The relation is read both ways (your own site’s contact form and your registrar’s notices are no longer perpetual strangers), and it is not manufactured by ignoring it.
  • Mass mail signals. A List-Unsubscribe one-click signature is no longer a free pass above the threshold (a listed blast with evidence above it used to ride that immunity into the Inbox); a Bcc-blast without authentication reaches the suspected band whatever the TLD (the escalation used to depend on a list of twelve); a transaction status (Paiement accepté, Commande réussie) is not a sender name; a rule forward no longer launders a message already judged (a campaign junked on an aliased mailbox was fanned out clean to three humans), and the copy a rule redirects to another local account keeps the phishing verdict, authentication results and BIMI selector recorded when the message entered the server, carried rather than re-evaluated (it used to land as if nothing had been checked).
  • DNSBL answers are read, not believed. A listing is a 127.0.0.x code in the zone’s own vocabulary; the 127.255.x error band (“query refused”, a typo in the zone name, a public resolver the list blocks) and the aberrant answers of a resolver that hijacks non-existent domains no longer count as a listing, where any answer used to. The other way round, only NXDOMAIN means “not listed”: a refusal, an answer without an address (NODATA), a SERVFAIL or a timeout scores nothing, but it is logged and counted, where it used to read as clean, so a list that stopped answering the box silently checked every message against nothing. oximail_dnsbl_answers_total{source,list,outcome} counts every answer (source is inbound for the filter, self for the box’s own sending IP; outcome is listed, clean, refused or inconclusive), and oximail doctor --reachability asks each list for its test entry 127.0.0.2 (RFC 5782 §5) under dnsbl_lists: listed is ok, a refusal of the box’s resolver is a failure, any other answer a warning.
  • The organizational domain follows the current suffix list. The public suffix list embedded in the spam filter (the phishing link checks) and the brand-logo resolver moved from its May 2026 release to its September 2026 one (28 releases of suffixes added and removed), so a site under a newly listed suffix, such as chatgpt.site, is no longer taken for part of its neighbour’s organizational domain.
  • A verdict is never lost on the way in. A message the filter condemned used to surface in the Inbox with no trace of why when the Junk mailbox lookup failed at filing time; that failure is loud now, and the DMARC aggregate-report alert says what the reporter explained (the override reason it applied), not just how many failed.

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, from the webmail’s junk button as from IMAP (the gesture used to pass through a folder property the detector never saw); a gesture that contradicts a verdict the filter had anchored as legitimate (your own support tool’s notification, a colleague) is recorded as an opinion and does not teach the shared corpus to see spam in its own working vocabulary (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:
To ask the filter what it thinks now, rather than what it thought at ingest:
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

Runtime-tunable thresholds are settable as config overrides 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) 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.
[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.