Quota (RFC 9208)
yarilo implements per-user storage and message-count quota via the IMAP QUOTA extension (RFC 9208) and the pkg/ package.
Architecture
Limits come from the userdb quota_ extra field:
userdb quota_rule=*:storage=5G → AuthResponse.QuotaRules → userInfo.QuotaRules
→ quota.ParseRules(...) → quota.Limits{StorageBytes: 5*1024^3}Usage is the count backend — derived from the index, never a stored counter. This mirrors the reference, which removed its dict quota backend (the drift-prone "counter is the source of truth" model); the authoritative value is computed from the mailbox index.
- The FileIndex carries two extensions (see the internal docs): a per-record
vsize(virtual/RFC822 size) and a headerhdr-vsizeaggregate{vsize, highest_.uid, message_ count} - The aggregate is maintained incrementally on append/expunge and self-heals: on load it is trusted only while
highest_(a cheap O(1) validity check, exactly the reference's), otherwise it is recomputed from the per-record vsize.uid+1 == uidnext && message_ count == messages quota.sums each folder'sCountUsage(idx, folders, limits) FolderVSizeaggregate — the authoritative usage.ignorefolders are skipped.
Every enforcer reads from the index (no dict in the enforcement path):
| Path | How usage is read |
|---|---|
| IMAP GETQUOTA / APPEND enforcement | session. → sum FolderVSize (1 s display cache; enforcement fresh) |
| LMTP delivery | opens the recipient index at delivery time → CountUsage |
yarilo-quota-status (Postfix policy) | a full mail process: looks the recipient up in userdb through yarilo-auth, opens the mailbox+index → CountUsage, exactly like the reference's quota-status (mail_). Not a dict reader. Needs the mail PV mounted and quota_ set — see quota-status actions. |
backend-api /show, /recalc | CountUsage; /recalc force-rebuilds each folder's aggregate |
| POP3 | nothing — POP3 never appends, so no enforcement; DELE→expunge decrements the index aggregate automatically |
Quota engine and IMAP QUOTA
- quota engine (
quota.) — the count backend + enforcement on every save: IMAP APPEND/COPY/MOVE (OVERQUOTA), LMTP delivery (552, or 452 withenabled protocol.), and the quota-status policy service. This mirrors the reference'slmtp. quota_ full_ tempfail quotaplugin, whosequota-storage.hooksc mail_— the path shared by APPEND and delivery.save - IMAP QUOTA extension (
protocol.) — RFC 9208imap. imap_ quota GETQUOTA/GETQUOTAROOT+ theQUOTAcapability. A client-facing query only, no enforcement (the reference'simap_plugin registers only the commands). Toggle it independently of the engine — you can enforce without advertising the extension, or advertise it without enforcing.quota
In the chart the engine is off by default (quota.) and the extension on (protocol.).
quota_clone (external mirror)
quota_ mirrors the current usage into external dicts for external consumers outside the mail server (provisioning DB, dashboard). It is not part of enforcement — enforcement always reads the index; yarilo's own quota-status opens the mailbox. The mirror is advisory and never the source of truth.
The trigger matches the reference plugin: on a usage-changing event (save / copy / expunge) the session marks the mirror dirty and, at most once per quota_ seconds, reads the authoritative count and writes it out; a final flush runs on session close. Only active users (with an open session) are mirrored — there is no full-user sweep.
yarilo's enhancement over the single-dict reference is a multi-dict fan-out: several targets are written in parallel (e.g. SQL + Redis at once), each best-effort — a failing target is logged and never blocks the others or the authoritative path.
quota:
quota_clone_dicts: [quota_clone_sql, quota_clone_redis] # names from dicts:
quota_clone_flush_delay: 10
dicts:
quota_clone_sql: { driver: sql, ... }
quota_clone_redis: { driver: redis, ... }Keys written per user: priv/ (bytes) and priv/ (count) — the same layout the reference's clone uses, so existing external readers work unchanged.
Configuration
Two independent toggles — in the chart the engine defaults off, the extension on:
quota:
enabled: true # engine: enforce on every save (APPEND/COPY/MOVE, LMTP, quota-status)
quota_name: "User quota" # quota-root name in GETQUOTA / GETQUOTAROOT
quota_exceeded_message: "Quota exceeded (mailbox for user is full)" # over-quota rejection text
quota_mail_size: "" # reject any single message larger than this ("50M"); ""/"0" = unlimited
protocol:
imap:
imap_quota: true # IMAP QUOTA extension: advertise QUOTA + answer GETQUOTA (query only)quota_ is independent of the usage limit and applies even without a per-user quota_. Over IMAP its rejection carries a distinct "exceeds max mail size" text, so a client can tell "message too large" from "mailbox full". Over LMTP it is offered as SIZE and enforced while the message is read (see LMTP). The quota-status policy service additionally honours quota_ (default +) when deriving the target folder from the recipient detail part.
No dict is needed — usage is summed from the index. Set a per-user limit in the SQL passdb:
UPDATE yarilo_users SET quota_rule = '*:storage=5G' WHERE username = '[email protected]';The quota_ column can hold a comma-separated list of rules.
Quota rule format
[<mailbox>:]<resource>=<limit>| Example | Meaning |
|---|---|
*:storage=5G | 5 GiB storage limit (all mailboxes) |
*:storage=500M | 500 MiB limit |
*:messages=100000 | 100 000 message limit |
*:storage=0 | Unlimited storage |
Units: K (KiB), M (MiB), G (GiB), T (TiB). Plain integer is bytes. 0 means unlimited. Multiple rules are comma-joined in the SQL column; the last *:storage= rule wins.
Site-wide policy options
These are global quota: config (not per-user rules) and layer on top of the resolved per-user limits:
quota_storage_percentage
- Default
100- Type
- integer
- Helm
quota.quota_storage_percentage
Scale the storage limit: limit·pct/.
quota_message_percentage
- Default
100- Type
- integer
- Helm
quota.quota_message_percentage
Scale the message-count limit.
quota_storage_extra
- Default
- none
- Type
- string
- Helm
quota.quota_storage_extra
Byte headroom added to the storage limit after scaling.
quota_storage_grace
- Default
10M- Type
- string
- Helm
quota.quota_grace
Storage overshoot allowed on inbound delivery (LMTP/LDA) only, never on interactive IMAP. It lets the one delivery that crosses the limit through, as long as it lands within the grace. A mailbox already at or over its limit accepts no further delivery, however much grace is left.
quota_ignore_unlimited
- Default
false- Type
- boolean
- Helm
quota.quota_ignore_unlimited
Omit the quota root from GETQUOTA/GETQUOTAROOT for unlimited users.
quota_mailbox_count
- Default
0- Type
- integer
- Helm
quota.quota_mailbox_count
Cap the number of mailboxes (folders). Enforced at CREATE — NO [LIMIT] Maximum number of mailboxes reached. 0 is unlimited.
quota_mailbox_message_count
- Default
0- Type
- integer
- Helm
quota.quota_mailbox_message_count
Cap on messages in a single mailbox. A mailbox holds up to this many; the save past it is refused with NO [OVERQUOTA] Too many messages in the mailbox (LMTP 552 5., or 452 4. with protocol.). 0 is unlimited.
quota_hidden
- Default
false- Type
- boolean
- Helm
quota.quota_hidden
Omit the quota root from GETQUOTA/GETQUOTAROOT for every user (enforcement still applies). Broader than quota_.
Effective storage limit = rule_. The scaled limit is what GETQUOTA reports and what every enforcement point checks. On LMTP delivery, and in the quota-status policy service, quota_ lets the delivery that crosses this limit through. It does not raise the limit.
Quota warnings
quota_ runs an action when a user's usage crosses a percentage of their limit — the same edge-trigger as the reference quota_ (fires once on the transition, not repeatedly while over). The action is a program in quota_ (mirrors sieve_), run best-effort; when no bin dir is set the crossing is only logged.
quota:
quota_warning_bin_dir: "/usr/lib/yarilo/quota-warning"
quota_warning_exec_timeout: 10
quota_warnings:
- quota_warning_name: "storage90"
quota_warning_resource: storage # storage | message
quota_warning_threshold: over # over | under
quota_warning_percentage: 90 # % of the user's limit
quota_warning_execute: "warn-user" # program name in the bin dirThe program is located by bare name inside the bin dir (a path separator is rejected) and receives the crossing context via the environment: USER, HOME, HOST, QUOTA_, QUOTA_, QUOTA_, QUOTA_, QUOTA_, QUOTA_.
over crossings fire on save (IMAP APPEND/COPY/MOVE, LMTP delivery); under crossings fire on the IMAP expunge that drops usage back below the threshold.
External over-quota flag
Keeps an external "over quota" flag in sync so an MTA can reject mail to over-quota users without querying the mail server. At login (IMAP) the actual over-quota state is compared to the userdb quota_; on a mismatch a program updates the external flag.
quota:
quota_warning_bin_dir: "/usr/lib/yarilo/quota-warning" # shared with warnings
quota_over_status_mask: "TRUE" # wildcard the userdb flag is matched against
quota_over_status_lazy_check: false # true = defer to the first quota op
quota_over_status_execute: "sync-flag" # program in the bin dirquota_(userdb) is the stored flag.over_ flag flagged= it is non-empty and matchesquota_(case-insensitiveover_ status_ mask */?glob).actual= usage ≥ limit for any resource.- On
actual != flaggedthe program runs withUSER,HOME,HOST,QUOTA_(the stale value),OVER_ FLAG QUOTA_(OVER yes/no) in the environment. The check runs once per session and only while the userdb flag is still fresh (login within 10 s). - Empty
quota_disables it. Currently wired for IMAP logins (POP3 lacks the usage-count path — a separate task).over_ status_ mask
quota-status actions
Every answer of the yarilo-quota-status policy service is a setting under components. in the chart (quota_ in yarilo.). In the over-quota and too-large actions, %{error} is replaced by the reason.
The service needs quota_ — the yarilo-auth master listener it looks recipients up through. Without it the service answers quota_ for every recipient: it checks no limit, applies no default_ and refuses no unknown user.
From 2.4.2 the chart fills it in: with components. empty it renders the release's auth service on components.. In 2.4.1 the chart leaves it empty, so set it there:
components:
quotaStatus:
enabled: true
auth_master_addr: "yarilo-auth:9102" # <release>-auth, components.auth.masterListenThe service's other settings:
listen
- Default
:12340- Type
- string
Policy listener. The chart sets it from components..
recipient_delimiter
- Default
+- Type
- string
- Helm
components.quotaStatus.recipient_delimiter
Separates the detail that names the target folder (alice+Spam@ → Spam).
default_quota_rules
- Default
[]- Type
- list
- Helm
components.quotaStatus.default_quota_rules
Rules, in quota_ format, for a recipient whose userdb entry carries none.
alias_dict
- Default
- none
- Type
- string
- Helm
components.quotaStatus.alias_dict
A dict from dicts: that maps an alias to its mailbox; the recipient is resolved through it before the lookup, retrying without the detail part. Empty means no alias resolution.
alias_max_hops
- Default
5- Type
- integer
- Helm
components.quotaStatus.alias_max_hops
Longest alias chain followed.
quota_status_success
- Default
OK- Type
- string
- Helm
components.quotaStatus.quota_status_success
Returned when the message fits: under the limit, or within the storage grace for the delivery that crosses it. Also for an ignored folder or a user without limits.
quota_status_overquota
- Default
554 5.2.2 %{error}- Type
- string
- Helm
components.quotaStatus.quota_status_overquota
Returned when the mailbox is full, with %{error} = quota.. Also when INBOX already holds quota_ messages, with %{error} = Too many messages in the mailbox.
quota_status_toolarge
- Default
- none
- Type
- string
- Helm
components.quotaStatus.quota_status_toolarge
Returned when the message is larger than quota., or larger than the user's whole storage limit. Empty uses quota_.
quota_status_nouser
- Default
REJECT Unknown user- Type
- string
- Helm
components.quotaStatus.quota_status_nouser
Returned when the recipient is unknown in userdb. Empty answers DUNNO, so a later Postfix restriction decides.
The default refusal 554 5. is permanent: the MTA bounces the message to the sender at once. To have the MTA keep it queued and retry instead, set for example quota_ or "452 4..
When the size is not known yet (an MTA asking at RCPT without a SIZE), the check counts one byte, so a mailbox already at or over its limit is still refused.
A userdb lookup that fails, or a mailbox that cannot be counted, answers DEFER_ with the reason. The MTA retries later; it neither accepts the message unchecked nor bounces it.
A request without a recipient, such as Postfix's end-of-message call, answers DUNNO.
IMAP wire (RFC 9208)
When protocol. is on the server advertises:
* CAPABILITY ... QUOTAClient commands:
C: A1 GETQUOTAROOT INBOX
S: * QUOTAROOT INBOX "User quota"
S: * QUOTA "User quota" (STORAGE 1024 5242880 MESSAGE 42 0)
S: A1 OK GETQUOTAROOT completed
C: A2 GETQUOTA "User quota"
S: * QUOTA "User quota" (STORAGE 1024 5242880 MESSAGE 42 0)
S: A2 OK GETQUOTA completed
C: A3 SETQUOTA "User quota" (STORAGE 10485760)
S: A3 NO [NOPERM] Permission deniedSTORAGE values are in kibibytes (1 KiB = 1024 bytes). MESSAGE values are raw counts. Limit 0 means unlimited.
SETQUOTA is always rejected — limits are operator-managed only.
Enforcement
When a user is over quota, APPEND returns (text from quota.):
NO [OVERQUOTA] Quota exceeded (mailbox for user is full)A message larger than quota. is rejected regardless of usage with a distinct text:
NO [OVERQUOTA] Requested allocation size 1200000 exceeds max mail size 1048576The check reads current usage from the index. On a transient read error it is skipped (fail-open) so an I/O blip does not block delivery.
Admin API
# Show current usage (summed from the index)
yarctl backend quota show [email protected]
# Force-rebuild each folder's aggregate from records, then report
yarctl backend quota recalc [email protected]These call GET / and POST / on yarilo-backend-api. There is no set — usage is computed, not stored.
Recalc
The index self-heals on load (the O(1) validity check), so recalc is only needed to force a rebuild after suspected aggregate corruption. It reopens each folder, recomputes hdr-vsize from the per-record vsize, and returns the sum.
Inspecting the quota_clone mirror
quota_ mirrors the authoritative usage into one or more external dicts (SQL + Redis fan-out). To confirm the fan-out is coherent or debug a divergent target:
# List the configured clone backends
yarctl backend quota clone list
# Read what one backend holds for a mailbox (advisory mirror)
yarctl backend quota clone get quota_clone_mysql [email protected]These call GET / and GET /, which reads priv/ + priv/ from that dict, scoped per user. backend is restricted to the configured clone list — for arbitrary dicts use yarctl backend dict lookup (yarctl). The returned value is an advisory mirror; the authoritative usage is quota show, summed from the index.
Helm
# values.yaml
quota:
enabled: true # enforce
quota_grace: "10M" # the chart's name for quota_storage_grace
protocol:
imap:
imap_quota: true # advertise the QUOTA extensionThe chart's quota: block uses the yarilo. key names with one exception: the storage grace is quota. in the values, rendered as quota_. From 2.4.2 a quota. in the values fails the render and names quota_; in 2.4.1 it is dropped without a warning.
The yarilo-quota-status pod mounts the mail PV read-only so it can open recipient mailboxes; set quota_ in the passdb schema.