Tuning Rspamd Action Thresholds Without Quarantining Real Mail
Rspamd's default reject at 15 and add_header at 6 are placeholders, not policy. Here is how to tune action thresholds from your own score distribution — cutting spam hard at the soft actions while keeping reject boringly conservative, so a Stripe receipt never lands in a bounce log.
EvilMail TeamJuly 19, 202612 min read
The default thresholds are a placeholder, not a policy
Here is the failure mode behind every one of these tickets. A customer forwards a phishing mail that sailed into their inbox, someone on the team gets annoyed, and they drop reject from 15 to 8 in actions.conf to "tighten things up." Two days later a Stripe receipt, a password-reset link, and a double-opt-in confirmation are sitting in the MTA bounce log with a 550 5.7.1 next to them. The sender got an SMTP rejection, the customer never got their mail, and now you are explaining to support why a signup confirmation vanished into the void.
That is what happens when you treat a score threshold as a knob you turn by feel. Rspamd assigns every message a floating-point score, sums the symbols it fired, and compares the total to a ladder of actions: greylist, add_header, rewrite_subject
Tuning Rspamd Score Thresholds Without False Positives | evilmail.pro — EvilMail Blog
,
reject
, and the implicit
no_action
below the lowest bar. The stock ladder is
greylist = 4
,
add_header = 6
,
reject = 15
. Those are conservative placeholders upstream ships so a fresh install does not immediately eat someone's mail. They are not tuned to your traffic, because they cannot be — rspamd has never seen your traffic.
The whole approach comes down to one asymmetry: tune the soft actions aggressively, keep `reject` boringly conservative, and let Bayes and fuzzy do the actual discrimination. A message crossing add_header gets filed to Junk, which is reversible and appealable. A message crossing reject is gone. On a shared platform where a false positive is a support ticket and a false negative is, at worst, spam in a Junk folder, those two outcomes are not remotely symmetric. Treat them that way.
Read your own score distribution before touching a number
You cannot pick a threshold without knowing the shape of your traffic, and almost nobody who posts "better" numbers on a forum ever shows you theirs. The first, non-negotiable step is to pull real scores out of your own history and see where legitimate mail and spam actually land.
The web UI at 127.0.0.1:11334 gives you a live history table, but for a distribution you want the log. Rspamd writes one result line per scanned message as (action): [score/required], so pull the integer part of the score straight out of it:
bash
# Coarse histogram of message scores across all scanned mail
grep -oP '\): \[\K-?\d+' /var/log/rspamd/rspamd.log \
| sort -n | uniq -c
That gives you the whole-traffic distribution in integer bins. To separate the two populations that matter, scan your known-ham and known-spam corpora on their own (the next section does exactly that with rspamc) and bin each. What you are building toward is two histograms on one axis: the score distribution of mail you know is ham, and the distribution of mail you know is spam. The picture that decides everything is the overlap zone — the band of scores where legitimate and junk mail collide. The width and position of that overlap, not any magic number off a blog, is what tells you where reject can safely sit.
Read that picture honestly. If your ham peaks near 0 and your spam peaks near 12 with a narrow overlap around 6 to 9, you are in great shape — put add_header at the ham-side floor of the overlap and leave reject far to the right. If instead ham and spam overlap heavily, smeared across the same 4-to-10 band, you do not have a threshold problem, you have a signal problem. No choice of number separates two distributions that sit on top of each other. Fix the signal first.
Fix the signal: the modules that make scores trustworthy
A score is only meaningful if the symbols producing it actually fire and actually discriminate. Before you move a single threshold, scan a known-spam and a known-ham message and confirm the discriminating symbols show up:
You want to see BAYES_SPAM on junk and BAYES_HAM on good mail, R_DKIM_ALLOW / R_SPF_ALLOW / DMARC_POLICY_ALLOW pulling legitimate authenticated mail *down* into negative territory, and FUZZY_DENIED catching known campaigns. If Bayes fires BAYES_UNKNOWN on everything, it is untrained and contributing nothing.
Bayes is the single biggest lever you have, and it does nothing until it has learned. The classifier needs a minimum corpus before it emits a confident verdict — set min_learns to around 200 per class. Back it with Redis and turn on autolearn in /etc/rspamd/local.d/classifier-bayes.conf:
ini
servers = "127.0.0.1:6379";
autolearn = true;
min_learns = 200;
# multi-tenant: one user's newsletter is another's spam
users_enabled = true;
That users_enabled line matters enormously on a shared platform. Per-user Bayes means a customer who marks a marketing blast as spam trains *their* filter, not everyone else's. Without it, one aggressive user poisons the global model for the whole server.
Seed the classifier manually from mail you have already sorted — do not wait for autolearn to get there on its own:
bash
# Batch-train from sorted Maildirs
find /var/mail/vhosts/evilmail.pro/*/Maildir/.Junk/cur -type f \
| xargs -r -n1 -P4 rspamc learn_spam
find /var/mail/vhosts/evilmail.pro/*/Maildir/cur -type f \
| xargs -r -n1 -P4 rspamc learn_ham
Fuzzy is the second half of the signal. Rspamd ships with fuzzy.rspamd.com enabled, which catches bulk campaigns other operators have already hashed. On a platform that gets hit with the same throwaway campaigns repeatedly, stand up a private fuzzy_storage too so you can hash-block a wave the moment you see it. Bayes and fuzzy together are what let you push the soft actions aggressively without collateral damage — they cut on *content the filter has actually seen*, not on a blunt global sum.
Set the four actions with intent
Now, and only now, edit /etc/rspamd/local.d/actions.conf. Never edit files directly under /etc/rspamd/ — local.d/ merges with the shipped config, override.d/ replaces it.
ini
actions {
greylist = 4; # or omit entirely on a transactional platform
add_header = 6; # the aggressive line — files to Junk
# rewrite_subject = 8; # leave off; it breaks threading
reject = 15; # only obvious garbage crosses this
}
The reasoning, action by action:
`greylist` adds real delivery latency — a temp-fail plus a retry delay measured in minutes per class-C tuple. On a temp-email or transactional platform where users are staring at a signup screen waiting for a code, greylisting costs you more in abandonment than it saves in spam. Lean on Bayes, fuzzy, and reputation instead and consider disabling it.
`add_header` is where the money is. Everything above this gets an X-Spam / X-Spamd-Result header, and a Sieve rule files it into Junk. This is aggressive by design and completely reversible — the user sees it, can appeal it, and you can retrain from it.
`rewrite_subject` prepends [SPAM] to the subject. It annoys users, breaks mail-client threading, and does nothing a header plus Sieve does not do more cleanly. Skip it.
`reject` is a hard SMTP 5xx. Keep it high. A useful rule of thumb: `reject` should be at least twice `add_header`. With add_header at 6 and reject at 15, a legitimate message would have to accumulate a wildly improbable score to bounce, while the everyday cutting all happens at the reversible line.
Wire the Junk filing in Sieve so the header actually does something:
Never let one symbol reject: caps, groups, and force_actions
The last-mile false-positive killers are about making sure no single misfire can push a good message over reject. Three tools do this.
Group caps. A message that trips several rules in the same category should not let that category run away with the score. Cap it in /etc/rspamd/local.d/groups.conf:
ini
group "statistics" {
max_score = 12;
}
Settings for authenticated mail. Outbound and relayed mail from your own submission users should never be rejected on content score — they authenticated, you know who they are. In /etc/rspamd/local.d/settings.conf:
ini
authenticated {
priority = 10;
authenticated = true;
apply {
actions {
reject = null; # never bounce our own users' mail
add_header = 12; # much looser
}
symbols_disabled = ["BAYES_SPAM", "FUZZY_DENIED"];
}
}
force_actions. The multimap module can override the score outright — hard-allow a transactional From/DKIM domain regardless of what the symbols say, or hard-reject on a private fuzzy hash regardless of score. This is your escape hatch for "this specific sender must always get through" and "this exact campaign must always die."
Close the loop: measure false positives, don't guess
Tuning is not a one-time edit, it is an operational loop. Sample the Junk folders weekly. Any legitimate mail you find there is a false positive that has to be fed back:
bash
rspamc learn_ham < rescued-from-junk.eml
Watch the reject log specifically for domains you recognize, and alert on *any* reject of mail that passed DMARC alignment — that is the single most dangerous class of false positive, because it means you bounced a properly authenticated sender. Your target is asymmetric on purpose: a false-positive rate on transactional mail of effectively zero, while some spam surviving into a Junk folder is tolerable because it is a soft, appealable failure.
Checklist: a safe tuning pass
1.Pull a ham-vs-spam score histogram from /var/log/rspamd/rspamd.log; find the overlap band.
2.Confirm Bayes is trained past min_learns and fuzzy is live — scan a known spam and check the symbols fire.
3.If ham and spam overlap heavily, stop and fix the signal (Bayes, DKIM/DMARC/SPF, fuzzy). Do not touch thresholds yet.
4.Set reject high — 12 to 15, at least 2x add_header.
5.Set add_header at the ham-side floor of the overlap (often ~6).
6.Wire the Sieve rule that files X-Spam into Junk.
7.Whitelist authenticated submission users in settings.conf (reject = null).
8.Cap runaway symbol groups with max_score; add force_actions for must-pass senders.
9.rspamadm configtest && rspamadm control reload, then ship.
10.Re-measure in 7 days: sample Junk, feed back false positives, alert on any reject of DMARC-aligned mail. Only then tighten add_header further.
The discipline is the whole point. Thresholds copied off a forum are someone else's traffic; the numbers that keep a customer's invoice out of Junk come from your own histogram and a Bayes model that has actually seen your mail.