Skip to main content

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>:

TrackOne counter per
Sender:user@domainindividual sender address
Sender:@domainwhole sending domain, pooled
Sender:user@local part, across domains
Recipient:user@domainindividual recipient
Recipient:@domainwhole recipient domain
SenderIP:/24client 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

VerdictEffect
DEFER4xx, the sender retries later. Correct for rate limits
REJECT5xx, the message is refused permanently
HOLDPostfix parks it in the hold queue for you to inspect
DISCARDAccepted 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.