cbpolicyd Quotas: Rate Limiting Mail on Zimbra
Quotas is the module people enable cbpolicyd for. It counts messages or bytes against a rolling time window and acts when the count is exceeded, which is something no other part of a Zimbra server can do. Sieve evaluates one message and forgets it. A milter reads one message at a time. Only cbpolicyd remembers that this sender has already sent four hundred messages this hour.
Part of the series that starts with cbpolicyd on Zimbra: An Introduction. Read Enabling cbpolicyd and Reading the Policy Model first, because a quota attaches to a policy and will silently do nothing if that policy matches nothing.
The quotas module is enabled by default on Zimbra, so there is no attribute to set.
How a quota is put together
Three tables, each pointing at the one above it.
policies which messages this applies to
|
+-- quotas what to count, over what period, and what to do
|
+-- quotas_limits the actual number
|
+-- quotas_tracking the live counters
quotas holds Track, Period, Verdict and Data. quotas_limits holds Type and
CounterLimit. One quota can have several limits, which is how you cap count and volume
together.
Track: what gets its own counter
This is the field that decides everything, and the format is <type>:<spec>:
| Track | One counter per |
|---|---|
Sender:user@domain | individual sender address |
Sender:@domain | whole sending domain, pooled |
Sender:user@ | local part, across domains |
Recipient:user@domain | individual recipient |
Recipient:@domain | whole recipient domain |
SenderIP:/24 | client network, masked to that prefix |
Sender:@domain and Sender:user@domain sound similar and behave completely differently.
The first gives the entire domain one shared bucket, so one busy user exhausts everyone's
allowance. The second gives each person their own.
Period and limits
Period is a rolling window in seconds. CounterLimit is the number, and Type is
either MessageCount or MessageCumulativeSize, the latter in bytes.
Verdict: what happens at the limit
| Verdict | Effect |
|---|---|
DEFER | 4xx, the sender retries later. Correct for rate limits |
REJECT | 5xx, the message is refused permanently |
HOLD | Postfix parks it in the hold queue for you to inspect |
DISCARD | Accepted and silently dropped |
Use DEFER unless you have a reason not to. A rate limit is a statement about timing,
not about whether the mail is wanted, and a deferral lets legitimate senders through a few
minutes later instead of bouncing their mail. Data is the text returned with the verdict.
Use case: catch a compromised account
The single best reason to run this module. A stolen password looks like a normally quiet mailbox suddenly sending thousands of messages, usually overnight. Scope the quota to authenticated submission and you are measuring exactly the population that can be compromised.
INSERT INTO policies (Name, Priority, Description, Disabled)
VALUES ('Authenticated submission limits', 15,
'Cap what any authenticated user can send', 0);
INSERT INTO policy_members (PolicyID, Source, Destination, Disabled)
VALUES ((SELECT ID FROM policies WHERE Name = 'Authenticated submission limits'),
'$*', -- any authenticated session
'any', 0);
INSERT INTO quotas (PolicyID, Name, Track, Period, Verdict, Data, Disabled)
VALUES ((SELECT ID FROM policies WHERE Name = 'Authenticated submission limits'),
'Auth: messages per hour',
'Sender:user@domain',
3600,
'DEFER',
'Sending rate exceeded. Please contact your administrator.',
0);
INSERT INTO quotas_limits (QuotasID, Type, CounterLimit, Disabled)
VALUES ((SELECT ID FROM quotas WHERE Name = 'Auth: messages per hour'),
'MessageCount', 200, 0);
$* in Source means any authenticated session, so unauthenticated inbound mail is
untouched. Pick a limit comfortably above what your busiest real user sends. The goal is to
catch a thousand-message burst, not to police normal work.
Use case: cap volume as well as count
A spammer sending a few large messages, or a user mailing a video to a distribution list, slips past a message count. Add a second limit row to the same quota:
INSERT INTO quotas_limits (QuotasID, Type, CounterLimit, Disabled)
VALUES ((SELECT ID FROM quotas WHERE Name = 'Auth: messages per hour'),
'MessageCumulativeSize', 524288000, 0); -- 500 MB in bytes
Both limits belong to one quota and either can trip it. Note that quotas_limits takes
bytes, while the accounting module takes kilobytes - an easy thousand-fold mistake.
Use case: protect a recipient from a mail bomb
Counting in the other direction protects an individual address from being flooded:
INSERT INTO quotas (PolicyID, Name, Track, Period, Verdict, Data, Disabled)
VALUES (3, -- Default Inbound
'Inbound: per recipient per hour',
'Recipient:user@domain',
3600,
'DEFER',
'Recipient is receiving too much mail, please retry shortly.',
0);
INSERT INTO quotas_limits (QuotasID, Type, CounterLimit, Disabled)
VALUES ((SELECT ID FROM quotas WHERE Name = 'Inbound: per recipient per hour'),
'MessageCount', 500, 0);
Be careful with this one. A shared address such as support@ legitimately receives far
more than a person does, and DEFER on inbound mail means senders you do not control are
retrying. Set it high enough to only catch genuine floods.
Use case: throttle a noisy client network
SenderIP masks the client address to a prefix, which catches a spam source rotating
through addresses in one range:
INSERT INTO quotas (PolicyID, Name, Track, Period, Verdict, Data, Disabled)
VALUES (1, -- Default, i.e. everything
'Per /24 per hour',
'SenderIP:/24',
3600,
'DEFER',
'Too much mail from your network, please retry later.',
0);
INSERT INTO quotas_limits (QuotasID, Type, CounterLimit, Disabled)
VALUES ((SELECT ID FROM quotas WHERE Name = 'Per /24 per hour'),
'MessageCount', 1000, 0);
Watch out for large senders behind shared infrastructure. Google, Microsoft and any mailing list provider will exceed a per-/24 limit that a small office never approaches, so either set it generously or add a higher-priority policy that exempts known good networks.
Use case: a daily cap per domain
For a multi-tenant server, pooling a whole domain into one bucket is the natural billing or fair-use boundary:
INSERT INTO quotas (PolicyID, Name, Track, Period, Verdict, Data, Disabled)
VALUES (2, -- Default Outbound
'Outbound: per domain per day',
'Sender:@domain',
86400,
'DEFER',
'Daily sending limit for this domain reached.',
0);
INSERT INTO quotas_limits (QuotasID, Type, CounterLimit, Disabled)
VALUES ((SELECT ID FROM quotas WHERE Name = 'Outbound: per domain per day'),
'MessageCount', 20000, 0);
If you want calendar days rather than a rolling 24 hours, the accounting module is the better fit.
Watching the counters
SELECT q.Name, t.TrackKey, t.Counter, datetime(t.LastUpdate, 'unixepoch') AS LastUpdate
FROM quotas_tracking t
JOIN quotas_limits l ON l.ID = t.QuotasLimitsID
JOIN quotas q ON q.ID = l.QuotasID
ORDER BY t.LastUpdate DESC
LIMIT 20;
TrackKey shows you exactly what cbpolicyd decided to count, which is the fastest way to
confirm your Track string does what you meant.
To reset a single quota's counters during testing:
DELETE FROM quotas_tracking
WHERE QuotasLimitsID IN (
SELECT l.ID FROM quotas_limits l
JOIN quotas q ON q.ID = l.QuotasID
WHERE q.Name = 'Auth: messages per hour');
Three things that catch people out
Counting happens per recipient. Postfix consults the policy service at RCPT stage, so a
single message addressed to five people can add five to a MessageCount counter. Watch
quotas_tracking during a test before choosing a production limit.
Every matching policy applies. cbpolicyd does not stop at the first match, so a quota
on Default and another on Default Outbound will both count the same outbound message.
That is useful when intended and surprising when not.
Policies are cached. Restart after every change:
zmcbpolicydctl restart
Next in this series
cbpolicyd Access Control covers the module for allowing and denying outright, which is the other half of what most people want from a policy daemon.