New "arc" inbound_auth mode derives dkim/dmarc only from an ARC-sealed ARC-Authentication-Results whose outermost sealer is in SPAM_CONFIG["trusted_arc_sealers"] (empty = any valid seal); plaintext headers are never trusted. New SPAM_CONFIG["rules"][..]["arc_verdict"] (trusted|untrusted) marks or drops inbound mail lacking a valid trusted seal, so a publicly-reachable mta-in effectively accepts only mail sealed by a trusted relay. The chain is verified with dkimpy; a verification/DNS failure never spams or drops. Config reference in docs/spam.md. --------- Co-authored-by: Riël Notermans <riel@mosa.cloud> Co-authored-by: Riël Notermans <riel@zzapps.nl>
25 KiB
Spam & sender-authentication processing
This document describes how Messages classifies inbound mail as spam or forged, how that classification is configured (globally or per mail domain), how it can be extended (native rspamd, header rules, custom webhooks, or an upstream MX gateway), and how the results are surfaced to users.
Overview
Every externally-received message runs through an inbound pipeline before it is delivered. The pipeline is assembled per recipient mailbox and, for external mail, runs these steps in order:
- Before-spam webhooks (
message.inbound) — user/integration webhooks that may drop, defer, or pre-decide the spam verdict. - ARC — holds (
RETRY) a message whose trusted ARC seal couldn't be verified because of a DNS failure (only whentrusted_arc_sealersis set). See ARC relay-trust. - Hardcoded rules — deterministic
header_matchrules andarc_verdictrelay-trust rules from config. - rspamd — native
/checkv2scan. - Inbound authentication — DKIM/DMARC verdict (SPF indirectly).
- After-spam webhooks (
message.delivering, thenmessage.delivered).
Each step returns a Decision (CONTINUE / RETRY / DROP) and may set the
spam verdict. The verdict is a tri-state ctx.is_spam: None (undecided) until
a step decides it; the last decisive step wins, and the hardcoded-rules and
rspamd steps are skipped entirely once a verdict already exists. This lets a
before-spam webhook, an internal-origin short-circuit, or a header rule pre-empt
rspamd.
Two distinct outcomes are produced:
is_spam(boolean) — routes the message to the Junk view and suppresses auto-reply and push notifications. Set only by high-confidence signals (rspamdquarantine/reject, a matchingspamrule, or a webhook override).- Graded UI markers (
postmark["spam"]="possible"/"likely") — the message still lands in the inbox but shows a "may be / likely spam" banner.
Internal mailbox-to-mailbox mail and selfcheck probes are trusted: the task
pre-sets is_spam = False and the pipeline omits the spam and auth steps
(webhooks still fire, so consumers can't tell the difference).
Implementation:
core/mda/inbound_pipeline.py(pipeline),core/mda/spam.py(rspamd + rules),core/mda/inbound_auth.py(DKIM/DMARC),core/mda/inbound_tasks.py(task wrapper). Webhooks are documented in webhooks.md.
rspamd native integration
Messages talks to rspamd over HTTP. The reference deployment ships an MPA
container (rspamd engine + nginx proxy, see src/mpa/) that exposes the
/checkv2 endpoint.
On each external message, call_rspamd() POSTs the raw RFC-822 bytes to
{rspamd_url}/checkv2 (Content-Type: message/rfc822, 10 s timeout), optionally
with an Authorization header, and forwards the SMTP envelope as rspamd scan
headers (From→mail_from, Rcpt→rcpt_to, IP→ip, Helo→helo,
Hostname→hostname) so rspamd can evaluate SPF, reputation, etc.
Messages does not interpret the rspamd score. It reads back rspamd's
action and maps it to a delivery outcome. This is the single source of truth
for that mapping:
rspamd action |
Outcome |
|---|---|
no action |
Deliver to inbox, not spam |
greylist, soft reject |
RETRY — held and re-tried (temporary defer, see below) |
add header |
Deliver to inbox, UI marker spam = "possible" |
rewrite subject |
Deliver to inbox, UI marker spam = "likely" |
quarantine, reject |
is_spam = True → Junk (reject can't be honored at SMTP time, so it lands in Junk) |
discard |
DROP — accepted and silently blackholed, no bounce |
| unknown / unmapped | Deliver to inbox |
If rspamd is not configured (rspamd_url absent), the step is a no-op and
the verdict is left to other steps. If rspamd is configured but errors or is
unreachable, the message is held for RETRY (fail-closed, never
fail-open) rather than delivered unchecked — see deferral below.
DKIM/DMARC symbols from the same /checkv2 response are reused by the inbound
authentication step (below) when it runs in rspamd mode, so a message is only
scanned once.
Configuration: global and per-domain
Spam behavior is driven by a single SPAM_CONFIG dictionary.
-
Global — the
SPAM_CONFIGDjango setting (env varSPAM_CONFIG, a JSON/dict value, default{}). -
Per mail domain — a
MailDomainmay override any subset of keys viaMailDomain.custom_settings["SPAM_CONFIG"]. Resolution is a shallow key-by-key merge over the global config:spam_config = settings.SPAM_CONFIG.copy() if maildomain.custom_settings and "SPAM_CONFIG" in maildomain.custom_settings: spam_config.update(maildomain.custom_settings["SPAM_CONFIG"])(
MailDomain.get_spam_config(), resolved at delivery time from the recipient mailbox's domain.)
There is no per-mailbox spam configuration — the effective scope is global → mail domain. (A future enhancement could add a per-mailbox layer.)
SPAM_CONFIG keys
| Key | Type | Meaning |
|---|---|---|
rspamd_url |
string | Base URL of the rspamd HTTP endpoint (/checkv2 is appended). Omit to disable rspamd. |
rspamd_auth |
string | Optional value for the Authorization header sent to rspamd. |
inbound_auth |
string | Sender-auth backend: native, rspamd, arc, or authentication-results. Omit/empty to disable DKIM/DMARC checks. |
trusted_relays |
int | Number of sender-side Received/Authentication-Results blocks to trust, counting from the boundary our own MTA prepends. Default 0 (trust only our own hop). Raise this when a fixed upstream gateway sits in front. Not used by inbound_auth: "arc". |
trusted_arc_sealers |
list | ARC sealer d= allowlist. Fail closed: [] (or absent) trusts nothing — you must list your sealers. Used by inbound_auth: "arc" and by arc_verdict rules, and enables the ARC RETRY-on-DNS-failure hold (see ARC relay-trust). |
rules |
list | Ordered rules — header_match / header_match_regex or arc_verdict trust conditions — with action spam / ham / drop (see below). |
Related settings
| Setting | Default | Meaning |
|---|---|---|
SPAM_CONFIG |
{} |
Global spam config dict (above). |
MESSAGES_INBOUND_DEFERRAL_MAX_AGE |
172800 (48h) |
Max time a message may be held on RETRY before it is force-delivered flagged (see deferral). |
Note: there are no dedicated
RSPAMD_*env vars — the rspamd URL and auth live insideSPAM_CONFIG(rspamd_url,rspamd_auth).
Sender authentication (DKIM / DMARC)
The inbound-auth step produces a sender-auth verdict independent of the spam
verdict, stored in postmark["auth"] and surfaced in the UI (below):
fail→ the message is a likely forgery (DMARC fail).none→ the sender's identity could not be verified (DKIM not passing, no enforceable DMARC).- verified → nothing recorded (no banner).
The backend is selected by SPAM_CONFIG["inbound_auth"]:
native— verify the DKIM signature locally (crypto + DNS) and require strict alignment between the signingd=domain and theFrom:domain. Never returnsfail(no DMARC policy lookup); worst case isnone.rspamd— read DKIM/DMARC symbols from the rspamd/checkv2result (reusing the spam-step scan). Verdict precedence:fail>pass>none.arc— readdkim=/dmarc=from theARC-Authentication-Resultsthat a trusted sealer cryptographically sealed (RFC 8617). Plaintext headers are never read; an unsealed or untrusted-sealed message isnone. See ARC relay-trust.authentication-results— parsedkim=/dmarc=from theAuthentication-Resultsheader(s) added by trusted upstream relays (bounded bytrusted_relays). Use this when an upstream MX gateway already does authentication.
rspamd symbol → outcome (mode rspamd)
| Check | Symbols → pass |
Symbols → fail |
Symbols → none |
|---|---|---|---|
| DKIM | R_DKIM_ALLOW |
R_DKIM_REJECT, R_DKIM_PERMFAIL, R_DKIM_TEMPFAIL, DKIM_INVALID |
R_DKIM_NA, DKIM_NA |
| DMARC | DMARC_POLICY_ALLOW |
DMARC_POLICY_REJECT, DMARC_POLICY_QUARANTINE, DMARC_BAD_POLICY |
DMARC_NA |
Final rule: a DMARC fail yields forged (auth = "fail"); otherwise if DKIM
is not pass, unverified (auth = "none"); otherwise verified.
SPF is not surfaced as a standalone verdict. It only influences classification indirectly through rspamd scoring (the envelope is forwarded to rspamd). The user-facing auth verdict is DKIM + DMARC only.
ARC relay-trust
ARC (Authenticated Received
Chain) lets an intermediary that observed a message's original authentication
cryptographically seal that observation, so a downstream receiver can trust
it even after forwarding breaks SPF/DKIM. Messages uses ARC two ways, both keyed
off one allowlist, trusted_arc_sealers:
trusted_arc_sealers— thed=domains whose seals we trust (subdomains match). Fail closed:[](or absent) trusts nothing — anyone can produce a valid ARC seal, so you must list the sealers you trust. Trust is granted only when the chain iscv=passand the outermost sealer is on the allowlist.
The result is a binary verdict (core/mda/arc.py):
arc_verdict |
Meaning |
|---|---|
trusted |
cv=pass and sealed by an allowlisted sealer |
untrusted |
everything else — no ARC chain, a chain from an unlisted sealer, or a chain that fails to validate |
We only verify seals we could trust. If the allowlist is empty, or the message's outermost sealer is not on it, the chain is
untrustedregardless of validity, so we skip crypto + DNS entirely. Attacker-controlled mail (which never names a trusted sealer) therefore triggers zero DNS traffic, and a forged chain claiming a trusted sealer is capped at 20 instances before we refuse to verify.
1. As a sender-auth verdict (inbound_auth: "arc") — dkim/dmarc are read
from the trusted sealer's sealed ARC-Authentication-Results; untrusted/unsealed
→ none. See Sender authentication.
2. As a gating rule — an arc_verdict rule acts on the verdict:
{ "arc_verdict": "untrusted", "action": "drop" } // discard (no Message)
{ "arc_verdict": "untrusted", "action": "spam" } // route to Junk
{ "arc_verdict": "trusted", "action": "ham" } // allowlist trusted mail
Rules are evaluated in list order (first match wins), so an arc_verdict rule
composes with the header rules in the same rules list. A dnsfail (below) is
indeterminate and matches neither verdict.
DNS failures hold, they don't fail open
dnsfail is an internal, transient signal — never an arc_verdict value. A
key-record lookup that doesn't complete (timeout / SERVFAIL / NXDOMAIN / empty)
is indeterminate, not a forgery — NXDOMAIN in particular can be transient
(negative caching after a fresh publish, a zone mid-reload). Because we only
verify seals from a listed sealer, a dnsfail only ever arises for a message
claiming one of your trusted sealers. Such a message is held for retry
(Decision.RETRY) by the arc pipeline step — never delivered unverified,
never dropped. The hold is bounded by MESSAGES_INBOUND_DEFERRAL_MAX_AGE (48h
default). Past the window the seal is deemed unresolvable (our own relay's
DNS works, so a key that never resolved for 48h is treated as bogus) and
reclassified to a definite untrusted verdict — so the arc_verdict rules then
apply (an untrusted → drop/spam rule fires) rather than force-delivering it.
Widget submissions are exempt. Messages from a widget channel's web form carry no seal by construction, so the arc step and
arc_verdictrules skip them — anuntrusted→droprule never discards first-party form traffic. (rspamd andheader_matchrules still apply.) If no widget channel exists, no widget-origin mail exists in the first place.
Fail closed: an empty
trusted_arc_sealerstrusts nothing — withinbound_auth: "arc"every message is thennone(unverified), and anarc_verdict: "untrusted"rule matches every non-widget message (widget submissions stay exempt, per above). Populate the allowlist (it may list several sealers, e.g. an external relay plus an internal gateway) to actually trust anything.
Single-relay assumption: the sealed
ARC-Authentication-Resultsis read from the outermost ARC instance — correct for one trusted relay in front of you. With two or more sealing hops the outermost AAR reflects the last hop's re-evaluation (which may showdkim=failfor legitimately forwarded mail), collapsing tononerather than recovering the origin verdict from an inner instance. This fails safe (never a false "verified") and is a deliberate limitation, not a bug.
Examples
Public MX, accept only trusted-ARC-sealed mail, junk the rest:
{
"inbound_auth": "arc",
"trusted_arc_sealers": ["relay.example"],
"rules": [{ "arc_verdict": "untrusted", "action": "spam" }],
"rspamd_url": "http://rspamd:11334/checkv2"
}
Third-party MX relay, ARC mandatory + honor the relay's X-Spam verdict. The
relay is your published MX: it scans mail, ARC-seals it (d=relay.thirdparty.example),
stamps X-Spam-* headers, then forwards to your MTA.
{
// Sender-auth banner comes from the relay's sealed results.
"inbound_auth": "arc",
"trusted_arc_sealers": ["relay.thirdparty.example"],
// The relay adds one hop in front of our MTA, so its X-Spam / Received
// headers land in block 1. Trust block 0+1 (raise if it chains more hops).
"trusted_relays": 1,
"rules": [
// 1. ARC mandatory: discard anything the relay didn't seal. First, so
// unsealed mail dies before any of its headers are trusted.
{ "arc_verdict": "untrusted", "action": "drop" },
// 2. Honor the relay's verdict — safe because rule 1 guarantees every
// surviving message came through the relay.
{ "header_match": "X-Spam-Flag: YES", "action": "spam" }
]
// No rspamd_url: the relay already scans.
}
Because trusted_arc_sealers is non-empty, a DNS failure verifying the relay's
seal holds the message (RETRY) instead of dropping it — a relay-DNS outage
never silently discards legitimate mail. Prefer "action": "spam" over "drop"
in rule 1 while validating the setup, then tighten to drop.
Hardcoded rules
SPAM_CONFIG["rules"] is an ordered list of deterministic rules, evaluated
before rspamd. The first matching rule decides the verdict. Each rule has exactly
one condition plus an action:
| Field | Meaning |
|---|---|
header_match |
Literal Header-Name: value (case-insensitive). Must contain a colon. |
header_match_regex |
Regex alternative, full-match, case-insensitive. |
arc_verdict |
ARC relay-trust condition — trusted / untrusted (see ARC relay-trust). |
action |
spam / reject → mark spam; ham / no action → mark not-spam; drop → discard the message (no Message row). Default spam. |
Header rules honor trusted_relays: only headers within the trusted window (the
most recent trusted_relays + 1 header blocks, newest first) are considered, so
a spammer can't forge a header that an upstream you trust would have stripped or
overwritten. The Return-Path header is always ignored (spoofable envelope
value). arc_verdict conditions ignore trusted_relays — they use the
cryptographic chain, not header position.
This is the primary mechanism for honoring the verdict of an upstream filter (next section).
Upstream / edge MX filtering ("en amont")
Many deployments put a dedicated anti-spam gateway in front of Messages at
the MX edge — it scans mail before it ever reaches a mailbox and typically
stamps its verdict into a header (e.g. X-Spam-Flag: YES) and/or adds its own
Authentication-Results. Messages accommodates this without any native scanning
of its own:
-
Trust the gateway's position. Set
trusted_relaysto the number of hops the gateway adds, so its headers fall inside the trusted window and forged copies from further upstream are ignored. -
Honor its spam verdict with a hardcoded rule, e.g.:
{ "SPAM_CONFIG": { "trusted_relays": 1, "rules": [ { "header_match_regex": "X-Spam-Flag:\\s*YES", "action": "spam" } ] } }(Set globally or per mail domain, e.g. only for domains whose MX points at the gateway.)
-
Honor its authentication results by setting
inbound_auth: "authentication-results"so the gateway's DKIM/DMARC checks drive the sender-auth verdict instead of re-checking locally.
You can run an upstream gateway and rspamd together (defense in depth): header rules run first and short-circuit rspamd when they match, so the gateway's decision takes precedence and rspamd only scores what the gateway passed through.
Custom spam processor via webhooks
Webhooks can fully replace or override the built-in spam decision — see webhooks.md for the general webhook contract; this section covers the spam-specific behavior.
A blocking webhook (message.inbound or message.delivering) may return a
2xx JSON body containing:
{ "is_spam": true }
which sets ctx.is_spam to that boolean. Only a real JSON boolean is honored;
any other value means "no opinion".
Phase determines the semantics:
message.inbound(before-spam) runs before the header-rules and rspamd steps. Because those steps skip once a verdict exists, a before-spam webhook that setsis_spampre-empts rspamd entirely — i.e. it becomes your spam processor.message.delivering(after-spam) runs after rspamd, so it overrides the verdict rspamd produced.message.delivered(after-spam, fire-and-forget) is non-blocking; its response is ignored and it cannot influence the verdict. It receives the finalis_spamvalue for logging/sync.
The webhook payload carries the pending verdict in the X-StMsg-Is-Spam header
(pending while undecided during the before-spam phase, else true/false).
The response body may also drive other actions (action: "drop", labels,
assignment, skip_autoreply, etc.) — see webhooks.md.
Webhook channels are scoped global / maildomain / mailbox and must have
Channel.is_active = True to fire. Blocking-webhook results (including the
is_spam override) are cached for the deferral window and replayed on retry, so
a rspamd outage that forces a RETRY doesn't re-invoke an already-successful
before-spam webhook.
What happens to a spam message
Once is_spam = True:
- Foldering — spam is a boolean flag on the message/thread, not a separate
folder. The thread list excludes spam by default; the Junk view is the
same list with
is_spam=1. The thread'sis_spamfollows its first message, and spam messages are excluded from "active" counters/timestamps.is_spamis indexed for search and filtering. It is independent ofis_trashed. - Auto-reply suppressed —
should_send_autoreply()returns early for spam (never vacation-reply to spam). - Push notifications suppressed — no push is enqueued for a spam message.
Deferral & "processing failed"
RETRY outcomes (rspamd greylist/soft reject, rspamd/webhook errors, or a
non-2xx blocking webhook) hold the message and re-try it on the inbound queue
(roughly every 5 minutes). If a message is still failing after
MESSAGES_INBOUND_DEFERRAL_MAX_AGE (default 48 h), it is force-delivered
with is_spam = False, auto-reply skipped, and stamped
postmark["processing"] = "fail" — which surfaces the "delivered without our
usual safety checks" banner (below). This guarantees mail is never lost to a
persistently failing rspamd/webhook dependency, at the cost of one un-checked
delivery.
User-facing warnings in the reading UI
The reading view shows up to four banners, derived from the message's
stmsg_headers (a serializer projection of the internal Message.postmark
JSONField; legacy X-StMsg-* MIME headers are also merged for old messages).
Naming note: "postmark" here is an internal per-delivery pipeline record (
Message.postmark), not the Postmark email SaaS.
| Banner | Severity | stmsg_headers trigger |
Text |
|---|---|---|---|
| Forged sender | error | sender-auth == "fail" |
"This message failed sender authentication and is likely a forgery. Do not trust it." |
| Unverified sender | warning | sender-auth == "none" |
"This contact's identity could not be verified. Proceed with caution." |
| Processing failed | error | processing-failed truthy |
"This message was delivered without our usual safety checks. Please review it with caution." |
| Suspected spam | warning | spam == "likely" / "possible" |
"This message is likely spam…" / "This message may be spam. Review it with caution." |
The sender chip also shows an icon + tooltip: a warning icon for unverified
and a gpp_bad icon for forged senders.
How each warning maps to the pipeline
| Banner | Source signal | Produced by |
|---|---|---|
| Forged / Unverified sender | postmark["auth"] = fail / none |
Inbound-auth step (DKIM/DMARC) — for mode rspamd, from the symbol table above |
| Suspected spam | postmark["spam"] = possible / likely |
rspamd action add header → possible, rewrite subject → likely |
| Processing failed | postmark["processing"] = fail |
Deferral-window expiry force-delivery (any persistently failing step) |
Note the two-tier design: rspamd's mid-confidence actions (add header,
rewrite subject) produce an inbox banner, while its high-confidence actions
(quarantine, reject) set is_spam and route to Junk with no banner.
User "report as spam" action
Users with edit rights can flag a message/thread as spam (or un-flag it) via the
flag API (spam flag). This sets the is_spam boolean (cascading to draft
children) and recomputes thread stats, moving the thread in/out of the Junk view.
Gap: this action is purely local foldering — it does not send any feedback to rspamd (no
learnspam/learnham/fuzzytraining call exists in the backend). Wiring the report-as-spam / not-spam actions into rspamd's learn endpoints is tracked as a future enhancement (issue #509).
Not yet implemented / future work
- rspamd learn feedback from the report-as-spam button (#509).
- Per-mailbox spam configuration (currently global → maildomain only).
- SPF as a standalone user-visible verdict (today it only feeds rspamd scoring; the surfaced auth verdict is DKIM + DMARC).
Implementation map
| Area | File |
|---|---|
| Pipeline assembly, rspamd action mapping, deferral constants | core/mda/inbound_pipeline.py |
rspamd /checkv2 client, hardcoded header rules |
core/mda/spam.py |
| DKIM/DMARC verdict, rspamd symbol table, AR parsing | core/mda/inbound_auth.py |
| Task wrapper, internal/selfcheck short-circuit, force-delivery | core/mda/inbound_tasks.py |
Webhook dispatch, phases, is_spam override, result cache |
core/mda/dispatch_webhooks.py |
SPAM_CONFIG resolution (get_spam_config) |
core/models.py (MailDomain) |
is_spam flag, postmark field, stmsg_headers projection |
core/models.py (Message/Thread) |
| Spam / Junk view filtering | core/api/viewsets/thread.py |
| Report-as-spam flag action | core/api/viewsets/flag.py |
| UI warning banners | src/frontend/.../thread-message/thread-message-header.tsx |
| rspamd engine + proxy container | src/mpa/ |