The failure mode: how one account becomes a listing
The pattern is always the same. A customer reuses a password that showed up in a credential-stuffing dump, an attacker authenticates against your submission service on port 587, and starts sending. Not one message, a flood. A single compromised SMTP-AUTH login can emit tens of thousands of messages in the ten or fifteen minutes it takes before a human notices the queue climbing. By the time you run postqueue -p and see the pileup, Spamhaus has already listed the sending IP on the SBL or CSS, Microsoft SNDS has flipped the IP to red, and Google Postmaster shows your domain reputation dropping from "high" to "bad" on a graph that takes weeks to climb back.
Here is the part that trips people up: your inbound spam filter does nothing about this. Rspamd scoring, RBL checks, Bayes, that entire pipeline is built to judge mail arriving *at* your users. This mail is leaving, and it's authenticated. It passed SASL. From the MTA's point of view it's a legitimate, logged-in user doing exactly what the protocol allows. Content scanning might catch some of it if the payload is obviously spammy, but a hijacked account blasting phishing links that score 3.0 sails straight through a filter tuned to reject at 15.
The control point is not the content. It's the *volume*, and it lives on the submission path, ports 587 and 465, where SASL AUTH happens. Rspamd's ratelimit module is the enforcement layer, and there are two mistakes that make almost every default deployment useless here:
- Keying on the envelope sender.
smtp_fromis attacker-controlled and free to rotate per message. Rate limiting on it is security theater. - Running in observe-only mode. The module ships wired to *count*, not to *reject*. It happily records that a user sent 9,000 messages and does nothing to stop the 9,001st.
Fix both and a hijacked account hits a wall at a few hundred messages instead of forty thousand. That's the difference between an incident report and a delisting request.
Where rspamd sits in the outbound path
In a Postfix + rspamd deployment, rspamd runs as rspamd_proxy in milter mode, hooked into Postfix via smtpd_milters. The critical detail is that submission traffic (587/465) and inbound traffic (25) both flow through the same rspamd, so your limits have to be *scoped*, otherwise you either throttle legitimate inbound delivery or you apply no limit at all to submission.
The elegant part is that the user selector does the scoping for you. The SASL login only exists after AUTH, which only happens on submission. A port-25 inbound message has no user, so any bucket keyed on user simply never fires for it. That makes user-keyed caps outbound-only by construction. The settings module still earns its keep for a related job: raising the spam-reject threshold specifically for authenticated mail, which is your second layer of outbound defense.
The ratelimit module: buckets, burst, and leak rate
Every rspamd rate limit is a leaky bucket, and the most common misconfiguration comes from misreading the syntax. A limit written "60 / 1h" does not mean "60 messages per hour, flat." It means the bucket holds a burst of 60 tokens that can be spent instantly, and it refills at a sustained rate of 60 per hour, roughly one token every 60 seconds. Internally rspamd normalizes the rate to messages-per-second (0.0167/s here). Each message spends one token. When the bucket is empty and hasn't refilled, the message is rate limited.
That distinction matters operationally. A legitimate user firing a burst of 40 messages when they hit "send" on a small newsletter is fine, they draw down the bucket and it refills. A compromised account trying to sustain hundreds per minute drains the bucket in seconds, then bounces off the empty ceiling on every subsequent message. Same limit, opposite outcomes, which is the whole point.
The module ships with built-in selectors — to, to_ip, to_ip_from, bounce_to, and the one that matters most here, user. Redis is mandatory; each bucket lives under a hashed key RL_<selector-hash> holding a floating-point counter and the last-update epoch, with a TTL derived from the burst and rate. When a bucket overflows, the module emits the RATELIMITED symbol as a pre-result, which short-circuits the pipeline into a reject before content scanning even finishes.
Config: outbound caps keyed on the authenticated user
Here is the meat. First, use the settings module to raise the spam-reject bar for authenticated traffic. This is your outbound content gate; the volume caps come next and self-scope on their own.
# /etc/rspamd/local.d/settings.conf
settings {
outbound {
priority = high;
authenticated = true;
apply {
actions {
reject = 12;
"add header" = 6;
}
}
}
}Then define the buckets, all keyed on user, never on smtp_from:
# /etc/rspamd/local.d/ratelimit.conf
rates {
user_hourly {
selector = 'user';
bucket = { burst = 60; rate = "60 / 1h"; }
}
user_daily {
selector = 'user';
bucket = { burst = 200; rate = "200 / 1d"; }
}
bounce {
selector = 'bounce_to';
bucket = { burst = 10; rate = "10 / 1h"; }
}
}
max_rcpt = 50;
whitelisted_rcpts = "postmaster,abuse";
info_symbol = "RATELIMIT_UPDATE";What each piece buys you: user_hourly and user_daily are the two-layer cap. A freshly hijacked account trips the 60/hour ceiling long before the daily limit is even relevant. By default the module counts each recipient against the bucket, so one message fanned out to 40 addresses draws 40 tokens; an account blasting large recipient lists drains the bucket far faster than its raw message count suggests, which is exactly the behavior you want. max_rcpt = 50 rejects fan-out bombs in a single envelope outright. bounce caps backscatter to null-sender addresses.
When a bucket overflows, the module sets a pre-result with the action soft reject. Postfix returns 451 4.7.1 and the client is told to retry later. That is the correct verdict for a rate limit: it's a temporary condition, so a legitimate sender who briefly overshoots backs off and succeeds a minute later, while a botnet hammering the ceiling gets nothing but 451s. Do not rewrite this into a permanent 550, a hard reject on a temporary limit makes well-behaved senders abandon mail they were entitled to send.
Containing a *confirmed* compromise is a separate action from the rate limit. When your alert fires, disable the account. Dovecot then rejects the SASL AUTH outright and the session dies. The rate limit buys you the minutes; killing the credential ends the incident.
Redis needs its own database and a sane eviction policy. If Redis evicts your buckets under memory pressure, your limits silently disappear:
# /etc/rspamd/local.d/redis.conf
servers = "127.0.0.1:6379";
db = "3";Set maxmemory-policy noeviction on that Redis instance. A rate-limit bucket that gets evicted is a rate limit that isn't there.
Per-user tiers without editing config on every signup
Hard-coding numbers works for a lab. A real provider has trial accounts, paid accounts, and a handful of high-volume bulk senders, and you cannot redeploy rspamd every time someone upgrades a plan. Drive the limits from data.
Point the bucket at a map keyed by SASL user, so the burst and rate are looked up per login:
# in ratelimit.conf
user_tiered {
selector = 'user';
bucket = "/etc/rspamd/local.d/maps.d/user_limits.map";
}# /etc/rspamd/local.d/maps.d/user_limits.map
# sasl-user burst rate
[email protected] 60 60 / 1h
[email protected] 2000 2000 / 1h
[email protected] 50 50 / 1dFor anything beyond a static file, use a Lua selector that resolves the tier from a Redis hash your app writes on plan change. The pattern a provider like evilmail.pro uses: the billing system runs HSET user_tier [email protected] paid when a plan activates, and a small Lua selector maps trial → 50, paid → 2000, bulk → 20000. Limits become data your application controls, not code your ops team redeploys.
Tie this to warmup. A brand-new sending domain or a fresh customer should not get the full ceiling on day one, that's how you burn a clean IP. Ramp it: day 1 at 50, day 3 at 200, week 2 at 1000, full tier once reputation stabilizes on Google Postmaster and SNDS. A cron job that nudges the tier map upward by account age does this without anyone touching config.
Observability: prove it fires before you trust it
A rate limit you haven't watched trip is a rate limit you don't actually have. Validate the config, then replay a message as the authenticated user and read the symbol:
rspamadm configtest
rspamc [email protected] \
[email protected] \
[email protected] \
symbols < msg.eml | grep -i ratelimitWhen a bucket trips, rspamd.log shows a line like:
ratelimit: bucket "user_daily" for [email protected] is overflowed,
key: RL_a1b2c3..., limit: 200 / 86400, action: soft rejectInspect the live buckets in Redis, and reset one when you unblock a real sender:
redis-cli -n 3 KEYS 'RL_*'
redis-cli -n 3 GET RL_a1b2c3d4 # counter + last-update epoch
redis-cli -n 3 DEL RL_a1b2c3d4 # reset this user's bucketThe rspamd web UI and Grafana (via the exporter) give you symbol counts over time. A spike in RATELIMITED from a single user is your compromise alarm. A false positive looks like a legitimate sender hitting user_hourly on a burst they were entitled to; the fix is raising *their* tier in the map, not disabling the limit globally.
Layered defense: what rate limiting does not cover
Rate limits cap volume, not content. A patient attacker sending 190 spam messages a day under a 200/day cap is still sending spam. Pair the caps with:
- The outbound spam gate from the settings profile above, so authenticated mail scoring past ~12 dies regardless of volume.
- Mandatory outbound DKIM signing, so your legitimate mail is attributable and your reputation is yours to protect.
- fail2ban on Dovecot/Postfix SASL auth failures, to catch the credential-stuffing *before* the successful login and force a password rotation on the hit account.
- Postfix per-destination egress shaping —
smtp_destination_rate_delayanddefault_destination_concurrency_limitsmooth bursts toward a single provider so you don't trip Gmail's own throttles. - A compromise alert that fires when any single user trips
RATELIMITEDmore than N times in five minutes. That's your early warning, and it beats reading the queue graph after the listing.
Operator checklist
- Redis on a dedicated DB with
maxmemory-policy noeviction, evicted buckets mean no limits. - Buckets keyed on the `user` selector, never
smtp_from, which is self-scoping to authenticated submission. - Settings module raises the outbound reject threshold on
authenticated = true. RATELIMITEDleft as a real 451 soft reject, not observe-only; a confirmed compromise is contained by disabling the account, not by forcing a 550.- Tiered map driven from your app DB or Redis, not hand-edited per signup.
max_rcptplus a bounce sublimit, with per-recipient counting doing the fan-out work.- Alert on repeated
Get these right and the worst case for a compromised mailbox drops from "we're on the SBL and Gmail is deferring everything" to "user X tripped their cap at 09:14, we killed the session and rotated the password by 09:16." That's the entire value proposition: the abuse throttles itself at the first burst, and your IP reputation never notices it happened.


