Compromised-mailbox containment
Purpose. One stolen mailbox password is the most likely way this server ends up on a blocklist. The outbound rate limits (Outbound rate limits) cap how fast a compromised account can send, but they cannot tell a busy mailbox from a hijacked one. This does: it watches whether a mailbox’s outbound mail is actually being accepted, and tells you about the ones that are not — or, once you arm it, stops them, without taking away the owner’s access to their own mail.
Out of the box it only alerts. Blocking is one checkbox away and most of this document describes what happens when it is on, but nothing cuts off a customer’s sending on a threshold nobody has validated yet.
Behaviour.
-
The worker’s
outbound-abusejob runs every 10 minutes over the delivery log (Delivery log & bounce classification) and groups finished outbound attempts in a rolling window (default 60 minutes) by envelope sender. -
A sender is judged compromised when — having sent at least
minMessages(default 20) attempts that reached a final outcome — either:- ≥ 30 % were rejected for reputation — the receiver named a blocklist, “spam” or “reputation”, or our own Rspamd refused the message as spam at SMTP time, or
- ≥ 60 % hard-bounced (5.x.x — sending to addresses that do not exist, the signature of a bought list). Deferrals are excluded, whatever their reply says: a 4xx is a slow receiver, not evidence, and a deferral retried four times is four rows. A message that is really being refused ends in a bounce, which counts. Messages held back by our own outbound rate limits are excluded too — see Which refusals count below.
-
On a verdict a critical alert appears on the dashboard naming the mailbox and the numbers behind the judgement. That is all that happens by default —
autoBlockships off, so the mailbox keeps sending until an operator has watched the thresholds fire against their own traffic and armed it. -
With
autoBlockon, the mailbox is also blocked from sending:sending_blockedis set with the reason and time, and an audit row is written with no actor (the system did it). -
Blocking refuses the mailbox as an envelope sender on the submission ports only. The owner still authenticates, still receives mail, and still reads everything over IMAP and webmail. Webmail and the messages API submit through the same port, so they are refused too (
422 rejectedfromPOST /messages). Their client reports:554 5.7.1 <bob@example.test>: Sender address rejected: Sending from this mailbox is blockedbecause it looks compromised. Contact your administrator. -
Only an admin lifts a block — Domains → the domain → the mailbox → Account → Restore sending, or
PATCH /api/v1/mailboxes/{id}with{"sendingBlocked": false}. The alert closes with it. Nothing un-blocks automatically. WithautoBlockoff there is no block to lift: the alert closes itself at the first sweep that no longer flags the mailbox. -
The alert stays open for as long as the block does. The mailbox goes quiet because it was blocked, so without this it would look resolved ten minutes later and the block would be invisible.
Configuration. Settings → Reputation protection → Containment (ServerConfig.abuse; windowMinutes has no field on the screen, so it is set through PUT /api/v1/server/config):
| Field | Default | Meaning |
|---|---|---|
enabled |
true |
Run the check at all. |
windowMinutes |
60 |
Rolling window the statistics cover. |
minMessages |
20 |
Floor — below this a mailbox is never judged, however bad the ratio. |
reputationRatio |
0.3 |
Share of reputation rejections that trips containment. |
hardBounceRatio |
0.6 |
Share of hard bounces that trips containment. |
autoBlock |
false |
Off: alert only. Turn on to block sending on a verdict. |
The schema bounds them, and PUT /api/v1/server/config refuses a value outside: windowMinutes 5–1440, minMessages at least 5, and both ratios 0.01–1.
OUTBOUND_ABUSE_INTERVAL_MINUTES (default 10) sets how often the worker sweeps.
Limits & edge cases.
-
With
autoBlockoff (the default) nothing is contained. The alert is the whole mechanism, and how fast it is acted on is however long it takes someone to look at the dashboard — alerts have no mail or webhook delivery yet (post-v1), so on an unattended server this is a record, not a defence. -
Once armed, containment takes up to ~11 minutes, plus a short tail — the sweep (10 min), the API’s containment tick (60 s) which pushes the blocked set into Postfix (a push whose apply fails is retried, after one minute and then doubling to at most fifteen, so a broken render does not write a configuration version every minute), and then Postfix recycling its
smtpdprocesses. Atexthash:map is read when a process starts, so ansmtpdalready running when the map changed keeps the old copy until it is reaped afterpostfix reload(bounded bymax_idle, ~100 s). The same tail applies in reverse when a block is lifted. It is a brake, not an interlock: a compromised mailbox can still send a window’s worth of mail, capped by the rate limits, before it is stopped. -
A restore holds. Restoring sending stamps
sending_restored_at, and containment ignores every delivery event older than it — so the mailbox is never re-blocked by evidence the administrator has already acted on. It takes effect within about a minute (the API’s containment tick), plus the Postfix process-recycle tail.It is worth knowing what the behaviour was, because the old behaviour is what an operator would expect from reading the query: the verdict was recomputed over the same rolling window every sweep, so a restore was undone about ten minutes later with no new outbound mail at all, whether or not the password had been reset. Acting on the cause and doing nothing were indistinguishable.
-
Restoring sending does not reset the password, and you should still reset it. The restore holds, but only against the past: everything the mailbox sends after it is judged afresh. If the credential is still stolen, the next window of rejections blocks the mailbox again — which is the protection working, not a bug. Reset the password first, which also ends its webmail sessions, and revoke any app password you did not issue: a reset leaves them working. The UI says to reset, and why.
-
System mailboxes are never blocked; blocking the DMARC ingestion mailbox would stop report ingestion to fix a problem it did not cause.
-
Only mailboxes can be blocked. A verdict against an alias or a bounce sender is dropped, because there is nothing to act on.
-
Detection is about rejection, not volume. A mailbox sending thousands of accepted messages is doing its job; that is what the rate limits are for. This catches the mailbox whose mail nobody will take.
-
Mail our own Rspamd refuses as spam at SMTP time counts, and counts as a reputation rejection (since 2026-09-05). Rspamd decides at end of message, so those are logged as a
milter-rejectthat usually carries a queue id, and they used to be dropped by the log parser entirely — so the clearest compromise there is, a mailbox whose entire output the spam filter is stopping on the way out, scored zero and was never blocked. They are classifiedreputationrather thanpolicydespite the 5.7.x DSN they carry: our own filter judging a message by its content is a statement about the sender, not a policy the sender tripped.
Which refusals count. Every finished outbound attempt in the window falls into one delivery class, and only some of them are evidence about the mailbox:
| Class | In the ratio? | Why |
|---|---|---|
delivered |
denominator | The baseline the shares are measured against. |
reputation |
numerator | A receiver cited a blocklist or spam — or our own Rspamd refused it as spam. |
hard |
numerator (hard-bounce ratio) | The address does not exist. |
policy |
denominator | An attempt that finished, but SPF/DMARC/relay rules are not a compromise signal. |
soft |
excluded | A 4xx deferral. So is any other deferral, whatever its class. |
throttled |
excluded | Our own rate limiter held it back. Nobody outside this server ever saw it. |
throttled is excluded from both sides of the ratio, and both halves of that matter:
- Counting it in the numerator convicted the mailbox the rate limiter exists to protect. A busy alias with
perUserMessagesPerHour: 500that tries 800 in an hour gets 500 out and 300 held back — 37.5 %, over the threshold, from mail that was never refused by anyone. - Counting it in the denominator alone would be worse: a genuinely compromised mailbox sends hard enough to trip its own limit, and its throttles would swamp the ratio and hide it.
Rspamd’s rate limiter and Rspamd’s spam verdict reach Postfix over the same milter with the same
milter-reject action, and Postfix does not log which rule fired. They are told apart by the reason string —
rspamd writes Ratelimit "<bucket>" exceeded — so an operator who overrides the buckets’ message in the
Rspamd config makes throttles look like reputation failures again. Leave it alone.
- The API must stay single-instance — the last-applied blocked set is held in memory, the same constraint the warm-up schedule carries.
Not to be confused with the burst alert. Outbound rate limits raises a warning when a sender keeps hitting its own limit. That is the opposite signal to this one — mail we held back, rather than mail other servers refused — it never blocks anything, and it resolves itself when the bursting stops. Containment’s alert is held open while the block stands, because the mailbox goes quiet because it was blocked. A mailbox can raise both at once, and that combination is the interesting one: sending hard enough to trip its own ceiling and being refused by receivers.