cbpolicyd Accounting on Zimbra
Accounting counts the same things quotas does, over a different kind of period. Quotas use a rolling window measured in seconds. Accounting uses calendar buckets: this day, this week, this month. That makes it the right module for "how much mail did this domain send in September", and for any limit that should reset on a date rather than gradually decay.
It can enforce as well as measure, which is easy to miss.
Part of the series that starts with cbpolicyd on Zimbra: An Introduction.
Enable it
zmprov ms $(zmhostname) zimbraCBPolicydAccountingEnabled TRUE
zmmtactl restart
Accounting or quotas?
| Quotas | Accounting | |
|---|---|---|
| Period | Rolling, in seconds | Calendar day, week or month |
| Resets | Gradually, as old entries age out | Sharply, at the period boundary |
| Size unit | Bytes | Kilobytes |
| Limits | Separate quotas_limits rows | Columns on the same row |
| Typical use | Rate limiting, abuse control | Reporting, monthly allowances |
The unit difference is worth writing down. quotas_limits.CounterLimit with
MessageCumulativeSize is in bytes; accounting.MessageCumulativeSizeLimit is in
kilobytes. Getting them the wrong way round is a thousand-fold error in either direction.
Use quotas to stop a burst. Use accounting to enforce "500 MB a month" or to answer a question at the end of a quarter.
The table
| Column | What it does |
|---|---|
Track | What gets its own counter, same syntax as quotas |
AccountingPeriod | 0 day, 1 week, 2 month |
MessageCountLimit | Message limit for the period, NULL for none |
MessageCumulativeSizeLimit | Size limit in kilobytes, NULL for none |
Verdict | What to do when a limit is passed |
Data | Text returned with the verdict |
Leave both limits NULL and the module only records. Set one and it enforces.
Track takes the same values as quotas: Sender:user@domain, Sender:@domain,
Recipient:user@domain, SenderIP:/24 and so on.
Use case: measure without enforcing
The place to start on any server, because it answers what your limits should be before you set any:
INSERT INTO policies (Name, Priority, Description, Disabled)
VALUES ('Traffic accounting', 90, 'Measure outbound volume per domain', 0);
INSERT INTO policy_members (PolicyID, Source, Destination, Disabled)
VALUES ((SELECT ID FROM policies WHERE Name = 'Traffic accounting'),
'%internal_domains', 'any', 0);
INSERT INTO accounting
(PolicyID, Name, Track, AccountingPeriod,
MessageCountLimit, MessageCumulativeSizeLimit, Verdict, Disabled)
VALUES
((SELECT ID FROM policies WHERE Name = 'Traffic accounting'),
'Outbound per domain per month',
'Sender:@domain',
2, -- monthly
NULL, -- no limit
NULL, -- no limit
NULL,
0);
Priority 90 puts it after everything else. It changes no verdict, so it does not matter
where it runs, and keeping reporting policies out of the way of enforcement ones makes the
priority list easier to read later.
Use case: a monthly allowance per domain
On a multi-tenant server, a calendar-month allowance is the natural billing boundary:
INSERT INTO accounting
(PolicyID, Name, Track, AccountingPeriod,
MessageCountLimit, MessageCumulativeSizeLimit, Verdict, Data, Disabled)
VALUES
(2, -- Default Outbound
'Monthly allowance per domain',
'Sender:@domain',
2,
50000, -- 50,000 messages
10485760, -- 10 GB expressed in kilobytes
'DEFER',
'Monthly sending allowance reached for this domain.',
0);
DEFER rather than REJECT, for the same reason as always: a domain that has hit its
allowance on the 28th should have its mail queued and retried, not destroyed. Though note
that with a monthly period, a deferral near the end of the month may keep retrying until
the sending server gives up. For a hard allowance, HOLD is often kinder, because the mail
sits in your queue where you can release it after a conversation.
Use case: a daily cap per user
INSERT INTO accounting
(PolicyID, Name, Track, AccountingPeriod,
MessageCountLimit, MessageCumulativeSizeLimit, Verdict, Data, Disabled)
VALUES
(2,
'Daily cap per sender',
'Sender:user@domain',
0, -- daily
2000,
NULL,
'HOLD',
'Daily message allowance reached.',
0);
The difference from the equivalent quota is the reset. A quota with Period = 86400 lets a
user who sent 2000 messages at nine this morning start sending again gradually from nine
tomorrow. This version gives everyone a clean slate at midnight.
Reading the numbers
SELECT a.Name, t.TrackKey, t.PeriodKey,
t.MessageCount,
t.MessageCumulativeSize AS KBytes,
datetime(t.LastUpdate, 'unixepoch') AS LastUpdate
FROM accounting_tracking t
JOIN accounting a ON a.ID = t.AccountingID
ORDER BY t.MessageCount DESC
LIMIT 20;
PeriodKey is the calendar bucket, so filtering on it gives you a report for one month:
SELECT TrackKey, MessageCount, MessageCumulativeSize AS KBytes
FROM accounting_tracking
WHERE PeriodKey LIKE '2026-09%'
ORDER BY MessageCumulativeSize DESC;
Export it for a spreadsheet:
sqlite3 -header -csv /opt/zimbra/data/cbpolicyd/db/cbpolicyd.sqlitedb \
"SELECT TrackKey, PeriodKey, MessageCount, MessageCumulativeSize
FROM accounting_tracking ORDER BY PeriodKey, TrackKey;" > traffic.csv
Housekeeping
accounting_tracking grows with one row per tracked key per period and nothing removes
old rows. On a busy multi-tenant server with per-user daily tracking, that is a row per
user per day indefinitely. Keep a year and drop the rest:
DELETE FROM accounting_tracking
WHERE LastUpdate < strftime('%s', 'now', '-1 year');
sqlite3 /opt/zimbra/data/cbpolicyd/db/cbpolicyd.sqlitedb 'VACUUM;'
Run VACUUM outside busy hours. It rewrites the database file and holds a lock while it
does, and cbpolicyd is answering Postfix from that same file.
Next in this series
cbpolicyd and Amavis, the module with the largest table and the narrowest usefulness on a Zimbra server.