The core of stage 8, pulled forward: a versioned OpenAPI contract, admin listeners with identity and roles, an audit log, and local delivery. Jobs, plan/apply, and the admin views follow in sub-packages 8a through 8c.
Contract and code generation
- Contract:
api/admin/openapi.yaml, path/admin/v1. - Generated with
oapi-codegenv2.8.0 (models,net/httpserver interface, client) intointernal/adminapi/api.gen.go; configuration inapi/admin/oapi-codegen.yaml. Hand-written client helpers for the socket and for mTLS live ininternal/adminapi/client.go. make generateregenerates the code,make check-generatefails if the checked-in code does not match the contract. The generator runs in module mode (network access); the runtime librarygithub.com/oapi-codegen/runtimeis vendored.
Listeners and identity
Both listeners are off unless configured, and both are separate from the
mail and metrics ports. Settings that have no effect are rejected:
socket and socket_roles only together, addr only with tls_cert,
tls_key, client_ca and at least one entry in clients, TLS settings
not without addr.
| Listener | Identity | Roles |
|---|---|---|
Unix socket (admin.socket, mode 0600) |
peer UID via SO_PEERCRED; only the UID of the server process is allowed |
admin.socket_roles |
TCP with mTLS (admin.addr) |
verified client certificate; subject CN or DNS/URI SAN (case-insensitive) | admin.clients[].roles |
The socket is reachable only inside the pod, so it is the same trust
boundary as extprograms. Being on the network is never an authorization
by itself. For mTLS, admin.tls_cert, admin.tls_key and
admin.client_ca are re-read when the files change (checked at most every
10 s). A rotation by cert-manager therefore takes effect without a
restart. A broken new version is logged, and until it is fixed the old one
stays in use. Client certificates need extKeyUsage clientAuth.
Roles: diagnose (version, server status and read-only diagnosis, delayed JMAP submissions), deliver (delivery with quota enforcement),
deliver:noenforce (additionally required for enforce_quota=false),
import (account import), trace (protocol trace of an account, see
below), provision (create, rename and subscribe mailboxes; change
ACLs), repair (maintenance jobs that repair; retry learning jobs;
flush the auth cache, see below),
delete (permanently delete a mailbox, messages and an account),
content (read message content: search, headers, export, Sieve scripts)
and support (flags, copy/move, change and activate Sieve scripts, cancel delayed JMAP submissions) and
export (export whole accounts as an archive; the restore runs through
import). content is deliberately separate from diagnose: diagnosis
sees metadata, but no content.
admin:
socket: /run/shardpost/admin.sock
socket_roles: [diagnose, deliver, "deliver:noenforce"]
addr: ":9443" # optional
tls_cert: /admin-tls/tls.crt
tls_key: /admin-tls/tls.key
client_ca: /admin-tls/ca.crt
clients:
- identity: ops.shardpost.internal
roles: [diagnose]
- identity: support.example.org
roles: [diagnose, trace]
accounts: ["*@example.org"] # optional: account scope
- identity: fleet.shardpost.internal
roles: [diagnose, repair]
all_accounts: true # optional: scope of all accounts
clients[].accounts limits the accounts an mTLS client may address
(path.Match patterns against the lowercased account; empty = all). The
scope applies to every account-related operation: diagnosis, trace,
import, delivery (against the resolved account), for account show also
to the canonical account of an alias, and for acl explain to the
principal being explained. Clients with a scope may address accounts only
in lowercase (RADOS namespaces are case-sensitive). Outside the scope the
API answers 403 forbidden.
Callers on the Unix socket are not limited.
clients[].all_accounts grants the scope of all accounts: listing the
account directory (GET /admin/v1/accounts) and planning, applying and
cancelling batches over every account (POST /admin/v1/plans, see
Batches over all accounts). Each operation
still needs its role. A client without the flag cannot do either, even
without an account scope; the flag and accounts exclude each other.
Socket callers always have it.
Environment: ADMIN_SOCKET, ADMIN_ADDR, ADMIN_TLS_CERT, ADMIN_TLS_KEY,
ADMIN_CLIENT_CA; roles and clients only via YAML.
Every request produces a log line admin request (event=admin_request)
with principal, transport, method, path, status, error code, duration and
the operation's domain fields (for a delivery: recipient, sender, account,
options, number of stored copies), but without message content or
secrets. Rejected mTLS clients appear with the certificate identity they
presented, rejected socket callers with their UID.
Metric: shardpost_admin_requests_total{operation,code}.
Local delivery
POST /admin/v1/deliveries delivers a message (message/rfc822) to a
recipient, like dovecot-lda. Recipient resolution works as it does for
LMTP (aliases, canonical account, userdb attributes). Sieve runs by
default, including redirect, vacation and notify. LMTP and LDA share the
same delivery path (internal/delivery). The server prepends
Received: from <transport> <principal> by <lmtp_hostname> with LDA to
the message, and above it Return-Path: <from> and
Delivered-To: <recipient> (removing a Return-Path the message brought
along; server.lmtp_return_path, server.lmtp_delivered_to, see
Sieve during delivery). A Sieve
ereject answers 422 (rejected); a reject sends a rejection
message to the sender and answers 200 with nothing stored and
disposition: rejected, unless the sender is the null sender (then the
message is discarded, disposition: discarded), the mail is automatic or
list mail, no relay is configured, the relay refuses the rejection
message or sieve_reject_mode=protocol is set (then 422 as well; see
Sieve during delivery). Recipient and sender must not contain control characters or
spaces and no further angle brackets. Resolution (10 s) and delivery
(5 min) run decoupled from the HTTP request: if the client aborts, the
delivery still runs to completion.
| Parameter | Meaning |
|---|---|
recipient |
recipient (address or account), required |
from |
envelope sender, empty = null sender |
mailbox |
straight into this existing mailbox, without Sieve |
sieve=false |
without the user script, into INBOX |
enforce_quota=false |
store even above the limit (usage is still counted); role deliver:noenforce |
The result names the account, the stored copies (stored: mailbox,
UID, GUID, size) and the disposition: stored, discarded (Sieve
discarded the message, or rejected mail from the null sender) or
rejected (Sieve rejected it and a rejection message went to the
sender); in the last two cases stored is empty.
Error codes: bad_request 400, unauthenticated 401, forbidden 403,
no_such_user/no_such_mailbox 404, too_large 413, rejected 422
(Sieve ereject, or a reject that sends no rejection message), over_quota 507, limit_exceeded 409 (an account limit, see
configuration; every API that
changes an account answers it the same way), temporary 503, internal 500.
shardpostctl
In the image at /usr/local/bin/shardpostctl. The default is the socket
/run/shardpost/admin.sock (-socket, SHARDPOST_ADMIN_SOCKET). With
-url https://host:port -cert -key -ca (or SHARDPOST_ADMIN_*) it goes
over mTLS.
shardpostctl version
shardpostctl deliver -d user@example.org -f postmaster@example.org < mail.eml
shardpostctl deliver -d user@example.org --no-enforce-quota --json < warning.eml
Exit codes of deliver (sysexits): 0 ok, 64 usage, 67 unknown recipient
or mailbox, 69 rejected or too large, 70 unexpected response, 73 over
quota, 75 temporary (try again), 77 not permitted.
Read-only diagnosis (stage 8a)
All operations only read (role diagnose). A test runs every one of them
against a store that fails on every write operation.
shardpostctl server status # version, pod, start, config fingerprint, store, placement
shardpostctl server list # registrations: role, version, system pool, running
shardpostctl account show user@example.org # Nauthilus resolution, store, quota, import lock, trace
shardpostctl mailbox list [-limit 100] [-after NAME] [-all] user@example.org
shardpostctl mailbox show user@example.org INBOX # name or mailbox ID
shardpostctl acl list user@example.org Shared
shardpostctl acl explain user@example.org Shared kollege@example.org
shardpostctl quota show user@example.org
shardpostctl shared list kollege@example.org
Every command supports --json; flags may also follow the arguments.
Exit codes as for deliver: 67 unknown account/mailbox, 64 usage (an
alias instead of the canonical account counts as usage too), 77 missing
role or scope.
| API | Contents | Cost |
|---|---|---|
GET /admin/v1/server |
version, instance (pod), start time, config_fingerprint, store ping, placement (where the store's own namespaces live as this server's last placement check found it: state none, native, migrated or unknown, and the system_pool by id and name) |
one ping |
GET /admin/v1/servers |
the process registrations (servers/<instance> in the admin store): role (serve, runner since 0.52.0, push, scrub, backfill, sweep; none before 0.47.0), version, capabilities, system_pool, started_at, last heartbeat (seen), stopped_at after an orderly end, fresh (seen within three minutes and not stopped); for a push worker partitions (the push partitions it holds) and watched (the accounts it watches), since 0.52.0; for a server workers (whether it runs the background workers, workers.run), since 0.52.0 |
a listing of the admin namespace unless admin.kv_index: read |
GET /admin/v1/accounts/{account} |
Nauthilus lookup (canonical, alias with the canonical account, unknown, unavailable), store present, number of mailboxes, quota, import lock, trace |
registry, quota |
GET …/mailboxes?after=&limit=&name= |
mailboxes in name order: ID, UIDVALIDITY, UIDNEXT, HIGHESTMODSEQ, shard size, subscription, special-use, number of ACL entries; next for the following page |
one registry excerpt, one header per mailbox |
GET …/mailboxes/{id} |
additionally messages, unseen, marked deleted, size, shards, start of the log (QRESYNC horizon), keywords | reads all index shards of the mailbox |
GET …/mailboxes/{id}/acl |
explicit entries and ACL generation (the owner implicitly has all rights) | registry |
GET …/mailboxes/{id}/acl/explain?principal= |
the principal's rights (the same function IMAP uses), the pointer in their registry and findings in plain text | two registries |
GET …/quota |
usage and limit (0 = unlimited) | quota counter |
GET …/shared |
the account's share pointers (the shared namespace) | registry |
account show also accepts aliases and then names the canonical account;
the store fields describe the (usually empty) namespace under the name
that was given. All other operations require the canonical account,
because the data lives underneath it. Mailboxes are addressed in the URL
by their stable ID. shardpostctl resolves names itself (?name=, a
single registry key); resolving an ID reads the account's entire
registry. A page ends after limit registry entries have been read,
including unreadable ones; a broken entry neither shifts nor ends the
list.
acl explain answers "Why doesn't X see the share?" with findings:
- An alias instead of the canonical account; an ACL entry under the alias never takes effect.
- No rights, so the mailbox does not exist for the principal (
NONEXISTENT). - Rights without
l: not listed, but addressable by name. - Rights only through
anyone: no pointers are written for that, so LIST does not show the mailbox. - A missing pointer (LIST does not show the mailbox; a SETACL for the
principal writes it) or a pointer without the
lright (LIST hides it, the pointer stays until the mailbox is deleted or a SETACL withdraws it). The generation of the pointer is informational only: SETACLs for other identifiers raise the mailbox's generation without touching it.
config_fingerprint is a SHA-256 over the effective configuration without
the values of the fields marked secret:"true"; a test requires this
marker for every new field whose name looks like a password, secret or
token. Identical fingerprints mean the pods have identical settings.
Job status comes from the maintenance jobs (stage 8c, below).
Provisioning and permissions (stage 8b)
Role provision. The Admin API does not set quota limits: LDAP
(rnsMSQuota) is authoritative, taken over at login (decision of
2026-09-19).
shardpostctl mailbox create [-special-use '\Archive'] user@example.org "Projekte/2026"
shardpostctl mailbox rename [-if-match '"m-…"'] user@example.org "Projekte/2026" "Archiv/2026"
shardpostctl mailbox subscribe|unsubscribe user@example.org "Archiv/2026"
shardpostctl acl set [-mode replace|add|remove] [-if-match '"acl-3"'] user@example.org Shared kollege@example.org lr
shardpostctl acl delete user@example.org Shared kollege@example.org
| API | Semantics |
|---|---|
POST …/mailboxes {name, special_use} |
201 created. If the mailbox already exists with the same special-use: 200 (a retry after a lost response), otherwise 409. Beyond limits.mailboxes or limits.mailbox_names: 409 limit_exceeded. Special-use is stored in the standard spelling, as with IMAP CREATE (\All, \Archive, \Drafts, \Flagged, \Important, \Junk, \Sent, \Trash); other attributes are rejected by the API. |
PATCH …/mailboxes/{id} {name?, subscribed?} |
Rename including child mailboxes (not INBOX; an occupied target gives 409), set the subscription; the same name is a no-op. |
PUT …/mailboxes/{id}/acl/{identifier} {rights, mode} |
replace sets the rights, add/remove change them relative to the current ones; the server computes and writes in one versioned step, so concurrent changes are not lost. |
DELETE …/mailboxes/{id}/acl/{identifier} |
Remove the entry. |
- Versions:
GET …/mailboxes/{id}and changes return the mailbox ETag ("m-…"),GET …/acland ACL changes the ACL ETag ("acl-<generation>").If-Matchmakes a change conditional; if the version does not match, the API answers412 precondition_failed. The ACL version is checked in the same write step. For the mailbox (rename, subscription) it is checked immediately before the change; the mailbox ETag also covers the ACL, so an ACL change invalidates it as well. A malformedIf-Matchgives 400. - Identifiers are canonical accounts (Nauthilus) or
anyone: aliases are rejected together with the canonical name, unknown accounts with 404, the owner with 400. An identifier that is not UTF-8 or holds a/or a NUL is refused with 400, by the store as well: the recipient's share pointer is keyed<owner>/<mailbox>. IMAPSETACLandDELETEACLanswerBADfor such an identifier. The recipient's share pointer follows as it does for IMAP SETACL (there is none foranyone); a change with no effect writes none. If only the pointer write fails, the change still stands (the ACL is authoritative; a warning is logged,acl explainshows the missing pointer, and areplacewith the same rights writes it afterwards). - Share notifications: a change that alters the rights of an
account leaves a notification in that account, as an IMAP
SETACLdoes (RFC 9670 3; see JMAP: Share notifications). ItschangedByis namedAdministrator, with no address and no principal: the admin client is nobody the account could look up. A change ofanyoneleaves none. The notification is best effort and never fails the request. - Names are UTF-8, here and in the store. IMAP clients that enabled
UTF8=ACCEPTorIMAP4rev2see the same names; older clients see and send them in modified UTF-7 (EntwürfeisEntw&APw-rfefor them), which the IMAP server converts in both directions. Both kinds of client address the same mailbox. Mailboxes that such a client created under 0.35.2 or earlier are stored under their modified UTF-7 name; see Upgrade: modified UTF-7 names. - Idempotence follows from the semantics: absolute values, mailboxes
addressed by their stable ID. A repeated request after a lost response
returns the same state. Send relative ACL changes with
If-Matchfor that reason. - Running IMAP sessions see the changes like those of another client: rights apply from the next command, new or renamed mailboxes at the next LIST.
- Exit codes of
shardpostctl: 65 on a conflict or a stale version (409/412), otherwise as for diagnosis.
Maintenance jobs (stage 8c)
Long-running and dangerous changes run as durable jobs. Every server with
workers.run (the default) runs one runner, and a deployment with job
runner pods (shardpost runner, 0.52.0) runs them there instead; jobs
live in the shared admin storage (namespace ~admin in the index pool,
one object per job and per plan) and survive restarts. The jobs API
(submit, list, cancel, plans) is a server's in every case.
shardpostctl check -wait user@example.org # check without repair (role diagnose)
shardpostctl plan scrub -classes refs,orphans user@example.org
shardpostctl plan quota_recalc user@example.org
shardpostctl plan acl_reconcile user@example.org
shardpostctl plan fts_rebuild user@example.org
shardpostctl plan fts_dict_convert user@example.org # legacy: layout 2 indexes from before 0.35.0
shardpostctl plan -layout 3 fts_layout user@example.org # full-text layout 3 (needs the cluster mark)
shardpostctl plan mailbox_name_convert user@example.org # modified UTF-7 names of 0.35.2 and earlier
shardpostctl plan attachment_backfill user@example.org # JMAP hasAttachment of older messages
shardpostctl plan -mailbox "Archiv/2019" mailbox_delete user@example.org
shardpostctl plan purge user@example.org
shardpostctl apply -wait <plan-id>
shardpostctl job list -account user@example.org
shardpostctl job show|wait|cancel <job-id>
shardpostctl plan -all index_check # a batch over every account
Plan → apply. plan computes a dry run and shows the target set and
the effect (findings, counter difference, missing pointers, affected
mailboxes, messages and bytes). apply starts the plan as a job, once and
before it expires (jobs.plan_ttl_seconds). When a mailbox is deleted and
on purge, the job compares the state (IDs, UIDVALIDITY, UIDNEXT,
HIGHESTMODSEQ, and on purge the Sieve scripts as well) with the plan
before the first write; if it differs, the job ends without a change and
reports "the account changed since the plan". A share reconcile applies
only the repairs that were planned and are still needed.
| Kind | Role | Account lock | Effect |
|---|---|---|---|
index_check (no plan) |
diagnose |
no | scrubber without repair, findings as the result |
scrub |
repair |
no | scrubber with the chosen classes: deliveries, moves, retired, uncovered, entries, refs, orphans, unknown, quota (all of them if none are given); with scrub.orphan_source: intents, orphans covers the blobs of open intents and unknown the objects of abandoned creations, unfinished deletions, the private \Seen marks of public folders that are gone and the METADATA of deleted mailboxes (see storage); the report carries OrphanSource, IntentsOpen, IntentsDropped, DeletionsFinished, CreationsUndone, StaleMetadata and, in shadow mode, Shadow |
quota_recalc |
repair |
yes, unless online | recount the usage from the index and set it. params (since 0.52.0): online (true: without the account lock, needs the mark quota/online — else 409: the writers capture every change of the usage while the run counts, and one compare-and-swap switches the counters; omitted: online with the mark and quota.online_recount: auto). The plan records online and shows a dry count (approximate while the account is written to); the result carries counted, stored, differs, mode, and for an online run barrier_seconds, listed_mailboxes, captures, capture_keys, capture_entries, switch_rounds, hold_ms, dead_intents, result (ok; a busy or lost run is planned again, its reason in reason and the intent waited for in oldest_key). See Quota: recounting the usage |
acl_reconcile |
repair |
no | write missing pointers for recipients, remove own pointers that carry no rights |
fts_rebuild |
repair |
yes, unless online | delete the full-text index (with the header store) and rebuild it from all messages; when it completes it sets the index version marker that IMAP substring search needs to narrow fully, see configuration. A layout 3 index gets the identifier filter and the text preparation the configuration asks for while the mark of the dictionary filter is set (since 0.50.0): the way to change fts.attached_headers for an existing account. params (since 0.51.0): online (true: without the account lock, needs the mark of online runs — else 409 — and a layout 3 index — else 400: the target is built from the messages beside the current generation, the id map stays, one compare-and-swap switches, the version marker of the target is set and an unfinished offline rebuild healed — not when skip_unreadable skipped a message; omitted: online with the mark and fts.online_build: auto), skip_unreadable (online: skip a live message whose blob cannot be read instead of failing), drop_orphan_ids (online, default true: drop ids without blob and mailbox entry, false only counts them). The plan records online; the result of an online run is the report of fts_layout with mode reindex |
fts_dict_convert |
repair |
no | write the substring dictionary of the full-text index again from the posting lists and delete the objects of the layout before 0.35.0; reads no message, may be repeated, stops when an import or another job locks the account. A legacy kind since 0.46.0: it matters only for layout 2 indexes written before 0.35.0. Planning it for a layout 3 account is refused with conflict (409); a run that finds layout 3 or a layout conversion under way, at its start or later, stops writing (no further batch, no deletion of old objects), succeeds and reports skipped |
fts_layout |
repair |
yes, unless online | convert the full-text index between layout 2 and layout 3 or reshard a layout 3 index (see storage); params: layout (2 or 3; omitted: the account's), shards (forced object counts of a layout 3 target, the same for postings, dictionary and header texts, 1–4096) or shape ("s/d/h", each count of its own, 1–4096; since 0.49.0; both together: 400), both ignoring the caps fts.shards_max and fts.dict_shards_max — omitted, the counts follow the key counts, fts.shard_target_keys and fts.shards_max for postings and header texts, fts.dict_target_keys and fts.dict_shards_max for the dictionary — , dict_filter (since 0.50.0: ids or none, the identifier filter of the layout 3 target; omitted, as fts.dict_filter and the mark of the filter say; the dictionary is derived from the postings either way, so both directions are exact; not with layout: 2, and ids on a layout 2 index without layout: 3 is refused with 400 — none is what layout 2 has), due_only (no change while the counts ask for the shape the index has and its identifier filter is the one asked for; how the fts_due worker plans it). layout: 3 needs the cluster mark (POST /admin/v1/fts/layout3/enable), dict_filter: ids the mark of the filter (POST /admin/v1/fts/dictfilter/enable, else 409 conflict), read when the job is planned and when it starts. The target keeps the text preparation of the source (attached_headers): changing it takes fts_rebuild. The plan shows the layout record and, for layout 3, the shape the counts ask for (with capped, the kinds a cap cut); the result names source and target generations and counts terms, dictionary keys, header texts and documents, and since 0.49.0 the writes of the copy (writes) and the most keys its write buffer held (buffer_keys) (noop when nothing changed). A job that fails or is cancelled clears its unfinished conversion itself. online (since 0.51.0; true needs the mark of online runs, else 409, and a layout 3 index, else 400; not with layout: 2; omitted: online with the mark and fts.online_build: auto, recorded in the plan) runs it without the account lock (storage); its result adds online, mode (copy), rounds, caught_up, hold_ms, indexed, dead_ids, and ends without a change and without an error as deferred busy (another run alive, the account locked), window or window_too_small (a run the fts_due worker or a batch planned), mark_off (the mark of online runs went after the plan), or unsupported (no layout 3 index) |
mailbox_name_convert |
repair |
yes | rename mailboxes stored under a modified UTF-7 name to their UTF-8 name (children move with the parent) and rewrite subscriptions without a mailbox the same way; a rename whose target exists is a conflict (with its children) and is skipped; names that are not valid UTF-8 are reported (see Upgrade: modified UTF-7 names) |
attachment_backfill |
repair |
no | read every message whose index record does not carry hasAttachment (stored before the record had the field) and remember the answer in the JMAP locator (a:<guid>, see JMAP: hasAttachment); changes no message and no index record, may be repeated and then reads only what is left, stops when an import or another job locks the account; a mailbox that cannot be listed and a message that cannot be read are skipped and counted (skipped_mailboxes, failed, the first errors in errors); the plan counts such messages and the ones already remembered |
mailbox_delete |
delete |
yes, unless online | delete the mailbox including its child mailboxes (not INBOX). params: mailbox_id, online (since 0.52.0: true locks only the mailboxes it removes instead of the account, needs the mark mailbox/lock — else 409; omitted: with the mark and jobs.mailbox_delete_online: auto). Online the job locks the targets, waits jobs.settle_seconds and the barrier (jobs.mailbox_lock_barrier_seconds), checks the plan (a delivery into a target since the plan is drift, the mail stays; flag changes are not), unregisters the targets by id and frees the account before it releases blobs and shards; a target another job locks, an import or a repairing scrub, or a MOVE into or out of a target that still stands, postpones the job (busy). Writes into a locked mailbox are refused (IMAP NO [UNAVAILABLE], JMAP forbidden/invalidProperties), a fileinto into one goes to INBOX, and only sessions that have one selected are closed. The result carries mode, the targets with messages and bytes, lock_seconds, barrier_seconds and result |
expunge |
delete |
no | permanently remove messages of a mailbox: uids or deleted: true (all with \Deleted) |
purge |
delete |
yes | withdraw shares in both directions, delete learning jobs and all objects of the account (listed with purge.listing: true, the default; named by the data model with false, see storage), remove the directory entry, delete the account's JMAP push and submission records |
- Account lock (maintenance mode, decision of 2026-09-19): the job
holds the lock the import uses as well. While it is held, IMAP and POP3
answer "Mailbox maintenance in progress", LMTP answers
451 4.2.1. IMAP/POP3 sessions that are already open check the lock at most every 5 seconds at the start of a command and then disconnect withBYE [UNAVAILABLE]or-ERR [SYS/TEMP]respectively. After taking the lock, the job waitsjobs.settle_seconds(default 10) before it writes. A session that sends no command during that time (an IDLE client, for example) writes nothing either. Other account jobs, imports and the periodic scrub stay out. If the account is already locked when the job starts (an import, another job), the job waits inrunningwith the phase "waiting for the account lock" and retries once per lease period, at most untiljobs.plan_ttl_secondsafter it was created. After that it endsfailed. If a job loses the lock, it ends with "maintenance lock lost". - Ownership: a job belongs to the server that took it over (a lease
with a generation,
jobs.lease_seconds). Before every write step the job checks its lease (fencing). If the server dies, another one takes over once the lease has expired and continues from the last checkpoint. On an orderly shutdown the server gives up running jobs immediately.fts_rebuildandfts_dict_converthave no checkpoint and start over after a takeover;attachment_backfillhas none either, but keeps what it wrote and reads only what is left. Leases compare the clocks of different servers, so those clocks have to be kept in sync with NTP. A deviation well belowjobs.lease_secondsis harmless. - Cancelling (
job cancel): a waiting job ends immediately, a running one at the next safe point. What has already been done is not rolled back. - States:
queued,running,succeeded,partially_failed,failed,cancel_requested,cancelled.apply -waitandjob waitexit with 0 (success), 1 (partial failure) or 69 (failed, cancelled). - Idempotence:
apply -idempotency-key K, or the headerIdempotency-Key, returns the same job on a retry (200). With a different request the key is rejected (409). Keys are per caller. A key expires together with its job,jobs.retention_daysafter the job ended; after that the same key starts a new job. Earlier versions kept keys forever; keys from before the update expire onceshardpost index backfill kvran (see Operations). Withadmin.kv_index: offkeys do not expire. - Scope: plans that change other accounts (share reconcile, purge)
need those accounts in the client's scope, both when the plan is created
and again at
apply. Clients with a scope see jobs only when an account is given. Jobs and plans outside their scope do not exist for them (404). - Without the job system (
jobs.enabled: false) the job endpoints answer 503temporary. - purge removes only the data in the store. The identity (LDAP) belongs to its owner. If the account logs in again afterwards, ShardPost creates an empty INBOX.
- Purge phases (checkpointed, a takeover continues with the next
one): 1 withdraw the account's grants to others, 2 withdraw the grants
others gave it (public folders included), 3 delete its learning jobs,
4 mark the account
purgingin the account directory (still under the account lock) and delete every object of its namespaces, the registry last, 5 remove the directory entry. From phase 4 on, while the registry is gone, nothing can create the account again: deliveries are deferred (451 4.2.1), sessions getNO [UNAVAILABLE], and a grant to the account is refused (409 account_purginghere,NO [UNAVAILABLE]for SETACL) before the ACL is written. Only the job's own lock session gets through, so that a takeover can take the account lock again; that leaves an empty registry, which phase 5 deletes once more before it removes the entry. After phase 5 the name is free. See storage. - A purge that ended after phase 4 without finishing (failed,
cancelled) leaves the account
purgingand without registry. Nothing can use the account then, and no exclusive job can take its lock — a second purge included: such a job fails at once with "the account was left purging by an ended purge job; run shardpostctl account repair". Runshardpostctl account repairfirst (below), then plan again. - Finished jobs and plans stay retrievable for
jobs.retention_days(a job from its end, a plan from its expiry), together with the idempotency key of a job. Withadmin.kv_index: read(the default) a new job is picked up at once by a server with free capacity (the runners are woken); otherwise, and as the fallback, withinjobs.poll_seconds. Inreadmode job lists read the hint index of the jobs instead of listing the admin namespace.
Batches over all accounts
shardpostctl plan -all index_check # role diagnose
shardpostctl plan -all -skip-idle scrub # role repair; what the nightly scrub runs
shardpostctl plan -all -classes refs scrub
shardpostctl plan -all fts_rebuild # locks each account in turn
shardpostctl plan -all -online fts_rebuild # without locks, in the reshard window (since 0.51.0)
shardpostctl plan -all attachment_backfill
shardpostctl plan -all -layout 2 fts_layout # every account back to layout 2
shardpostctl apply -wait <plan-id>
POST /admin/v1/plans plans a batch: one kind run on every account of
the account directory. Body: kind (index_check, scrub,
fts_rebuild, attachment_backfill, fts_layout), params (scrub:
classes; fts_layout: layout, shards, shape, dict_filter,
due_only, online; fts_rebuild: online, skip_unreadable,
drop_orphan_ids), skip_idle (scrub only) and anytime (fts_layout,
fts_rebuild: ignore the reshard window, since 0.51.0). An omitted
online is decided by the plan for the whole batch.
-
Reshard window (since 0.51.0): a batch of
fts_layoutorfts_rebuildwithoutanytimestarts an account only insidefts.reshard_window; outside it the job showswaiting for the reshard windowand gives itself back until the next window starts (the attempt does not count), then goes on from its checkpoint. An online run that does not fit into what is left of the window is put off to the next one (laterin the result); one longer than the whole window fails the account (window_too_small). -
Online runs (since 0.51.0): with
onlinethe accounts are not locked; an account another run of which is alive is tried again at the end as a locked one; one without a layout 3 index counts asunsupported, not as failed. The plan and its job have the kindbatchand no account;summarynames the kind, its role, whether it locks the accounts, the parallelism and the directory (shards, backfill marker). It is applied like any plan withPOST /admin/v1/jobs. -
Role and scope: a batch needs the role of its kind and the scope of all accounts (socket callers; mTLS clients with
all_accounts), for planning, applying and cancelling. Reading it needsdiagnose, like any job; clients with an account scope do not see it. -
Precondition: the account directory must carry a completed backfill marker (
shardpost index backfill accounts), otherwise the plan is refused with409 conflict: accounts from before the directory would be missed. -
Walk: the job reads the directory a page at a time in its order (shard, account) and works on
jobs.batch_parallelaccounts at once (scrub.parallelfor a scrub). After each page it stores the cursor<shard>:<account>and the counters as its checkpoint; a server that takes it over redoes at most one page. An entrypurgingwithout registry counts asgone; with a registry it is handled like an active one. The progress shows the directory shard and the accounts looked at (the total is not known up front). -
Account lock: a kind that locks the account (
fts_rebuild) takes the lock per account, keeps it renewed while it works and waitsjobs.settle_secondsbefore it writes, so a batch of it takes at least accounts ×jobs.settle_seconds/jobs.batch_parallel. While it holds an account, that account is in maintenance as with a single job: its open IMAP and POP3 sessions getBYE [UNAVAILABLE]/-ERR [SYS/TEMP]at their next command, new logins and LMTP deliveries are deferred. A batch offts_rebuildtherefore disconnects every account once, one after another; run it in a quiet window. An account an import or another job holds is put aside and tried again after the walk, up to three times a lease period apart (at most 1000 accounts are put aside, the rest fail at once); still locked, it counts as failed. -
Cancel:
job cancelstops the batch between two accounts. -
Result:
accounts(looked at),done,skipped(idle, scrub withskip_idle),gone,failed,findings(accounts whose report had findings:index_check,scrub), the first 50 failed accounts with their error (errors,errors_droppedfor the rest) and the first 50 accounts with findings (finding_accounts). A batch with a failed account endspartially_failed; the per-account reports are not kept (run the kind for one account to see one). -
Skip-if-idle (
skip_idle, scrub only): every batch scrub of all repair classes writes the account's scrub record into its directory entry (shown byaccount directoryandaccount list). A batch withskip_idleleaves an account alone when its last batch scrub was complete and found nothing, is younger thanscrub.interval_days, the account's change counter and registry version are still the recorded ones, the scrub object holds no orphan candidates and no quota count, and no delivery or MOVE intent is open. A skipped account misses the per-account listings, so unreferenced JMAP upload blobs and the\Seenmarks of deleted public folders wait for its next full scrub, at mostscrub.interval_dayslater (until a later stage records intents for them). See storage. -
Export is no batch kind: an export streams one archive per request to the caller, and a batch job has no place to put one archive per account. Export accounts one by one (
shardpostctl export, listing them withaccount list).
shardpost scrub --batch (the nightly CronJob) submits the batch scrub
with skip_idle through the admin store directly, unless a batch scrub
has not ended yet (see Operations).
The account directory
shardpostctl account directory user@example.org # role diagnose
shardpostctl account list -limit 500 # role diagnose, scope of all accounts
shardpostctl account list -all -state purging
shardpostctl account repair user@example.org # role repair
shardpostctl account repair --json user@example.org
GET /admin/v1/accounts/{account}/directory (role diagnose) shows the
account's directory entry (state, creation time, the purge's lock
session, the last batch scrub), whether its registry exists, and the
directory itself: shard count, phase and the completion marker of the
last backfill.
GET /admin/v1/accounts (role diagnose, scope of all accounts) lists
the directory a page at a time: cursor (<shard>:<account>, from
next), limit (1 to 1000, default 100) and state (active,
purging). The order is the directory's (shard, then account), not
alphabetical; it reads the directory, never a pool listing. With state
a page may hold fewer than limit items while next is present (a page
reads at most ten times limit entries). shardpostctl account list
pages with -after, fetches every page with -all and prints JSON lines
with -all --json.
POST /admin/v1/accounts/{account}/directory/repair (role repair)
applies the repair rules to one account:
purging, and no purge job of the recorded lock session still runs (queued,runningorcancel_requested): the entry is removed when the registry is gone (removed) and set back toactivewhen it exists (activated); while the job runs, nothing changes (purge_running).active, older than ten minutes and without registry: the entry is stale and removed (stale_removed). It comes from a purge by an older version, a crash between the registry hook and the registry creation, or a creation whose write failed.
Anything else is none. The answer shows the entry before and after.
Neither command resolves the account through Nauthilus: a purged account
may no longer exist there. The same rules run for every entry in
shardpost index backfill accounts (see
Operations).
Upgrade: modified UTF-7 mailbox names
Up to 0.35.2 the IMAP server stored mailbox names as the client sent
them. A client that had not enabled UTF8=ACCEPT sends non-ASCII names
in modified UTF-7, so a mailbox it created as "Entwürfe" is stored as
Entw&APw-rfe. Since then the server converts names on the wire and
keeps UTF-8 in the store. Such a mailbox keeps its old name, which every
client now sees literally (an older client as Entw&-APw-rfe), and an
older client that opens "Entwürfe" no longer finds it.
After the upgrade, run mailbox_name_convert for the accounts that may
have such mailboxes (all of them, if unsure; the plan of an account
without any is empty):
shardpostctl plan mailbox_name_convert user@example.org
shardpostctl apply -wait <plan-id>
The plan lists every rename (rename, with the mailbox ID), every
subscription without a mailbox that is rewritten (subscription),
every conflict (conflict: the UTF-8 name is taken, or a parent is in
conflict; nothing is renamed for it, resolve it by hand and plan again)
and every stored name that is not valid UTF-8 (invalid_utf8). Only a
name level that is exactly the modified UTF-7 form of another string is
converted; R&D or A&B stay, A&-B becomes A&B. Check the plan:
a name a UTF-8 client really chose in that form would be converted too.
The job locks the account (open sessions are disconnected and
reconnect), applies only the planned changes that are still needed and
can be run again; when a rename fails, the mailboxes below it stay where
they are and the job ends partially_failed. Clients see the renamed
mailboxes as new folders once and synchronise them again.
The typical conflict. Delivery and Sieve name mailboxes in UTF-8.
When a Sieve rule (fileinto :create "Entwürfe"), an autocreate entry
or a JMAP client created Entwürfe and an IMAP client without UTF-8
created Entw&APw-rfe, the account has two mailboxes for one name, and
the plan reports a conflict. Resolve it by hand: move the messages of
one into the other (with an IMAP client that shows both, or
shardpostctl move <account> "Entw&APw-rfe" <uids> "Entwürfe"), delete
the emptied mailbox
(mailbox_delete), and plan again; or rename the old one to a third
name (shardpostctl mailbox rename <account> "Entw&APw-rfe" "Entwürfe (old)")
if both are to stay.
What the job does not rewrite. Only mailbox names and subscriptions
change. Other places that name a mailbox keep the old name: Sieve
scripts (fileinto "Entw&APw-rfe" then files into, or with :create
creates, a mailbox of that literal name again), IMAPSieve and learning
rules in the configuration, client-side filter rules and folder settings
(sent and drafts folders). Check the account's Sieve scripts
(shardpostctl sieve list|show <account>, or ManageSieve) for names with
& after the conversion and change them to the UTF-8 names; clients ask
for their folder settings again or are pointed at the new names.
Support tools (stage 8d)
Tools for support work on individual messages, Sieve scripts and the learning queue.
shardpostctl messages alice@example.org INBOX # metadata (diagnose)
shardpostctl search -subject Rechnung -since 2026-09-01 alice@example.org # (content)
shardpostctl message show alice@example.org INBOX 42 # envelope + headers (content)
shardpostctl message export alice@example.org INBOX 42 > 42.eml # (content)
shardpostctl flags -add '\Seen' -remove '$Junk' alice@example.org INBOX 42,43 # (support)
shardpostctl move alice@example.org Junk 42 INBOX # copy|move (support)
shardpostctl plan -mailbox Trash -deleted expunge alice@example.org # then apply (delete)
shardpostctl sieve list|show alice@example.org [name]
shardpostctl sieve put -activate alice@example.org main < main.sieve # (support)
shardpostctl sieve check -message INBOX:1789839043:42 alice@example.org
shardpostctl learning list -state dead
shardpostctl learning retry -confirm-repeat <id> # (repair)
- Stable targets: messages are addressed by mailbox ID, UIDVALIDITY
and UID. If the UIDVALIDITY does not match (the mailbox was recreated),
the API answers 412. Without
-uidvalidity, shardpostctl uses the current one. For strict identity, pass the value from an earlier listing. UIDs that no longer exist are reported asmissingin the response. Copy/move returns the new UID for each message (as UIDPLUS does). - Expunge runs as a planned job. The plan records the UID and GUID of
every message. On apply, only what still carries the same GUID is
removed, and with
deletedonly what is still\Deleted. A changed UIDVALIDITY aborts it. - Search is bounded: at most
admin.search_scan_limitmessages per request and 200 hits per page.nextcontinues (in mailbox ID order).from,to(Cc as well) andsubjectare substrings in the envelope,textuses the full-text index. If the index is missing (never built, or duringfts_rebuild),textfinds nothing. - Expunge plans cover at most 40,000 messages. Handle larger sets in
several plans (
-uids). - Privacy: for searches the audit log names only the criteria, never their values. For messages and scripts it names the identity and the size, never the content.
- Sieve is compiled before it is stored, together with the account's
includes. A faulty script is not stored (400). Versions go through ETags
(
If-Match,If-None-Match: *to create only). A script name is at most 512 bytes of UTF-8, as over ManageSieve and JMAP. The active script cannot be deleted.jmap-vacationis the server's copy of the JMAP VacationResponse, for display (see JMAP): it can be listed, shown and checked, but storing, deleting or activating it is refused (409). An import of scripts that names it is refused (409) and one that does not leaves it in place; export, restore andmigrateleave it out. - Checking Sieve without side effects:
sieve checkcompiles the script and, on request, runs it against a stored message (rolecontent) or a text supplied with the request (diagnose). Nothing is actually carried out: no redirect, no vacation reply, no notification, no external program (execute/filtercount as having run with exit 0, without running), no duplicate or vacation record, no filing. The response lists what would happen and, undersuppressed, every suppressed side effect. The message carries theReturn-PathandDelivered-Tofields a delivery adds, so the script sees what it sees in a delivery; withoutenvelope_fromthat is the null sender,Return-Path: <>, and withoutenvelope_tothe account. A vacation reply reports its effective period, after the vacation period settings, asdaysandseconds(seconds: 0is a reply to every message). With a script and a text supplied in the request (rolediagnose), the store tests (mailboxexists,mboxmetadata,specialuse_exists) are active. That waydiagnoselearns no more than it would throughmailbox list. This is a deliberate decision. With global scripts configured (sieve.before_dir,sieve.after_dir) the check runs the sequence a delivery runs — before, the checked script, after — and reports the scripts that ran asscriptsand failed global ones asfailed_scripts; an account without an active script is checked with an empty one. - Learning queue:
learning list|showshows jobs with their state (pending,dead,delivered), program, attempts and next due time.retrypicks up dead jobs only. Learning programs are not idempotent: if the program may already have received the message (attempts > 0),-confirm-repeatis required. A job whose message was never committed (no evidence) cannot be retried. - Clients with an account scope see learning jobs only when an account is given. Jobs of other accounts do not exist for them (404).
- With
imapsieve.learning.index: readthe learning list reads the owner index of the queue (all jobs, or those of the account given) and then each job; otherwise it lists the queue namespace.showandretryread the job by its id in every mode. A retried job is due at once, and the worker is woken by a notify inreadmode.
Export and restore (stage 8e)
An account can be backed up as a versioned archive and restored through the account import, with the same IMAP identities.
shardpostctl export -o alice.tar alice@example.org # role export, verifies the archive after writing
shardpostctl archive verify alice.tar # offline, without a server
shardpostctl restore alice.tar # role import, into the exported account
shardpostctl restore -account restore-test@example.org alice.tar # isolated restore
Format shardpost-account-export, version 1: a tar stream with
| Entry | Contents |
|---|---|
shardpost-export.json |
format, version, account, timestamp, server version |
mailboxes/<id>/mailbox.json |
ID, name, UIDVALIDITY, UIDNEXT, HIGHESTMODSEQ, subscription, special-use, ACL, annotations (METADATA) |
mailboxes/<id>/<uid>.json |
UID, GUID, flags, MODSEQ, INTERNALDATE, SAVEDATE, POP3 UIDL, size, SHA-256 |
mailboxes/<id>/<uid>.eml |
the message as stored |
sieve/<name>.sieve |
one Sieve script |
manifest.json |
complete yes/no, counters, active script, skipped messages, size and SHA-256 of every entry |
The full-text index is not included; it is rebuilt from the messages.
- Consistency: the export does not lock the account. Every mailbox is
read in the state it had at export time. Messages that have been deleted
in the meantime appear in the manifest under
skipped, deleted mailboxes likewise underskipped_mailboxes. If an export fails part way through, the archive ends withcomplete: falseand is rejected by both verification and restore. For an exact point in time, setusers holdin the Director beforehand. - Verification: every message is checked against the size and SHA-256
in its description, and at the end every entry is checked against the
manifest.
restorereads the whole archive once beforehand and does not even start restoring a damaged or incomplete archive (exit 65). - Restore uses the account import (a session holding the account
lock): mailboxes with UIDVALIDITY and ID, messages with UIDs, flags,
MODSEQ, dates and POP3 UIDL, then UIDNEXT/HIGHESTMODSEQ per mailbox
(finalize), ACL and annotations, and finally the Sieve scripts. Every
mailbox is checked against the target's inventory (
verified). A restore can be repeated; messages that are already present are skipped. The target has to be empty or in the same state. A mailbox with a different UIDVALIDITY that holds messages is a conflict. - Isolated restore (a different target account, for restore evidence
for example): ACLs are not carried over (they would give real accounts
rights on the copy) and no Sieve script is activated (no vacation
replies or forwards out of the copy).
-acland-activate-sieveturn that on explicitly. - The export keeps moving the admin listener's write deadline forward as
it progresses and therefore runs through even for large accounts.
shardpostctl exporthas no overall timeout.-timeoutbounds only the wait for the response to start and each pause without data. An aborted export leaves no file behind. - Not in the archive: subscriptions to mailboxes that do not exist, server annotations (METADATA without a mailbox), the trace switch and the quota limit (which comes from LDAP). The full-text index is rebuilt from the messages.
- Exit codes: 0 fine, 1 restore partially failed (run it again), 65 archive damaged or incomplete, 64 usage, 69 export failed, 73 target file exists.
Public folders
A public folder belongs to no account: an archive, an announcement
folder, a team inbox that outlives whoever created it. Clients see them
under the prefix Public/ (server.public_prefix), the store keeps them
in the reserved namespace ~public.
Quote the namespace name: a shell expands ~public as a home directory
(zsh errors out, bash leaves it alone).
shardpostctl mailbox create '~public' "Announcements"
shardpostctl acl set '~public' "Announcements" anyone lr
shardpostctl acl set -mode add '~public' "Announcements" team@example.org lrswipkxte
shardpostctl acl explain '~public' "Announcements" user@example.org
shardpostctl mailbox list -all '~public'
- Administration uses the same operations as an account: the API
accepts
~publicwherever an account is expected, without a user database lookup.account show ~publicreports the lookup aspublic. Delivery is refused: a public folder has no address of its own. - Migration: a public namespace of another server is imported with
shardpostctl migrate -source-namespace, see Importing a public namespace. - Rights: there is no owner and therefore no implicit right. Only ACL
entries grant access;
anyonecovers every authenticated account, an entry for one account wins over it. Without thelright a folder does not exist for that account (NONEXISTENT, notNOPERM), exactly as for shared folders. Unlike Dovecot, the rights are data in the store:acl settakes effect with the next command, without a reload. - Subscriptions belong to the account, not to the folder, and are kept by mailbox id: a folder that is renamed stays subscribed.
- Quota: the namespace counts its own usage and is bounded by
server.public_quota_bytesandserver.public_quota_msgs(0 = unlimited). A message in a public folder does not count against any account, which is what Dovecot does as well. - Reading state:
\Seenis private per account and kept in that account's own namespace; every other flag and every keyword is shared. "Answered" and "flagged" are statements about the folder, "read" is a statement about a person.STATUS ... (UNSEEN)therefore differs between accounts. This matches Dovecot, whose private index also holds\Seenalone. - Delivery goes through Sieve: a normal account receives the message
and its script files it into the folder
(
fileinto "Public/Support/Inbox"). That needs thep(post) right on the folder for that account; without it the message stays in INBOX, as a failedfileintodoes. The message is stored in the public namespace, so it counts against its quota, not the account's. A delivery never creates a public folder. - Backup:
shardpostctl export -o public.tar '~public'andrestorework like they do for an account.acl_reconcileis refused for~public: there are no pointers to reconcile. - Switching them off: an empty
server.public_prefixdisables the namespace for clients (no entry inNAMESPACE, no extra reads perLIST, no public account in a JMAP session); the folders stay in the store and remain administrable. While it is off, an account can create a mailbox of its own calledPublic/...; once the prefix is in use again, such a mailbox is shadowed by the public namespace. The same applies to an account that already had a mailbox namedPublicbefore the feature existed.
Protocol trace for an account
To debug a problem with a particular client, a recording of the IMAP
protocol can be switched on for an account for a limited time (role
trace):
shardpostctl trace on -for 30m user@example.org # without -for: trace.default_duration_seconds
shardpostctl trace status user@example.org
shardpostctl trace off user@example.org
API: GET/PUT/DELETE /admin/v1/accounts/{account}/trace, PUT with
an optional {"duration_seconds": n}. The switch lives as the registry
key trace in the Ceph account and therefore applies to every server in
the cluster, no matter which pod receives the request. It has to be set on
the canonical account (an alias is rejected together with the canonical
name, as it is for the import) and expires after
trace.max_duration_seconds at the latest.
Effect:
- New IMAP sessions of the account log every line in both directions from
the successful login onwards as
event=protocol_tracewithdir(Cclient→server,Sserver→client),line,session_idanduser. Lines overtrace.max_line_bytesare truncated (cut_bytes). - Literals (message content, APPEND data, fetched sections) appear only as
<literal N bytes>, the text ofPREVIEWas"<masked>". Client lines and literals are reported by the IMAP parser, that is, exactly what the server processes; pipelining, rejected literals and read buffers do not shift that view. Server lines come from the data stream that was sent. - Credentials never appear: the recording starts after the login and ends
at
UNAUTHENTICATE. A client line withLOGINorAUTHENTICATEin the authenticated state is replaced by<authentication command, trace stopped>and stops the trace until the next login (any literals or SASL lines that follow do not appear either). - Sessions that are already running are only picked up at their next
login. Expiry takes effect immediately. Switching the trace off takes
effect in running sessions at the next command, once
trace.recheck_secondshave passed; a session in IDLE keeps being recorded until IDLE ends or the trace expires. If the trace is extended before it expires, a session that is already being recorded continues past the old end time. protocol_trace_startandprotocol_trace_stop(withreason) mark the beginning and the end; the Admin API's audit log records who switched the trace on, when and until when.
The trace contains the account's subject lines, addresses, file names (ENVELOPE, BODYSTRUCTURE), search terms and mailbox names. That is why it is a role of its own, time-limited and intended only for debugging. Reading it out, for example:
kubectl -n shardpost logs -l app.kubernetes.io/name=shardpost-server -c server --since=30m \
| jq -r 'select(.event=="protocol_trace" and .user=="user@example.org") | "\(.time) \(.session_id[0:8]) \(.dir): \(.line)"'
Delayed JMAP submissions
The pending delayed submissions of an account (JMAP HOLDFOR and
HOLDUNTIL, see JMAP) can be listed and
canceled for the user:
shardpostctl submission list user@example.org # role diagnose
shardpostctl submission cancel user@example.org S5f0c1e2d3b4a # role support
shardpostctl submission list -all # role diagnose, scope of all accounts; lists ~admin
API: GET /admin/v1/accounts/{account}/submissions (role diagnose)
lists the submissions the account still holds a message copy for —
pending, being sent, or ended and not yet cleaned up — by release time,
read from the account's intents and one record each, without a listing.
POST /admin/v1/accounts/{account}/submissions/{submission}/cancel
(role support) cancels a pending one as a JMAP client would: the
message is not sent, its copy is deleted, the EmailSubmission shows
canceled, and the record names the canceling principal
(canceled_by admin:<identity>). A submission the worker has begun to
send is conflict (409); one that ended and was cleaned up is
no_such_submission (404). GET /admin/v1/submissions (role
diagnose, scope of all accounts, after and limit up to 1000) lists
the records of every account in key order: one listing of the admin
namespace per page, a tool for diagnosis and for the empty-queue check
before a rollback, which also finds a record whose copy and queue entry
were lost. Without the admin store indexes there is no
queue and both answer temporary (503). See the
manual page.
Auth cache flush
Every server keeps the logins and lookups Nauthilus answered for
auth.cache.ttl and lookup_ttl (see
configuration). A change in LDAP that
has to take effect before — a locked account, a removed alias, a new
quota — is pushed through with a flush (role repair):
shardpostctl auth flush user@example.org # the entries of one account
shardpostctl auth flush # every entry
shardpostctl auth flush --json alias@example.org
API: POST /admin/v1/auth/flush with an optional
{"account": "<name>"}; without it, empty or * everything is
flushed. The answer is {"account": "<name in lower case or *>", "cached": true}; cached is false on a server without an auth cache
(the static backend, or auth.cache.ttl: 0), where nothing is flushed.
The name is matched without regard to case against the login names, the
lookup names and the canonical accounts of the entries, so an alias, an
address or the account all reach the entries that carry them. The
server that answers flushes its own cache before it answers and records
the flush in the flush log of the cluster (the omap object auth-flush
in the admin namespace: a generation counter and one entry per flush,
kept for an hour). The other servers apply it when the notification on
that object wakes them and at the latest on their next poll, 60 seconds
later; the answer does not wait for them. A server that lost its watch,
or finds that entries it has not read are gone, flushes everything.
The flush is in the audit line (flush_account). When the flush log
cannot be written the answer is 503 (temporary): the answering server
is flushed, the others are not.
A flush does not reach the Director's routing cache (up to 30 s) nor the cache of Nauthilus itself. See Operations: Auth cache for what each change costs until it takes effect.
Full-text layout 3
shardpostctl fts layout3 status # mark and server registrations (role diagnose)
shardpostctl fts layout3 enable -pods 3 # role repair
shardpostctl fts layout3 list -verify # accounts with layout 3 (scope of all accounts)
shardpostctl fts layout3 disable # exit 65 while accounts have layout 3
shardpostctl fts dictfilter status # mark of the dictionary filter (role diagnose)
shardpostctl fts dictfilter enable -pods 3 # role repair
shardpostctl fts dictfilter disable # exit 65 while generations have the filter or nosig
shardpostctl fts online status # mark of online runs (role diagnose, since 0.51.0)
shardpostctl fts online enable -pods 3 # role repair
shardpostctl fts online disable # new runs offline
shardpostctl quota online status|enable -pods 3|disable # mark of online quota recounts (since 0.52.0)
shardpostctl mailbox lock status|enable -pods 3|disable # mark of the mailbox lock of mailbox_delete (since 0.52.0)
shardpostctl fts layout show user@example.org # the account's layout record
shardpostctl fts layout clear user@example.org # remove a conversion whose run ended
Layout 3 of the full-text index (storage)
needs a cluster mark, because servers before 0.45.0 cannot read it.
fts.layout defaults to 3 since 0.46.0, so the mark alone switches the
servers that do not set fts.layout: 2 (see
Operations: Full-text layout 3).
| Endpoint | Role | Effect |
|---|---|---|
GET /admin/v1/fts/layout3 |
diagnose |
whether the mark is set (when, by whom, for how many pods, forced) and the registrations (servers/<instance>: role, version, capabilities, system pool, start, last heartbeat, end, fresh within three minutes and not stopped) |
POST /admin/v1/fts/layout3/enable |
repair |
{"pods": n, "force": false}: sets the mark when exactly n fresh registrations of servers (role serve, or none before 0.47.0) report fts_layout3, otherwise 409 conflict naming the instances; the push worker, scrubs and backfills are not counted; force skips the check (audited, recorded in the mark) |
POST /admin/v1/fts/layout3/disable |
repair |
removes the mark, waits five seconds, and lists the accounts of the presence index ix.fts_layout (accounts, up to 1000, more): a downgrade below 0.45.0 needs it empty |
GET /admin/v1/fts/dictfilter |
diagnose |
the cluster mark of the dictionary filter (fts/dictfilter, since 0.50.0) and the registrations, as for layout 3: with the mark, new index generations get the identifier filter of fts.dict_filter and the text preparation of fts.attached_headers (storage) |
POST /admin/v1/fts/dictfilter/enable |
repair |
{"pods": n, "force": false}: sets the mark when exactly n fresh registrations of servers report fts_dict_filter, otherwise 409 conflict; as layout 3 |
POST /admin/v1/fts/dictfilter/disable |
repair |
removes the mark, waits five seconds, and lists the accounts whose entry in the presence index ix.fts_layout carries props — a generation with the filter or nosig, current or under way; set before such a generation is created, cleared after a switch to one without, kept in step by the scrub (accounts, up to 1000, more; an entry that cannot be read is listed too): a downgrade below 0.50.0 needs it empty |
GET /admin/v1/fts/online |
diagnose |
the cluster mark of online runs (fts/online, since 0.51.0) and the registrations, as for layout 3: with the mark, fts_layout and fts_rebuild may run without the account lock (storage) |
POST /admin/v1/fts/online/enable |
repair |
{"pods": n, "force": false}: sets the mark when exactly n fresh registrations of servers report fts_online_build, otherwise 409 conflict; as layout 3 |
POST /admin/v1/fts/online/disable |
repair |
removes the mark (new plans run offline, online: true is refused with 409) and returns the status; runs that started finish (their accounts show next_online) |
GET /admin/v1/quota/online |
diagnose |
the cluster mark of online quota recounts (quota/online, since 0.52.0) and the registrations, as for layout 3: with the mark, quota_recalc may run without the account lock (quota) |
POST /admin/v1/quota/online/enable |
repair |
{"pods": n, "force": false}: sets the mark when exactly n fresh registrations of servers report quota_online_recount, otherwise 409 conflict; as layout 3 |
POST /admin/v1/quota/online/disable |
repair |
removes the mark (new plans run offline, online: true is refused with 409) and returns the status; runs that started finish or end busy |
GET /admin/v1/mailbox/lock |
diagnose |
the cluster mark of the mailbox lock of mailbox_delete (mailbox/lock, since 0.52.0) and the registrations: with the mark, mailbox_delete locks only the mailboxes it removes (storage) |
POST /admin/v1/mailbox/lock/enable |
repair |
{"pods": n, "force": false}: sets the mark when exactly n fresh registrations of servers report mailbox_lock, otherwise 409 conflict; as layout 3 |
POST /admin/v1/mailbox/lock/disable |
repair |
removes the mark (new plans take the account lock, online: true is refused with 409) and returns the status; runs that started finish |
GET /admin/v1/fts/layout3/accounts |
diagnose, scope of all accounts |
the presence index a page at a time (after, limit up to 10000); verify=true reads each account's layout record and marks entries without a layout 3 index (mismatch); the shapes carry the identifier filter (dict_id_min_runes) and the text preparation (attached_headers) of a generation that has them (since 0.50.0) |
GET /admin/v1/accounts/{account}/fts/layout |
diagnose |
the layout record: current (layout 2 without a record), next with next_tok and next_at (a conversion in progress; next_online and next_mode copy or reindex for an online run, since 0.51.0), old with old_toks and old_at (sources a run left after its switch, the runs and their last import heartbeats, empty for a job) |
POST /admin/v1/accounts/{account}/fts/layout/clear |
repair |
removes the conversion in progress when its run has ended (a job that does not run any more, an import reshard whose heartbeat is older than 90 s): lifts exactly its freeze, deletes its targets; the same for sources an ended run left after its switch (old); cleared lists the tokens, comma-separated ("" when nothing was cleared) |
GET /admin/v1/accounts/{account}/fts/stats |
diagnose |
key counts of the index (since 0.49.0): without parameters the counters !n and !dn of the posting objects and the document count c (S + 1 reads, no lock; layout 2 has no counters); exact=true counts every object's keys page by page (parallel 1–16, default 1, for a test account 2; thousands of reads on a large account that transfer about its whole index omap, values included, since the client library cannot read keys alone; roughly consistent, bounded by the listener's 5 minute write timeout); classes=true classifies the terms by field, class and length and projects the dictionary keys per identifier bound 13–40 — only for the test accounts of fts.stats_test_accounts, otherwise forbidden (403). No term is ever returned |
Every server since 0.45.0 registers at startup and every minute, also
without jobs or an admin listener; since 0.47.0 so do the push worker
(shardpost push), the scrubs (shardpost scrub, scrub --batch,
scrub --sweep as role sweep) and the backfills (shardpost index backfill …), each with its role and for its whole runtime, and every
one records its orderly end (stopped_at). The push worker and the job
runner register as <host>-push and <host>-runner, stable across
restarts of their container; every other process other than a server
registers as <host>-<role>-<random>, so a tool run in a server's pod
does not overwrite the server's record. A registration not
renewed for 24 hours is deleted by the expiry worker of the job system
(with admin.kv_index: off or jobs.enabled: false everywhere, only by
status, enable and GET /admin/v1/servers). With admin.kv_index
other than read, they list the admin namespace. A pod that crashed
leaves a fresh record without stopped_at for three minutes, so
enable refuses until it aged; one that ended in order does not.
Cluster operation (mTLS)
The component deploy/components/admin-mtls (active in the
croessner-nauthilus overlay) provides the mTLS listener:
- A dedicated CA
shardpost-admin-ca(cert-manager, bootstrapped viashardpost-selfsigned). It is deliberately separate fromshardpost-ca, which issues server certificates for IMAP/LMTP; only certificates from this CA are accepted as admin clients. - Server certificate
shardpost-admin-tlsfor the serviceshardpost-admin:8443(pod portadmin), mounted as a directory at/secrets/admin-tls(withoutsubPath, so rotation needs no restart). - Client certificate
shardpost-admin-operator(CN = identity) with the rolediagnose. Further clients each get their ownCertificatewithusages: [client auth]and an entry inadmin.clients. - NetworkPolicy
shardpost-admin-ingress: port 8443 only for pods in the namespaceshardpostwith the labelshardpost.roessner-net.de/admin-client: "true".
Calling it from such a pod with the client secret mounted:
shardpostctl -url https://shardpost-admin.shardpost.svc:8443 \
-cert /op/tls.crt -key /op/tls.key -ca /op/ca.crt version
Accepted on 2026-09-18: operator version 200, deliver without the role
403, a client of a foreign CA and a client without a certificate rejected
in the handshake, a pod without the label blocked by the NetworkPolicy;
audit lines with transport=mtls and the identity.
Account import with preserved IMAP identities
The core of sub-package 8e (stage 8). An account is taken over in such a way that IMAP clients only synchronize incrementally after the cutover: UIDVALIDITY, UIDs with their gaps, UIDNEXT, flags and keywords, INTERNALDATE/SAVEDATE and MODSEQ per message are preserved, and HIGHESTMODSEQ becomes at least the source value. imapsync cannot do this, because IMAP APPEND always assigns new UIDs.
Semantics
- Lock:
POST /admin/v1/accounts/{account}/import(roleimport) takes the account-wide import lock for one session (headerX-Import-Sessionon all further calls; every call extends it,admin.import.lock_ttl_seconds). While it is held, IMAP, POP3, ManageSieve, LMTP (451 4.2.1) and local delivery reject the account temporarily. Only the canonical account from the userdb is accepted. The body may carrymessages, the number of messages the import is going to store: a fresh layout 3 full-text index is sized for them (kept by the server for the session). Taking the lock clears an unfinished full-text conversion another run left (an aborted import session's, a job's that lost the account). - Full-text reshard:
POST .../import/fts-reshard(same session header) reads the key counts of the account's layout 3 index and, when they ask for more objects, reshards it under the session — afts_layoutjob would wait for the import lock. It waitsjobs.settle_seconds, works up tofts.import_reshard_sync_secondsand answers 200 (idle: nothing to do,done,failedwitherror) or 202 (running, it goes on in the background, renewing the lock and its heartbeat every 30 s); every answer carries the index generationg. A second POST of the session while its run is active answers 202 without starting another; a run of another session with a heartbeat younger than 90 s answers409 conflict, an older one is cleared and replaced.GET .../import/fts-reshardreportsidle,running,doneorfailed, from this server's runs or, without one, from the layout record. While the session's reshard runs, its message and change batches answer409 conflict. The runs live in the server the importer talks to (migratereaches the same pod through the socket of the import sidecar); a server shutdown stops them and clears their conversion. A layout 2 index is never resharded. - Mailboxes:
PUT .../import/mailboxescreates the mailbox with the UIDVALIDITY of the source (with the Dovecot mailbox GUID as its stable ID where available) or takes over an existing one with the same UIDVALIDITY. An empty mailbox with a different UIDVALIDITY (an INBOX from the first login, say) is replaced, a non-empty one is a conflict (409 conflict). If the source has renamed a mailbox, the import follows it through the stable ID. New mailboxes getadmin.import.shard_sizeas their shard size. - Messages:
POST .../mailboxes/{id}/messageswithmultipart/mixed; every part is amessage/rfc822with the identity as JSON in the part headerX-Import-Meta(uid,modseq,flags,internal_date,save_date,guid,pop3_uidl). UIDs ascending. A UID whose message has the same size is skipped (resumption), one with a different size is a conflict. Quota is counted, not enforced. The same source GUID in several mailboxes shares one blob: the import takes an existing blob of that GUID when its size matches, without comparing the content (a source GUID names one message); a blob of another size gets a fresh GUID of its own. - Deltas:
POST .../changessets flags with the source MODSEQ and removes UIDs the source no longer has.GET .../messagesreturns the inventory page by page (after,limit). - Finalization per mailbox:
POST .../finalizesets UIDNEXT and HIGHESTMODSEQ to at least the source values, the \Recent mark to UIDNEXT and the start of the change log past HIGHESTMODSEQ. A client that comes back with an older source state via QRESYNC/CONDSTORE then gets the complete answer (all newer entries, VANISHED for all gaps), one with the current state gets nothing. - Sieve and ACL:
PUT .../import/sievereplaces the scripts and the active script; all scripts are compiled beforehand as they are in ManageSieve (422 rejectedif one does not compile; nothing changes then).PUT .../mailboxes/{id}/aclreplaces the explicit ACL entries.PUT .../import/mailboxes/{id}/metadatarefuses entries under/private/vendor/shardpost/jmap/, which the server keeps (400). - POP3: with
-pop3-uidl-format(Dovecot syntax 2.3%08Xu%08Xvor 2.4%{uid | hex(8)}) the UIDL of the source is stored for every INBOX message; POP3UIDLthen returns it instead of the GUID. COPY/MOVE do not carry it over (it has to stay unique per maildrop).
Migrator shardpostctl migrate
Reads the source over IMAP (master user, UID FETCH ... MODSEQ X-GUID SAVEDATE) and ManageSieve (with a PROXY header where the load balancer
requires one), compares it with the target's inventory and sends only the
differences. Every run is repeatable and checks at the end, per mailbox,
that UIDs, sizes and flags match the source state that was read
(verified). Target mailboxes the source no longer has are removed
(-no-prune turns that off; INBOX always stays).
In the cluster it runs in the import sidecar of the server pod
(component deploy/components/import-sidecar) against the admin socket.
The source and the Sieve endpoint are set as SHARDPOST_MIGRATE_* in the
component, the master password is in the secret
shardpost-migrate-source (key password):
kubectl -n shardpost create secret generic shardpost-migrate-source \
--from-literal=password="$(kubectl -n director get secret director-backend-master -o jsonpath='{.data.password}' | base64 -d)"
kubectl -n shardpost exec deploy/shardpost-server -c import -- \
shardpostctl -timeout 10m migrate user@example.org
For long runs, detached with a log under /work:
kubectl -n shardpost exec deploy/shardpost-server -c import -- sh -c \
'nohup shardpostctl -timeout 10m migrate --json user@example.org >/work/user.json 2>/work/user.log &'
Before it copies, the migrator sends the number of the source's messages
as the import hint, and after every batch it calls the full-text
reshard (above), waiting for a running one; a batch answered 409 because
of it is sent again. A failed reshard is a warning. Run it with
shardpostctl -timeout 10m: the synchronous part of a reshard fits into
the default two minutes, large batches may not.
Exit codes: 0 everything verified, 1 parts failed (run it again), 64 usage, 69 run aborted.
Importing a public namespace
A public namespace of the source (Dovecot namespace { type = public })
is not part of any account, so an account import skips it. It is
imported on its own, into the public folders:
shardpostctl -timeout 10m migrate \
-source-user lists@example.org \
-source-namespace Mailinglists/ -target-prefix Mailinglists/ \
-readers lists@example.org,user@example.org '~public'
- What is read: the namespace with the prefix
-source-namespace, as-source-usersees it (master login as for an account). The folders land below-target-prefix; a namespace that is itself a mailbox (ZabbixofZabbix/) becomes the folder the prefix names. Identities (UIDVALIDITY, UIDs, MODSEQ, flags, dates) are kept as for an account. - Session: the import session is opened on
~publicand locks the public namespace for its duration: IMAP answersNO [UNAVAILABLE]forLISTand for public folders, a session that has one selected is closed at its next command (checked every few seconds), a delivery that files into one is deferred. Plan the first copy of a large namespace accordingly; a delta is short, but reads the flags of every folder once per reader. - Only into folders that are not in use yet: a later run makes the
target what the source is. A reader's
\Seenbecomes exactly the source's — what the account read here since the last run is unread again — and the reader marks of an earlier run have moved HIGHESTMODSEQ past the source, so a CONDSTORE/QRESYNC client that synchronized such a folder here between two runs can miss flag changes the second run brings with source MODSEQs below that. Run the last delta before clients are routed here, and grant the rights afterwards (or keep clients away until then). - Readers log in at the source under their account name; an account that has another login name there cannot be a reader. A folder a reader can list but not open is skipped with a warning.
\Seen: the messages carry the\Seenof the source user; that is the shared default every account starts from. For each account of-readersthe migrator logs in at the source as that account, reads its\Seenand sends it withPUT .../import/mailboxes/{id}/readers/{reader}: the server writes private marks where the reader's state differs from what the reader sees now, under a new MODSEQ of the mailbox (as an IMAP STORE does), and sets the reader's subscription. The call is repeatable; a run that finds nothing to change writes nothing. The marks are written in batches of 1000, each under its own MODSEQ. A folder the reader does not see at the source is skipped.- Not copied: rights (Dovecot takes them from the configuration and
from groups; grant them with
acl set '~public' ...), Sieve scripts, special-use attributes. - Prune only removes folders below a non-empty
-target-prefix; the folder the prefix itself names stays. - Scope: an admin client with a scope needs
~publicand every reader in it. - Delivery into the imported folders is a matter of Sieve; what a
global
sieve_afterscript did at the source is done here by a script insieve.after_dir(see configuration).
Migration procedure with the Director
- First copy while the account is still in service (still on mailstack):
run
migrateas often as needed until a run reports almost no changes. - Director:
users hold,users kick. - Final
migraterun (delta, Sieve, ACL); all mailboxesverified. - Pin in the Director/
director.luatoshardpost,users move --to-shard shardpost --strategy kick_existing,hold clear. - Check with a client: same UIDVALIDITY, no full download.
As long as mailstack stays unchanged after the cutover, the way back is a re-route; changes that arrived after that would have to be copied back (with a full resync on the way back in that case).
Rendered from docs/admin-api.md in the ShardPost repository.