Files
lasuite-messages/docs/spam.md
T
cf0b70e7ee (inbound) ARC relay-trust: inbound_auth "arc" + arc_verdict rule (#763)
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>
2026-07-24 01:20:12 +02:00

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:

  1. Before-spam webhooks (message.inbound) — user/integration webhooks that may drop, defer, or pre-decide the spam verdict.
  2. ARC — holds (RETRY) a message whose trusted ARC seal couldn't be verified because of a DNS failure (only when trusted_arc_sealers is set). See ARC relay-trust.
  3. Hardcoded rules — deterministic header_match rules and arc_verdict relay-trust rules from config.
  4. rspamd — native /checkv2 scan.
  5. Inbound authentication — DKIM/DMARC verdict (SPF indirectly).
  6. After-spam webhooks (message.delivering, then message.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 (rspamd quarantine/reject, a matching spam rule, 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 (Frommail_from, Rcptrcpt_to, IPip, Helohelo, Hostnamehostname) 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 = TrueJunk (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_CONFIG Django setting (env var SPAM_CONFIG, a JSON/dict value, default {}).

  • Per mail domain — a MailDomain may override any subset of keys via MailDomain.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).
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 inside SPAM_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 signing d= domain and the From: domain. Never returns fail (no DMARC policy lookup); worst case is none.
  • rspamd — read DKIM/DMARC symbols from the rspamd /checkv2 result (reusing the spam-step scan). Verdict precedence: fail > pass > none.
  • arc — read dkim=/dmarc= from the ARC-Authentication-Results that a trusted sealer cryptographically sealed (RFC 8617). Plaintext headers are never read; an unsealed or untrusted-sealed message is none. See ARC relay-trust.
  • authentication-results — parse dkim=/dmarc= from the Authentication-Results header(s) added by trusted upstream relays (bounded by trusted_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 — the d= 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 is cv=pass and 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 untrusted regardless 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 untrusteddrop/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_verdict rules skip them — an untrusteddrop rule never discards first-party form traffic. (rspamd and header_match rules still apply.) If no widget channel exists, no widget-origin mail exists in the first place.

Fail closed: an empty trusted_arc_sealers trusts nothing — with inbound_auth: "arc" every message is then none (unverified), and an arc_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-Results is 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 show dkim=fail for legitimately forwarded mail), collapsing to none rather 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:

  1. Trust the gateway's position. Set trusted_relays to the number of hops the gateway adds, so its headers fall inside the trusted window and forged copies from further upstream are ignored.

  2. 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.)

  3. 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 sets is_spam pre-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 final is_spam value 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's is_spam follows its first message, and spam messages are excluded from "active" counters/timestamps. is_spam is indexed for search and filtering. It is independent of is_trashed.
  • Auto-reply suppressedshould_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/fuzzy training 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/