The yarilo-backend-api binary exposes the operator surface over HTTP
for backend-plane operations: dict (today), acl (Phase ACL-1),
quota (Phase QUOTA-1), folder / user (future). One instance
runs per backend tag (or one per standalone deployment).
The yarilo-admin CLI is a thin HTTP client over this API; every
backend-plane subcommand lives under yarilo-admin backend <service> <command> (backend dict ..., future backend acl ...,
backend quota ..., etc.).
For the director's own admin endpoints (ring / backends / users / peers) see DIRECTOR-API.md — different binary, different port, different token.
- Protocol: JSON over HTTPS (matching the existing
/api/director/...surface) - Auth: Bearer token in
Authorization: Bearer <token>. Server reads it from theBACKEND_API_TOKENenv var (wired by the chart from a Secret). Empty token disables auth — local dev only - IP allow-list: when
backend_api.allowed_netsis set inyarilo.yaml, clients outside those CIDRs get403 forbiddenbefore the bearer check - mTLS: when
internal_tls.enabled: true, the listener is TLS-terminated with the same internal CA the rest of the cluster uses
| Setting | Default |
|---|---|
| Listen | :9105 |
| Auth | Bearer token, mandatory in production |
| TLS | mTLS in production; plain in dev |
| Body limit | 1 MiB per request |
| Iterate timeout | 5 minutes |
| Other op timeout | 30 seconds |
Liveness probe. Returns 200 {"status":"ok"} whenever the process
is up. Bypasses payload constraints — usable by k8s probes.
Lists every dict driver registered in this process.
{ "drivers": ["fail", "file", "memory", "redis", "sql"] }Reports whether the named dict is configured on this backend-api.
{ "name": "metadata", "exists": true }// request
{ "key": "priv/box/<guid>/comment", "op": { "username": "alice@x.com" } }
// response
{ "found": true, "values": ["<base64>"] }op (per-call pkg/dict.OpSettings) is optional. Multi-value
drivers return the full list; single-value drivers return a
one-element array. found: false → values is omitted/empty.
Streaming endpoint. Response Content-Type: application/x-ndjson —
one JSON object per line. A {"error": "..."} line MAY appear
mid-stream when iteration fails after some rows have been emitted;
clients MUST check every line for the error key.
// request
{
"path": "priv/box/",
"flags": 3,
"op": { "username": "alice@x.com" }
}
// response (NDJSON, one row per line)
{"key":"priv/box/abc123/comment","values":["<base64>"]}
{"key":"priv/box/abc123/admin","values":["<base64>"]}Flags bitmask (pkg/dict.IterFlag):
| Bit | Value | Meaning |
|---|---|---|
| 0 | 1 | Recurse — descend into sub-hierarchies |
| 1 | 2 | SortByKey |
| 2 | 4 | SortByValue |
| 3 | 8 | NoValue — omit values from rows |
| 4 | 16 | ExactKey — return all values for one exact key (no recursion) |
// request
{ "key": "priv/foo", "value": "<base64>", "op": {} }
// response
{ "result": 1, "status": "ok" }result is the raw pkg/dict.CommitResult value (1 = OK,
0 = not-found, -1 = failed, -2 = write-uncertain). status
is the human-readable mirror used by the CLI.
// request
{ "key": "priv/foo", "op": {} }
// response
{ "result": 1, "status": "ok" }Unsetting a missing key is not an error — status: ok.
// request
{ "key": "priv/quota/storage", "delta": 1024, "op": {} }
// response when key exists
{ "result": 1, "status": "ok" }
// response when key is missing
{ "result": 0, "status": "not-found" }// request
{}
// response
{ "status": "ok" }Drivers without TTL support are a no-op (still 200).
Multi-op atomic transaction. Returns a single commit result; on failure no individual op is applied.
// request
{
"op": { "username": "alice@x.com" },
"ops": [
{ "kind": "set", "key": "a", "value": "<base64>" },
{ "kind": "unset", "key": "b" },
{ "kind": "atomic-inc", "key": "counter", "delta": 5 }
]
}
// response
{ "result": 1, "status": "ok" }kind is one of set / unset / atomic-inc.
Errors come back with the matching HTTP status and a JSON body:
{ "error": "dict \"no-such\" not configured" }| Status | Meaning |
|---|---|
| 400 | bad request body / malformed JSON / unknown driver |
| 401 | missing or invalid bearer token |
| 403 | client IP not in allowed_nets |
| 404 | dict name not configured on this backend-api |
| 500 | driver / I/O error |
| 503 | dict closed (process shutting down) |
Read-only inspection of mailbox state. Mutating folder ops
(create / delete / rename / expunge) are deferred — they need
IMAP-level ACL context and proper event emission to live sessions.
See TODO.md.
Common request body (every folder endpoint accepts it):
{ "user": "alice@x.com", "folder": "INBOX", "namespace": "personal" }namespace defaults to personal when omitted. Other valid values
are the slugs configured under namespaces[].prefix (e.g.
shared, public).
Returns every folder visible in the namespace via the underlying
storage driver (UserMailbox.ListFolders).
{ "folders": ["INBOX", "Sent", "Trash"] }CLI: yarilo-admin backend folder list <user> [--namespace NS]
Folder metadata. guid is the 16-byte rename-stable identifier
stamped at folder creation — survives RENAME and is used as the
ACL/metadata key namespace.
{
"name": "INBOX",
"guid": "ab12...ef",
"uid_validity": 1747000000,
"next_uid": 42,
"messages": 7,
"unseen": 3,
"highest_modseq": 19
}CLI: yarilo-admin backend folder info <user> <folder>
Convenience extraction of guid from the info payload — useful for
piping into ACL / METADATA CLIs that need the GUID directly.
{ "folder": "INBOX", "guid": "ab12...ef" }CLI: yarilo-admin backend folder guid <user> <folder>
folder/info plus an on-disk rollup (sum of physical message
sizes from UserMailbox.List).
{
"name": "INBOX",
"guid": "ab12...ef",
"uid_validity": 1747000000,
"next_uid": 42,
"messages": 7,
"unseen": 3,
"highest_modseq": 19,
"size_bytes": 1234567,
"on_disk_count": 7
}CLI: yarilo-admin backend folder stats <user> <folder>
Creates a new folder under the named namespace. When special_use
is set AND the namespace is personal, the folder is registered
with that RFC 6154 attribute via the special-use store so a
subsequent LIST surfaces it (matches the IMAP CREATE-SPECIAL-USE
flow). 409 when the folder already exists.
{
"user": "alice@x.com",
"folder": "Archive",
"namespace": "personal",
"special_use": "\\Archive"
}Response (200):
{ "status": "ok" }When the folder is created but special_use registration fails,
the response is 200 with a special_use_error field — the folder
exists, the operator just needs to follow up to set the attr.
Authorisation: admin path — bypasses ACL. See the security
note at the top of this document; backend-api is gated by
Token, AllowedNets, and mTLS.
CLI: yarilo-admin backend folder create <user> <folder> [--namespace NS] [--special-use ATTR]
Removes a folder, its index state, and any yarilo-acl file +
namespace-wide list entries pointing at it. The mailbox blob
storage handles the on-disk teardown; per-mailbox ACL cleanup is
non-fatal — when it fails the operation still returns 200 with a
warning logged so the admin can correlate.
{ "user": "alice@x.com", "folder": "Old", "namespace": "personal" }Response (200): { "status": "ok" }. 404 on missing folder.
CLI: yarilo-admin backend folder delete <user> <folder> [--namespace NS]
Renames a folder within one namespace (cross-namespace rename is
not supported). The yarilo-acl file is moved across index dirs
and the namespace-wide index entries are rewritten. INBOX cannot
be renamed via backend-api (Dovecot-style move-messages semantics
not implemented here yet).
{
"user": "alice@x.com",
"old_folder": "Drafts",
"new_folder": "OldDrafts",
"namespace": "personal"
}Response (200): { "status": "ok" }. 404 on missing source, 409 on
destination conflict, 400 when old_folder == "INBOX".
CLI: yarilo-admin backend folder rename <user> <old> <new> [--namespace NS]
Removes every message currently flagged \Deleted from a folder
(matches the IMAP EXPUNGE semantic). When uids is set, only
those specific UIDs are considered (matches UID EXPUNGE / UIDPLUS).
Returns the expunged UID list plus the count.
{
"user": "alice@x.com",
"folder": "Trash",
"namespace": "personal",
"uids": [42, 43]
}Response (200):
{ "status": "ok", "expunged": [42, 43], "count": 2 }404 on missing folder. Per-message removal errors are logged at
Warn and the operation continues with the next message — partial
expunge is surfaced via the returned expunged list (count may be
smaller than requested).
CLI: yarilo-admin backend folder expunge <user> <folder> [--namespace NS] [--uids 1,2,3]
Returns what backend-api can resolve locally — username, the
template-resolved home directory, every configured namespace and
whether its on-disk root exists — plus the userdb block when
backend_api.auth_master_addr is configured (Phase AUTH-1).
The userdb block is rendered as a nested object so the local view
is never mixed with the auth-side view; userdb_status carries the
terminal state of the call ("ok" / "not_found" / "error").
{
"username": "alice@x.com",
"home": "/var/mail/vhosts/x.com/alice",
"namespaces": [
{
"name": "personal",
"type": "personal",
"prefix": "",
"home": "/var/mail/vhosts/x.com/alice",
"location": "",
"exists": true
}
],
"userdb_status": "ok",
"userdb": {
"uid": 1001,
"gid": 1001,
"home": "/var/mail/vhosts/x.com/alice",
"mail_location": "maildir:~/Maildir",
"groups": ["staff", "mail"],
"quota_rule": ["*:storage=5G"],
"allow_nets": ["10.0.0.0/8"],
"extra": { "tier": "gold" }
}
}When auth_master_addr is unset, the userdb and userdb_status
keys are absent — admin tools that only care about the local view
get the pre-AUTH-1 response shape unchanged. When the master-protocol
call fails (auth down, network), the response still returns 200 with
userdb_status: "error" and the userdb key set to null, so admin
tooling that values the local view is not blocked by auth-side
flakiness.
CLI: yarilo-admin backend user info <user>
Enumerates every username the yarilo-auth userdb backend can
surface. Thin wrapper over pkg/authclient's IterateUsers; the
response is a sorted username array.
{ "users": ["alice@x.com", "bob@x.com", "carol@x.com"] }Returns 503 when backend_api.auth_master_addr is unset (no userdb
to enumerate); returns 502 when the master-protocol call fails
(reason text in the JSON error field).
CLI: yarilo-admin backend user iterate
Walks every folder in every implemented namespace and reports per-folder message + byte totals plus the rollups. Suitable for ad-hoc capacity inspection before QUOTA-1 ships.
{
"user": "alice@x.com",
"folders": [
{ "namespace": "personal", "folder": "INBOX", "messages": 7, "size_bytes": 1234567 }
],
"total_messages": 7,
"total_size_bytes": 1234567
}CLI: yarilo-admin backend user usage <user>
Walks an existing folder's fileindex and returns every record.
Use the optional limit field to cap the response size.
// request
{ "user": "alice@x.com", "folder": "INBOX", "namespace": "personal", "limit": 100 }
// response
{
"folder": "INBOX",
"folder_guid": "ab12...ef",
"uid_validity": 1747000000,
"next_uid": 42,
"highest_modseq": 19,
"truncated": false,
"records": [
{ "uid": 1, "filename": "1747000000.M...", "flags": ["\\Seen"], "modseq": 5, "size": 1234, "vsize": 1234 }
]
}CLI: yarilo-admin backend index dump <user> <folder> [--limit N]
Regenerates the fileindex for one folder from the on-disk storage
(driver-specific Scan). The new index preserves every UID that
the old index already knew for the same filename and assigns fresh
UIDs (from the current next_uid) to filenames the index has not
seen — so client UID caches stay valid for everything they could
already see.
Driver support:
| Driver | Behaviour |
|---|---|
maildir |
Walks cur/ + new/, parses flags + size from filename. Flags from disk win over the previous index (the filename is the source of truth for maildir). |
dbox |
Walks u.<seq> files, reads GUID + size + Received date from the per-file trailer. Flags are left empty in the scan — the rebuild keeps prior index flags. |
mdbox |
Returns 501 Not Implemented with a pointer to Phase MDBOX-PROD-READY (see TODO.md). |
// request
{ "user": "alice@x.com", "folder": "INBOX", "namespace": "personal" }
// response
{
"folder": "INBOX",
"folder_guid": "ab12...ef",
"scanned": 42,
"uids_preserved": 40,
"uids_assigned": 2,
"orphans_dropped": 0,
"duration_ms": 37
}Optional "reset_uids": true is rejected with 501 today —
nuking UIDs forces every client to full resync via UIDVALIDITY
bump, so the v1 path is DELETE + CREATE via IMAP. Will land
once the design for UIDVALIDITY semantics is locked.
The endpoint takes the cross-process mailbox lock
(locks.MailboxKey) for the whole rebuild so concurrent
IMAP writers cannot race the snapshot.
CLI: yarilo-admin backend index rebuild <user> <folder> [--namespace NS]
Compacts the .index.log overlay into the base .index file.
No semantic change — records, UIDs, modseq stay identical; only
the on-disk layout shrinks. Fast no-op when the log already only
contains its header.
// request
{ "user": "alice@x.com", "folder": "INBOX", "namespace": "personal" }
// response
{ "folder": "INBOX", "duration_ms": 4 }CLI: yarilo-admin backend index optimize <user> <folder> [--namespace NS]
Per-user IMAP SUBSCRIBE state. Reuses
internal/userstate/subs.Store — same on-disk format (sorted folder
names, tmp+rename atomicity) and the same locks.SubscriptionsKey
as IMAP, so concurrent sessions see admin writes immediately.
| Endpoint | Request | Response |
|---|---|---|
POST /api/backend/subscriptions/list |
{user, namespace?} |
{"subscriptions": [...]} |
POST /api/backend/subscriptions/add |
{user, folder, namespace?} |
{"status": "ok"} |
POST /api/backend/subscriptions/remove |
{user, folder, namespace?} |
{"status": "ok"} |
CLI: yarilo-admin backend subscriptions {list|add|remove} <user> [<folder>] [--namespace NS]
Per-user RFC 6154 special-use overrides. Reuses
internal/userstate/specialuse.Store — same on-disk format and
the same lock key as IMAP CREATE (USE ...).
Only the personal namespace carries special-use — RFC 6154
\Sent / \Drafts / etc. do not extend to shared or public.
| Endpoint | Request | Response |
|---|---|---|
POST /api/backend/specialuse/list |
{user} |
{"overrides": {...}, "defaults": {...}} |
POST /api/backend/specialuse/get |
{user, folder} |
{"folder", "attr", "source": "override"|"default"|"none"} |
POST /api/backend/specialuse/set |
{user, folder, attr} |
{"status": "ok"} |
POST /api/backend/specialuse/delete |
{user, folder} |
{"status": "ok"} |
CLI: yarilo-admin backend specialuse {list|get|set|delete} <user> [<folder>] [<attr>]
RFC 5464 METADATA admin surface backed by the configured metadata
dict (same one IMAP GETMETADATA / SETMETADATA reads/writes). Keys
follow the GUID-namespaced layout from pkg/mailbox/attribute.go,
so admin writes are visible to the next IMAP round-trip.
Request envelope (every metadata endpoint accepts it):
{
"user": "alice@x.com",
"folder": "INBOX",
"namespace": "personal",
"scope": "private",
"entry": "/private/comment",
"value": "<base64>",
"as_user": "alice@x.com"
}- Empty
foldertargets server scope (vendor-prefixed under INBOX's GUID). scopeisprivateorsharedforlist;get/set/deletederive it from the leading/private/or/shared/inentry.as_usermatters for shared/public folders under/private/scope where each user has their own slice; defaults touser.
| Endpoint | Notes |
|---|---|
POST /api/backend/metadata/list |
Iterates every entry under the chosen scope; values base64-encoded. |
POST /api/backend/metadata/get |
Returns {found, value} for one entry. |
POST /api/backend/metadata/set |
value is base64. Wraps a single dict transaction. |
POST /api/backend/metadata/delete |
Unset one entry under one dict transaction. |
CLI: yarilo-admin backend metadata {list|get|set|delete} <user> [<folder>] --entry /private/<name> [...]
Active-session listing. Data source is yarilo-anvil — backend-api
dials it per request, runs WHO, then closes.
// request (all fields optional)
{ "service": "imap", "user": "alice@x.com", "group_by": "user" }
// response (default group_by="user")
{
"total": 2,
"groups": [
{
"user": "alice@example.com",
"total": 1,
"sessions": [
{ "id": "s1", "user": "alice@example.com", "ip": "1.1.1.1", "service": "imap", "connected_at": "2026-05-31T15:00:00Z" }
]
}
]
}
// response when group_by="none"
{ "total": 2, "sessions": [ ... flat list ... ] }Filters: service=imap|pop3|submission|lmtp and user=<exact>.
What "active" means: entries register on login-pod CONNECT
and clear on DISCONNECT. Caveats — see TODO.md:
- LMTP does not go through anvil; LMTP deliveries are not listed.
- Stale entries can survive a login-pod crash (no TTL / heartbeat yet).
- Per-folder grouping (currently-SELECTed folder) is not tracked — session binaries do not push folder state into anvil yet.
Returns 501 Not Implemented when anvil_service.listen is empty.
CLI: yarilo-admin backend who [--protocol IMAP] [--user U] [--group-by user|none]
Aggregated counts. Same filters as /who plus an optional
breakdown dimension.
// request
{ "service": "imap", "user": "alice@x.com", "by": "" }
// response
{ "total": 1, "service": "imap", "user": "alice@x.com" }
// request — breakdown by protocol
{ "by": "protocol" }
// response
{
"total": 5,
"by_protocol": { "imap": 3, "pop3": 1, "submission": 1 }
}
// request — breakdown by user
{ "by": "user" }
// response
{
"total": 5,
"by_user": { "alice@x.com": 2, "bob@x.com": 3 }
}CLI:
yarilo-admin backend who count # global total
yarilo-admin backend who count imap # total for protocol
yarilo-admin backend who count --user alice@x.com # total for user
yarilo-admin backend who count --by protocol # breakdown by protocol
yarilo-admin backend who count --by user # breakdown by user
Used in the op field of every endpoint that mutates or reads
per-user state. All fields optional; an empty op is equivalent
to no op field at all.
{
"username": "alice@example.com",
"home_dir": "/var/mail/vhosts/example.com/alice",
"expire_secs": 3600
}| Phase | Adds |
|---|---|
| OPS-BACKEND-API (v1.23) | dict surface; yarilo-admin backend dict CLI as HTTP client |
| BACKEND-API-EASY (v1.24, this) | folder / user / index / subscriptions / specialuse / metadata read-write surfaces against the existing storage + dict backends |
| ACL-1 (next) | POST /api/backend/acl/{get,set,delete,my-rights,list-rights,debug} + yarilo-admin backend acl CLI |
| QUOTA-1 | POST /api/backend/quota/{show,set,unset,recalc} |
| BACKEND-API-AUTH | user info enriched with uid/gid/userdb fields via yarilo-auth RPC |
| BACKEND-API-SESSIONS | who / kick via session-binary RPC; anvil penalties/connections |
| BACKEND-API-WRITE | folder create/delete/rename/expunge, index rebuild/optimize, folder repair once driver-specific resync ships |