Per-domain and per-mailbox spam settings
Purpose. One domain’s mail is not another’s. A newsletter address that must never lose a message and a support alias drowning in bulk mail cannot share one threshold, and cPanel administrators expect to set both — and to keep a sender list — without touching the server-wide policy. This is the three-level version of the spam thresholds that already exist under Settings.
Behaviour.
- Three levels, and the specific one wins. Server-wide (
ServerConfig.spam, Settings) → domain (Domains → a domain → Spam) → mailbox (Domains → a domain → a mailbox → Limits → Spam). Each level sets, independently, the score at which Rspamd rejects, marks as spam (add_header) and greylists. - Blank means inherit, and that is not the same as typing the same number. A blank field follows the level above it as that level changes; a copied number does not. The UI therefore shows an inherited value as a placeholder and a hint — “Inherited: 15 (server default)” — never as a filled-in field, because a filled field is what the next save would write back and it would silently detach the scope.
- The API returns what will actually happen.
GET /domains/{id}/spamandGET /mailboxes/{id}/spamreturnthresholds(what this scope stores, withnullfor inherit),effective(the resolved numbers) andeffective.inheritedFrom, which namesself,domainorserverper field. A three-level inheritance UI that only renders the level you are looking at is guesswork; this is what stops it being guesswork. - Allow and block lists at both levels. An entry is an address (
news@shop.test) or a bare domain (shop.test, matching any sender at it), with an optional note. Senders are lower-cased and validated on the way in — never a regex, because the string is written into a file a daemon parses. A block beats an allow at the same level, and a mailbox entry beats its domain’s. - Every change is applied like any other configuration change. A save calls
ConfigService.reapply()with aspam:message, so it is rendered, validated inside the Rspamd container byrspamadm configtest, rolled back if rejected, and recorded as aserver_configversion with the rest of the timeline. It is audited too. - Scoring is per recipient, so this governs inbound only. Outbound limits live in
ServerConfig.limits(Outbound rate limits) and compromised-mailbox containment in Postfix — a block entry here does not stop a mailbox sending.
Configuration. None. Two tables (spam_settings, spam_list_entry), each row scoped to exactly one domain or one mailbox by a CHECK constraint, and both cascade with the entity they belong to.
How it reaches Rspamd. As an apply-time render extra, not through ServerConfig — a tenant’s spam policy is state, and putting it in the configuration would copy every domain’s settings into every stored render and let a configuration rollback silently revert an unrelated domain’s policy. SpamSettingsService.renderExtras() generates local.d/settings.conf whole from the two tables on every apply, one block per configured scope, priority encoding specificity:
| Scope | priority |
|---|---|
| domain thresholds | 10 |
| mailbox thresholds | 20 |
| domain allow / block | 30 / 40 |
| mailbox allow / block | 50 / 60 |
Rspamd applies the single highest-priority matching block. Allow and block are written as action thresholds rather than as a separate multimap — 9999 for allow, 0 for block — so all four levels resolve through one mechanism with one ordering rule.
Limits & edge cases.
- A settings block’s
actionsmerge with the server-wide ones, but not with a lower-priority block. So a domain that sets onlyrejectkeeps the server’sadd_headerfor free, while a mailbox that sets onlyrejectwould fall back to the server, skipping its own domain. The control plane resolves that one hop itself and writes the inherited value into the mailbox block. Measured againstrspamd/rspamd:latestrather than assumed. - A scope that sets nothing emits no block, including a mailbox whose domain sets everything — its domain’s block already matches that recipient. Otherwise the file would grow with every mailbox anyone had ever opened the screen for.
local.d/settings.confmust not wrap its blocks insettings { … }. Rspamd mergeslocal.d/<section>.confinto the section its filename names, so a wrapper yieldssettings { settings { … } }— whichrspamadm configtestcallssyntax OKwhile the daemon applies none of it. The generated header says so and a test asserts it.- The file is rewritten whole on each apply; there is no incremental path, and being reconstructible from the database is what makes it recoverable.
- Greylisting is a delay, not a rejection: a legitimate sender retries and gets through, typically within minutes. Setting it very low is the cheapest defence available and the most likely to be blamed for “slow mail”.
Verifying it. From the Rspamd container, scan a message as a given sender and recipient and read the threshold back:
docker exec mailserver-rspamd-1 sh -c ' printf "From: s@external.test\nTo: bob@example.test\nSubject: t\n\nx\n" > /tmp/m.eml rspamc -h localhost:11334 --from s@external.test --rcpt bob@example.test symbols /tmp/m.eml'The Score: 6.50 / 12.00 line names the reject threshold actually in force for that recipient, which is the fastest way to tell an inheritance mistake from a scoring one.