mirror of
https://github.com/suitenumerique/messages.git
synced 2026-09-11 04:17:47 +02:00
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>
477 lines
25 KiB
Markdown
477 lines
25 KiB
Markdown
# 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](#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](./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_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:
|
|
|
|
```python
|
|
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](#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 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](#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](https://datatracker.ietf.org/doc/html/rfc8617) (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](#sender-authentication-dkim--dmarc).
|
|
|
|
**2. As a gating rule** — an `arc_verdict` rule acts on the verdict:
|
|
|
|
```jsonc
|
|
{ "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_verdict` rules skip
|
|
> them — an `untrusted` → `drop` 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:
|
|
|
|
```jsonc
|
|
{
|
|
"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.
|
|
|
|
```jsonc
|
|
{
|
|
// 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](#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.:
|
|
|
|
```json
|
|
{
|
|
"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](./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:
|
|
|
|
```json
|
|
{ "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](./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 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`/`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/` |
|