Skip to main content

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?

QuotasAccounting
PeriodRolling, in secondsCalendar day, week or month
ResetsGradually, as old entries age outSharply, at the period boundary
Size unitBytesKilobytes
LimitsSeparate quotas_limits rowsColumns on the same row
Typical useRate limiting, abuse controlReporting, 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

ColumnWhat it does
TrackWhat gets its own counter, same syntax as quotas
AccountingPeriod0 day, 1 week, 2 month
MessageCountLimitMessage limit for the period, NULL for none
MessageCumulativeSizeLimitSize limit in kilobytes, NULL for none
VerdictWhat to do when a limit is passed
DataText 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.