03:14 — a SASL login lands from an ASN this mailbox has never touched, two continents from where the user actually lives. 03:20 — the account has pushed 4,000 messages through port 587, every one authenticated cleanly, every one addressed to disposable and dictionary-generated domains. 06:00 — your sending IP is on the CBL, Gmail is deferring everything with a 421, and your support queue is filling with legitimate customers asking why their mail is stuck. Your inbound rspamd saw none of it, because authenticated submission is trusted by design. That gap is what this article closes.
Outbound spam from a compromised mailbox is not a content-filtering problem. Your Bayes classifier, your fuzzy hashes, your RBL lookups — all of that runs on the inbound smtpd and never touches a message a valid user submits. It is a detection problem, and you solve it by putting rspamd on the submission path with three signal families and a per-user volume baseline built from your own maillog.
The blind spot: authenticated submission skips the spam engine
Almost every Postfix + rspamd deploy in the wild milters only the inbound service. The submission (587) and smtps (465) services authenticate the sender, decide they are one of yours, and hand the message straight to the queue. This is deliberate and, in isolation, correct — you do not want to bounce your own paying customer because their newsletter tripped BAYES_SPAM. But the moment credentials leak — reused password, phishing, an infostealer on the user's laptop — that trust becomes a liability, because your IP and domain reputation are shared across every mailbox on the platform. One compromised account is not one account's problem. It is a blast radius over everyone you send for.
Put rspamd on the submission path without breaking legit senders
Point the submission and smtps services at rspamd's proxy worker milter, and tag the daemon name so rspamd knows this traffic came from an authenticated user. In master.cf:
submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_sasl_auth_enable=yes
-o smtpd_tls_security_level=encrypt
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
-o smtpd_milters=inet:localhost:11332
-o milter_macro_daemon_name=ORIGINATINGMirror the same -o smtpd_milters line onto the smtps (465) service. The {auth_authen} macro carries the SASL username into rspamd automatically over the milter protocol — that username is the key to everything that follows.
Now tell rspamd that authenticated mail is a distinct policy. The instinct to reject an authenticated spammer outright is wrong: a single false positive means a real customer's message is gone. Raise the reject threshold out of reach and lean on add header, soft reject, and quarantine, so a legitimate burst is delayed, not lost. In /etc/rspamd/local.d/settings.conf:
settings {
authenticated {
authenticated = true;
apply {
actions {
"reject" = 999.0;
"add header" = 8.0;
"soft reject" = 12.0;
}
}
}
}Overriding only the action thresholds keeps every symbol — SPF, DKIM, Bayes, fuzzy, reputation — scoring exactly as before; you are changing the consequences, not the scan. Soft reject is the sweet spot for a compromised account: the sending client gets a 451 4.7.1 try again later, so a burst clips flat instead of flooding out, and you buy minutes to react without permanently bouncing anyone.
The three signals that actually flag a compromise
No single metric is trustworthy on outbound. A transactional account legitimately blasts 500 messages an hour; a lawyer legitimately emails 40 strangers a day. You want 2 of 3 signal families to fire before you act.
- Rate-limit breaches keyed on the authenticated user. The volume ceiling, covered below in depth. This is the highest-leverage control.
- Content-score inversion. A mailbox whose 30-day history is pure ham suddenly lighting up
BAYES_SPAM,FUZZY_DENIED,MIME_BAD,MANY_INVISIBLE_PARTS,FROM_NEQ_ENVFROM, orR_SUSPICIOUS_URL. Per-user Bayes and rspamd's reputation module make the inversion sharp — the delta from the user's own baseline matters more than the absolute score. - Recipient-pattern anomalies. RCPT fan-out, distinct-domain count spiking past 30/hour, dictionary-style invalid recipients producing a double-bounce storm, and daytime users suddenly sending at 03:00.
Wire the composite as an rspamd rule that requires at least two families. A force_actions clause then quarantines only on the combined condition, which keeps false positives near zero.
Rate limiting by authenticated user, not by IP
This is the one control most providers get wrong. IP-keyed limits are useless here: your submission service sits behind one or a handful of IPs, users share carrier-grade NAT, and a corporate customer's whole office egresses from a single address. Key on the SASL username. In /etc/rspamd/local.d/ratelimit.conf:
rates {
authuser {
selector = 'user';
bucket = [
{ burst = 40; rate = "100 / 1h"; },
{ burst = 20; rate = "600 / 1d"; }
];
}
}
whitelisted_rcpts = "postmaster,mailer-daemon";These are token buckets: burst is the instantaneous allowance, rate is the sustained refill. A user can fire 40 messages back-to-back, then settles to 100/hour with a hard 600/day ceiling. Legit bulk and transactional accounts get their own selector with a higher bucket — whitelist by username, never by IP. When the bucket empties, rspamd emits RATELIMIT and the authenticated settings policy turns that into a soft reject. The attacker's 4,000-message run clips at the bucket ceiling instead of escaping.
Turning your maillog into a per-user volume baseline
The ratelimit is your guardrail. It will not catch the *slow burn* — the patient attacker who sends 45 messages an hour, safely under the 100 ceiling, for eight hours straight. That is 360 spam messages from an account whose real median is 4/hour, and the static limit never fired once. The baseline catches it.
Parse Postfix maillog on sasl_username=, keep a rolling 7-day median per user, and alert when a user crosses max(50, 5 × their own median). Top authenticated senders in the last hour is a one-liner:
grep "$(date '+%b %e %H')" /var/log/mail.log \
| grep -oP 'sasl_username=\K[^,]+' \
| sort | uniq -c | sort -rn | headPersist the counters in Redis so the anomaly logic stays cheap: INCR outq:<user>:<yyyymmddHH> on each authenticated send, EXPIRE the key at 8 days, and a small responder job compares the current hour against the rolling median of the trailing week. The relative threshold is what makes this work — a 5× jump on a quiet mailbox trips at 20 messages, long before any absolute floor.
Wiring alerts and automatic quarantine
Detection is worthless if it stays inside rspamd. Emit it. A force_actions rule quarantines on the composite (rate breach plus content inversion), and the built-in Prometheus endpoint on the controller feeds Grafana. In /etc/rspamd/local.d/force_actions.conf:
rules {
compromise_quarantine {
action = "soft reject";
expression = "RATELIMIT & (BAYES_SPAM | FUZZY_DENIED | MANY_INVISIBLE_PARTS)";
require_action = ["soft reject", "reject"];
}
}Alert in Grafana on the rate of rspamd_actions_total{action="soft reject"} — a step change there is a live compromise almost every time. A lightweight responder cron queries rspamd history in Redis for repeat offenders and pages ops. And keep the external feedback loops wired: Google Postmaster Tools, Microsoft SNDS, and ARF/FBL parsing catch reputation damage that started before your internal signals did.
When an account trips, the runbook is fixed and fast:
# 1. kill submission for the account (Dovecot / your auth DB)
# set the mailbox inactive so SASL auth fails immediately
# 2. rotate the password, invalidate app-specific passwords
# 3. purge the queued spam by sender
mailq | grep -B1 'attacker@yourdomain' | grep '^[A-F0-9]' \
| awk '{print $1}' | tr -d '*!' | postsuper -d -
# 4. audit attacker persistence — the step everyone forgetsStep four is where breaches survive a password reset. Attackers add a ~/.dovecot.sieve forward rule, a hidden alias, or an OAuth app password so they keep receiving replies and can re-establish access. Check managesieve rules, forwarding aliases, and app-specific credentials before you call it closed.
Checklist: outbound spam containment
- Submission (587) and smtps (465) both milter to the rspamd proxy worker with
daemon_name=ORIGINATING. - An
authenticatedsettings policy withrejectraised to 999 — soft reject and quarantine, never bounce a first offense. - Ratelimit keyed on the
userselector with burst plus sustained buckets; bulk senders whitelisted by username, never by IP. - A per-user 7-day median baseline job running against
sasl_username=, alerting onmax(50, 5× median). force_actionsquarantine wired to the 2-of-3 composite.- Grafana alert on the
rspamd_actions_total{action="soft reject"}rate. - FBL/ARF, Google Postmaster, and Microsoft SNDS monitored for early reputation loss.
What good looks like: mean time to detect under five minutes and under a few hundred messages, caught by your own metrics — not by Gmail's 421 and a support queue that already knows before you do.


