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
- Trusted-peer check (ADR-031). Loopback and
[security] trusted_ipsskip 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 theReceived:line it wrote, with the same SPF, DMARC, DNSBL and score as a direct client (backup MX). Do not list a backup intrusted_ips;oximail doctorwarns when one is. - Authentication verdicts. SPF, DKIM, DMARC, and ARC results (from the inbound verification pass) feed the score. A DMARC
p=rejectfailure is rejected outright with550 5.7.26before 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 ap=rejectdomain used to reach the Inbox by sealing its own. - 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. - 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.
- 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 viaX-Spam-Status, never junked. Only a confident verdict moves mail out of sight.
- 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
$junkground 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. TheRCPT-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 asphishing, normalised against the junk threshold,1.0meaning “this evidence alone was enough to condemn”, with its codes; a filterisPhishingselects 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,--applywrites,--clearremoves) 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 publishedp=rejectstays 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-Unsubscribeone-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.xcode in the zone’s own vocabulary; the127.255.xerror 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, onlyNXDOMAINmeans “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 (sourceisinboundfor the filter,selffor the box’s own sending IP;outcomeislisted,clean,refusedorinconclusive), andoximail doctor --reachabilityasks each list for its test entry127.0.0.2(RFC 5782 §5) underdnsbl_lists: listed isok, 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:/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
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_attemptsbans the IP with escalation (60 min → 6 h → 24 h). Behind a proxy, the real client IP is read fromX-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, andGET/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 anX-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.