It is 4am and the signup queue is on fire. A few hundred registrations in twenty minutes, all from freshly-minted .top domains, all rotating through a single hosting /24, all carrying a near-identical subject line: "Confirm your account now". You check the usual suspects — Spamhaus, SORBS, the SpamRATS lists — and every one comes back clean. The domains are three hours old. The /24 has never sent a byte of email before tonight. Public reputation systems work on aggregate signal accumulated over time, and time is exactly what you don't have.
This is the gap a stock rspamd install cannot close on its own. The multimap module ships as an empty scaffold — the public RBLs are wired up and active, but the local lists are yours to write. The moment you run real infrastructure — temp-email, outbound relays, high-volume signup flows like we do at evilmail.pro — you hit abuse patterns no public list has caught up to yet. The fix is your own rules, and all of it hangs off one decision: is this a local list I own and edit directly, or a list I want to distribute over DNS across many nodes? Answer that and the module choice, map type, scoring, and reload story all follow.
multimap vs rbl: which module, and why it matters
multimap matches a message property directly against a list you control — a flat file, an HTTP endpoint, Redis, or a CDB. Its killer feature is latency of *change*: edit the map file, and rspamd picks it up on the next mtime poll, sub-second to a minute later, with no restart. When you spot that /24 at 4am, you append one line and the next message is scored against it. That is the difference between catching a pattern in 90 seconds and waiting for a third party to notice.
rbl works the other way around. You don't hand rspamd a list; you hand it a DNS zone to query. rspamd builds a lookup, fires it at your nameserver, and turns the answer into a symbol. This is what you want when the list must be shared across many MXes, when it's too large for a flat file, or when an out-of-band abuse pipeline is inserting and expiring records on its own schedule. One authoritative zone, every node queries it, TTL controls propagation.
Both modules configure under /etc/rspamd/local.d/ — multimap.conf and rbl.conf. You never edit files under /etc/rspamd/ proper; those get overwritten on upgrade. local.d snippets are merged into the shipped config, which is the whole point of the override directory.
One concept trips up nearly everyone: a map has both a match type (what message property it looks at) and a value type (how the data is stored and compared). Match types include ip, from, rcpt, header, hostname, rdns, helo, asn, country, received, url, dkim, and selector. Value types are radix (IPv4/IPv6 plus CIDR), hash (an exact-string set), regexp (PCRE), glob, and cdb
Deny (and allow) by sender domain and IP
Here is a working multimap.conf covering the three matches you reach for first — a sender-domain blocklist, an IP/CIDR blocklist, and a domain allowlist:
LOCAL_BL_FROM {
type = "from";
filter = "email:domain";
map = "${LOCAL_CONFDIR}/local.d/maps.d/from_bl.map";
symbol = "LOCAL_BL_FROM";
score = 7.0;
description = "Sender domain on local blocklist";
}
LOCAL_BL_IP {
type = "ip";
map = "${LOCAL_CONFDIR}/local.d/maps.d/ip_bl.map"; # radix, CIDR one per line
symbol = "LOCAL_BL_IP";
score = 6.0;
}
LOCAL_WL_FROM {
type = "from";
filter = "email:domain";
map = "${LOCAL_CONFDIR}/local.d/maps.d/from_wl.map";
symbol = "LOCAL_WL_FROM";
score = -8.0;
}The filter = "email:domain" line is load-bearing. Without it, a from map matches the full address ([email protected]), so you'd have to list every mailbox. With it, you match on the domain and one line covers the whole sender. The map files themselves are dead simple — one entry per line, # for comments, and an optional per-line score override that beats the stanza default:
# maps.d/from_bl.map
spammer.top
throwaway-mail.xyz
phishy.example 9.0 # this one is worse, bump it
# maps.d/ip_bl.map (radix)
203.0.113.0/24
198.51.100.23
2001:db8:dead::/48For the allowlist, resist the urge to get clever with rule ordering — rspamd does not evaluate maps top-to-bottom the way a firewall does. Give the allowlist symbol a strong negative score (-8.0 above) so that even if a legit sender also trips a couple of weak spam symbols, the metric stays well below any action threshold. If you need a genuine hard-pass that short-circuits scanning entirely, set prefilter = true with action = "accept" on the stanza — but reserve that for senders you trust unconditionally, because it skips the rest of the pipeline.
Header and subject matching with regexp maps
The 4am subject-line pattern is a job for a header map with regexp = true:
SUSP_SUBJECT {
type = "header";
header = "Subject";
regexp = true;
map = "${LOCAL_CONFDIR}/local.d/maps.d/subject_re.map";
symbol = "SUSP_SUBJECT";
score = 4.0;
one_shot = true;
}Leave off regexp = true and the map does exact string matching, which is almost never what you want for headers. The map file holds bare PCRE patterns, one per line:
# maps.d/subject_re.map
/^\s*(?i)verify your account/
/(?i)confirm your (account|identity) now/
/[а-яА-Я].*(?i)paypal/ # Cyrillic lookalikes near a brand
/^[A-Za-z0-9+\/]{40,}={0,2}$/ # bare base64-ish gibberish subjectTwo things keep this fast and safe. First, one_shot = true stops the symbol from firing — and adding its score — more than once per message. Second, anchor your patterns. rspamd compiles regexps with Hyperscan where it can, which does not backtrack; but a pattern Hyperscan can't handle falls back to PCRE, where something like (.*)* can be driven into pathological blowup by a crafted message. Anchor with ^/$, bound your quantifiers, and if you're really just looking for a fixed substring, use a glob value type instead of a regexp.
For sender-vs-header mismatches — a classic phishing tell — reach for selectors rather than a header map. Comparing selector = "from('mime').domain" against from('smtp').domain lets you score the divergence between the display From and the envelope From. type = "received" similarly lets you match a specific relay hostname while walking the Received chain.
Standing up your own RBL and RHSBL
When the list needs to live on more than one machine, move it into DNS. The mechanics differ between an IP-based blocklist (DNSBL) and a domain-based one (RHSBL), and that difference is the thing people get wrong most often.
A DNSBL reverses the octets of the IP and prepends the zone. To check 198.51.100.23 against bl.internal.evilmail.pro, rspamd queries the A record for 23.100.51.198.bl.internal.evilmail.pro. An answer of 127.0.0.x means listed; NXDOMAIN means not listed. An RHSBL does no reversal at all — the domain is queried as-is: spammer.top.dbl.internal.evilmail.pro. Get that reversal logic backwards and your zone will answer nothing.
The rbl.conf stanza wires both together and, crucially, maps distinct return codes to distinct symbols so your abuse pipeline can encode *why* something is listed:
rbls {
EVILMAIL_BL {
rbl = "bl.internal.evilmail.pro";
from = true; # connecting / From IP
received = true; # walk the Received chain
ipv4 = true;
ipv6 = true;
returncodes {
EVILMAIL_BL_ABUSE = "127.0.0.2";
EVILMAIL_BL_SIGNUP = "127.0.0.3";
EVILMAIL_BL_PROXY = "127.0.0.4";
}
}
EVILMAIL_RHSBL {
rbl = "dbl.internal.evilmail.pro";
emails = true; # sender email domains
urls = true; # domains found in the body
is_whitelist = false;
}
}Operationally, the zone belongs on infrastructure you already run. At evilmail.pro it lives on the existing PowerDNS stack (NS storm/void/kraken/pandora.example.net); the abuse pipeline INSERTs and DELETEs records as patterns come and go, and the record TTL governs how fast every MX sees a new listing. Keep that TTL short — 300 seconds is reasonable — so a same-night listing actually propagates same-night.
Scoring, actions, and not nuking legit mail
Symbol scores sum into the metric, and the metric crosses action thresholds defined in local.d/actions.conf. The bundled defaults are roughly: greylist at 4, add-header at 6, reject at 15. The temptation when you're angry at 4am is to slap score = 15 on your new symbol so it rejects on its own. Don't. A single high-score symbol is a false-positive machine — the day it misfires on a real customer, you bounce their mail with zero corroboration.
Design custom symbols to *contribute*. A +6 on LOCAL_BL_IP rejects nothing by itself, but combined with an existing BAYES_SPAM and an SPF_FAIL it sails past 15 naturally — and the message is only rejected when three independent signals agree. That is far safer, and when you review history you can see exactly which signals stacked. Use one_shot = true on any symbol that could otherwise fire repeatedly on one message and inflate the score.
Map hygiene: signing, hot-reload, and safe edits
Local map files hot-reload on mtime change; rspamd polls them on an interval governed by min_watch_interval (sub-second up to about a minute). HTTP(S) maps refresh according to their Cache-Control/TTL. You never restart rspamd to change map *content* — only structural .conf edits need a systemctl reload rspamd.
Two disciplines keep this from biting you. First, sign remote maps. Generate a keypair with rspamadm signtool -g, sign the map, and reference it as map = "sign+https://maps.internal/from_bl.map"; sign_key = "...";. Now a compromised map host cannot inject arbitrary rules into your scanner. Second, keep maps in git. A single bad regexp can wedge scanning across every message, and when it does you want git revert and a clean diff, not archaeology. Keep everything under local.d/maps.d/*.map.
Testing and debugging before prod
Never push a rule blind. The loop is:
rspamadm configtest # validate syntax before touching prod
rspamc < sample.eml # scan a real message, see the score + fired symbols
rspamc -v < sample.eml # verbose per-symbol breakdown
systemctl reload rspamd # only for structural .conf changes
rspamadm control reload # force a map/controller reload
tail -f /var/log/rspamd/rspamd.logrspamc < sample.eml is the fast feedback: it prints the total score and every symbol that fired, so you can confirm LOCAL_BL_IP actually triggered on your test message. Watch rspamd.log after a reload for the failure modes that bite hardest — cannot open map, regexp compile errors, and radix parse failures (what you get when a malformed CIDR line sneaks into an IP map). The Web UI on 127.0.0.1:11334 gives you History with a per-message symbol breakdown, the fastest way to answer "why did this message score the way it did".
Ship-it checklist
- All rules in
local.d/only — never edit files under/etc/rspamd/proper. - Map value type matches the data:
radixfor IP/CIDR,hashfor exact strings,regexpfor headers. A mismatch fails silently. filter = "email:domain"onfrommaps unless you really mean the full address.regexp = trueon header maps; patterns anchored, quantifiers bounded.rspamadm configtestpasses before any reload.- A real sample scored with
rspamcand the expected symbol confirmed fired.
Get the local/DNS decision right at the top and none of this is hard. The payoff is measured in minutes: a pattern you can see is a pattern you can block, without waiting for the rest of the internet to agree with you.


