The server loads its configuration once, before it opens Ceph, authentication and the listeners. Libraries: Viper and go-playground/validator.
shardpost --config /etc/shardpost/server.yaml
shardpost --config /etc/shardpost/server.yaml config check
shardpost --config /etc/shardpost/server.yaml scrub [--batch]
shardpost --config /etc/shardpost/server.yaml index backfill accounts [--restart]
shardpost --config /etc/shardpost/server.yaml index backfill kv [--restart]
shardpost --config /etc/shardpost/server.yaml index show kv [<key>...]
shardpost --config /etc/shardpost/server.yaml index backfill learning [--restart]
shardpost --config /etc/shardpost/server.yaml index show learning [<id>...]
scrub scrubs in its own process (scrub.users, else every account of
the account directory, else every namespace of an index pool listing);
scrub --batch submits the batch scrub the servers run and exits (see
Scrub and
Operations).
index backfill accounts fills the account directory once after a
rollout (see Operations);
it has no configuration keys of its own. index backfill kv fills the
admin store's hint indexes and due queues (see
Operations); it uses
admin.kv_index (refuses off) and jobs.retention_days. index show kv prints their state and that of the given admin store keys
(read-only). index backfill learning fills the learning queue's due
queue and owner index (see
Operations); it uses
imapsieve.learning.index (refuses off) and
imapsieve.learning.queue_shards. index show learning prints their
state and that of the given learning jobs (read-only).
Alternatively, SHARDPOST_CONFIG determines the file path. IMAP_CONFIG
is kept as a fallback as long as SHARDPOST_CONFIG is not set. An
explicit --config takes precedence over both variables. An explicitly
empty SHARDPOST_CONFIG disables the old fallback. Precedence: built-in defaults, explicit YAML file, explicitly set legacy env variables. There is no implicit file lookup and no automatic reload. Changes in Kubernetes are applied through a controlled deployment rollout. Go runtime settings such as GOMEMLIMIT remain environment variables; additional standardized OTEL SDK options are still evaluated by the SDK.
The sections are server, ceph, rook, tls, nauthilus, auth (see auth cache), smtp, sieve, scrub, logging, telemetry, imapsieve, extprograms, quota, admin, fts, limits (see account limits) and jmap (see JMAP; without jmap.addr that front-end is not started). The complete field and env mapping is documented in internal/config/config.go, internal/config/imapsieve.go, internal/config/extprograms.go, internal/config/quota.go, internal/config/admin.go, internal/config/fts.go, internal/config/limits.go, internal/config/auth.go and internal/config/jmap.go (including jmap.push); extprograms, the learning rules, the quota rules and warnings as well as the admin roles and clients are configurable via YAML only (see docs/imapsieve.md, docs/quota.md and docs/admin-api.md).
server:
imap_addr: ':1143'
imaps_addr: ':1993'
pop3_addr: ':1110'
pop3s_addr: ':1995'
lmtp_addr: ':2424'
http_addr: ':8080'
auth_backend: nauthilus
insecure_auth: false
memory_budget_bytes: 268435456
ceph:
user: shardpost
index_pool: shardpost-index
data_pool: shardpost-data
rook:
namespace: rook-ceph
nauthilus:
transport: grpc
grpc_addr: nauthilus.auth.svc.cluster.local:9444
oidc_client_secret_file: /run/secrets/nauthilus/client-secret
tls:
cert: /tls/tls.crt
key: /tls/tls.key
sieve:
addr: ':14190'
global_dir: /etc/sieve
before_dir: /etc/shardpost/sieve/before
after_dir: /etc/shardpost/sieve/after
telemetry:
protocol: http/protobuf
endpoint: http://collector:4318
sample_ratio: 0.1
user_attributes: false # account, mailbox and client address in spans
server.insecure_auth (default false) decides whether IMAP, POP3,
ManageSieve and LMTP accept authentication on a connection without TLS.
Turn it on only for an isolated test profile; JMAP has its own switch,
jmap.insecure_auth (default false).
The rook section applies only while ceph.key is empty: the server then
reads the key and, if ceph.mon_host is empty, the monitor list from the
Rook-managed secret and ConfigMap through the Kubernetes API and follows key
rotations. With ceph.key set, rook is ignored and no Kubernetes access
happens; see running without Kubernetes.
Pools (ceph)
| Key | Environment | Default | Meaning |
|---|---|---|---|
ceph.index_pool |
CEPH_INDEX_POOL |
shardpost-index |
replicated pool of the accounts' index objects (omap) |
ceph.data_pool |
CEPH_DATA_POOL |
shardpost-data |
pool of the message blobs (may be erasure coded) |
ceph.system_pool |
CEPH_SYSTEM_POOL |
empty | replicated pool of the store-wide state: the index part of the namespaces ~admin, ~accounts, ~learning and ~server (admin store, account directory, queues, locks, server metadata). Empty keeps them in the index pool, as before 0.47.0. Must differ from both other pools (by name here, by pool id when the store opens) |
ceph.chunk_size |
CEPH_CHUNK_SIZE |
4 MiB | chunk size of the message blobs |
ceph.client_options |
– | empty | further librados options of the connection, YAML only, values as strings: objecter_inflight_ops and objecter_inflight_op_bytes (the client's throttles of operations and bytes in flight; librados defaults 1024 and 100 MiB), rados_osd_op_timeout and rados_mon_op_timeout (seconds; the server sets 60 and 30). Other options are refused |
ceph.system_pool is not a switch to flip: a cluster has one placement,
and the data says which. A server, the push worker and every other
shardpost command check it when they open the store (two reads, no
listing):
- without
ceph.system_pool, a fence in the index pool that says the state has moved refuses the start (set ceph.system_pool); - with it, the system pool must carry the mark of this index pool and
cluster, written by
shardpost system init(a new cluster) orshardpost system migrate(an existing one); otherwise the start is refused with the command to run.
A server and the push worker repeat the check every minute and end
(after draining) when it reads a placement that contradicts the
configuration; a check that cannot read logs a warning and goes on (a
server or push worker that cannot read at startup starts in the state
unknown; a short-lived command does not start). Set the key on the
server, the push worker and the scrub CronJob together. The Kubernetes
manifests set CEPH_SYSTEM_POOL (empty until the migration), and an
environment variable that is set wins over the file even when it is
empty: change it in the manifests, not in server.yaml. The system
tools themselves (shardpost system …) do not check the placement and
take the system pool from their options or from the fence; they run
with the key empty and refuse a value that names another pool. They also
refuse a ceph.client_options with rados_osd_op_timeout above 60 and
raise the objecter throttles for their parallel copies
(shardpost-system(1)).
See Storage: placement
and Operations.
ceph:
index_pool: shardpost-index
data_pool: shardpost-data
system_pool: shardpost-system # only after "shardpost system init" or "migrate"
client_options:
objecter_inflight_ops: "4096"
objecter_inflight_op_bytes: "268435456"
Global Sieve scripts
sieve.global_dir holds the scripts a user script can pull in with
include :global. sieve.before_dir and sieve.after_dir hold scripts
that run for every delivery (LMTP and the local delivery API) around
the account's active script, the way Pigeonhole runs sieve_before and
sieve_after:
- Every regular file
<name>.sieveof a directory is a script; they run in name order (10-spam.sievebefore50-lists.sieve). Other files and names starting with a dot are ignored. The directory is read for every delivery, so a changed ConfigMap takes effect without a restart. - The order is: before scripts, the account's active script, after
scripts. The next script runs only while the message is still kept
(implicit or explicit
keep); that keep is handed on instead of being carried out, and the actions of all scripts add up. A script that files the message elsewhere, discards or rejects it ends the sequence. So an after script only sees what the user's script left in INBOX, and afileinto "INBOX"in a user script — unlikekeep— ends the sequence too. - An explicit
keepis an action of its own, as within one script: once a script asked for it, no later script takes it away. A laterfileintoordiscardcancels only the implicit keep, so the message still lands in INBOX (and, with afileinto, in the other mailbox as well); a laterrejectfails with an action conflict and the message stays kept. Policy decisions that must hold whatever the user's script does — a reject, a discard of known spam — therefore belong insieve.before_dir, not insieve.after_dir. - A header edit (
editheader) of one script is what the next one sees. - A script that fails at runtime is skipped as if it had kept the message
and logged; the others still run, and so do the other scripts of a
directory when one file cannot be read. At startup and for
config checka directory or file that cannot be read or a script that does not compile is an error. - The flags of a keep are handed on with it: they are what the flag
variable (
imap4flags) of the next script starts with. So a script that marks the message (addflag,keep :flags) and keeps it does not lose the mark because a later script keeps the message too, and a later script can still remove it (removeflag,setflag,keep :flags). - The startup check compiles the global scripts without an account, so
one that uses
include :personalis refused;include :globalworks. - The environment variables
SIEVE_BEFORE_DIRandSIEVE_AFTER_DIRwin over the file when they are set, even to an empty value. - The scripts run with the rights of the recipient. A
fileintointo a public folder needs thepright of that account; without it the message stays in INBOX. That is how list mail is sorted into public folders for the accounts that may post there and left alone for everybody else. - IMAPSieve does not run them, and neither does a delivery with
sieve=falseor into a named mailbox.
shardpostctl sieve check runs the same sequence, see
admin API.
Delivery headers
Every copy LMTP or the local delivery API stores gets
Return-Path: <envelope sender> and Delivered-To: <recipient> above
its Received field, like Dovecot's LMTP; Return-Path fields the message
brought along are removed. Details in
Sieve during delivery.
| Key | Env | Default | Meaning |
|---|---|---|---|
server.lmtp_return_path |
LMTP_RETURN_PATH |
true |
Add Return-Path (null sender <>) and remove the message's own. |
server.lmtp_delivered_to |
LMTP_DELIVERED_TO |
final |
final: Delivered-To with the envelope recipient; none: no field. Other values are a startup error. |
Sieve settings (sieve.settings)
sieve.settings (SIEVE_SETTINGS) holds the Sieve implementation
settings as key=value;key=value, under Pigeonhole's key names where
Pigeonhole has the setting. Among them, with their defaults:
| Key | Default | Meaning |
|---|---|---|
sieve_vacation_min_period |
1d |
Shortest vacation period; 0 lets :days 0/:seconds 0 answer every message. |
sieve_vacation_max_period |
60d |
Longest vacation period; 0 is no maximum. |
sieve_vacation_default_period |
7d |
Period without :days/:seconds; between minimum and maximum. |
sieve_reject_mode |
mdn |
reject sends a rejection message (MDN) and accepts the message; protocol refuses it in the protocol like ereject. |
rejection_subject |
Rejected: %s |
Subject of the rejection message. |
rejection_reason |
Your message to <%t> was automatically rejected:%n%r |
Text of the rejection message. |
postmaster_address |
postmaster@%d |
Sender of rejection messages and of notifications without :from; %d is the recipient's domain. |
A ; inside a value is written \;, a backslash before it or at the
end of a value \\; in YAML, put the whole value in single quotes (in
double quotes every backslash has to be doubled). The startup and
config check
refuse periods that do not parse or
contradict each other, an unknown sieve_reject_mode and a
postmaster_address that is no address. What these settings do is
described in Sieve during delivery.
Unknown keys, mistyped YAML values, invalid env numbers, invalid boolean values, contradictory TLS pairs and explicit secret files that are missing or read as empty cause a startup error. Env numbers are always interpreted as decimal. A secret file takes precedence over the corresponding direct secret value. Error messages never print values.
config check validates the configuration structure, reads the referenced secret files and compiles the global Sieve scripts of sieve.before_dir and sieve.after_dir. It is no proof of connectivity to Ceph, Nauthilus, SMTP or the external programs; program files are checked when they are invoked. Certificates are loaded when the server starts afterwards.
A running server looks every tls.reload_seconds (default 60; 0 loads once) whether the files of tls.cert/tls.key and jmap.tls_cert/jmap.tls_key changed and loads the renewed pair, so a certificate that cert-manager renews in a mounted Secret reaches the listeners without a restart; a renewal is recognised by the files' content, and the reload logs the new certificate's serial and expiry. A renewed pair that does not load is logged and the previous certificate stays in use. Mount the Secret as a volume, not with subPath: a subPath mount never sees an update. The kubelet itself takes up to about a minute to update a mounted Secret. Without a file path, the existing env-based deployments remain usable.
Client IP behind the Director
server.proxy_protocol_trusted_cidrs controls whether client addresses from
PROXY protocol v1/v2 are adopted ahead of the actual mail protocol/TLS. For
this to work, the Director must also set haproxy.enabled: true for the
backends in question. The setting applies jointly to IMAP, POP3, ManageSieve,
LMTP and JMAP; the list is global, one trust boundary for all listeners.
A proxy that reaches a listener through a Kubernetes NodePort has its
address rewritten on the way: with externalTrafficPolicy: Cluster the pod
sees a node address (the Calico tunnel address from another node, the
node's own address from the same node), so the header is honoured for some
connections and dropped for others. For JMAP behind HAProxy use
externalTrafficPolicy: Local and list the proxy addresses here, and let
the network policy admit the JMAP port only from those addresses — every
peer in the list can name any client address.
The production overlay uses the verified Calico pod network
172.16.0.0/12 together with network-policy.yaml. The mail ports are
reachable only for Director pods in the director namespace and for the
controlled test tools mailtest/imaptest in the shardpost namespace.
These test tools are explicitly part of the proxy trust boundary. HTTP
health and metrics remain reachable. Whenever the Calico pools change, the
CIDR list has to be reviewed as well; individual dynamic Director pod IP
addresses are not recorded permanently.
Without a PROXY preamble, direct connections in which the server greets first can take up to five seconds of detection time. That is why the Director sends the preamble to the LMTP backend as well. Direct operator probes need a matching timeout or have to send PROXY protocol themselves.
Structured operational logs
slog writes JSON to stdout. logging.level (YAML) and LOG_LEVEL
(the environment takes precedence) accept debug, info, warn, error;
the default and the Kubernetes rollout use info. Invalid values prevent
startup.
info: connection start and end, authentication success and rejection, deliveries and the server lifecycle.debug: additionally the known command names and durations for IMAP, POP3 and ManageSieve.handler_errordenotes a handler error, not every negative protocol status. IDLE appears once the command has ended.warn: temporarily disrupted authentication, connection limits and the existing fallback messages.error: storage errors, failed deliveries and internal errors.
Sessions carry proto, a random session_id, remote and local.
remote holds the client address supplied by the trusted PROXY sender;
for direct access it holds the TCP peer. Successful logins include the
canonical user. The session ID stays the same across IMAP
UNAUTHENTICATE, only the user binding is removed.
connection_close carries the session duration in milliseconds. For LMTP,
the backend session begins after LHLO. The tls field describes TLS to
the backend, not a TLS channel already terminated at the Director.
Not even debug logs command arguments, SASL data, passwords, tokens,
script texts or mail contents. The only exception is the time-limited
protocol trace that is switched on explicitly per account
(event=protocol_trace, see Admin API):
it contains the protocol lines after login, with literals recorded only as
a size. Unknown commands are named UNKNOWN. The logs contain client IP
addresses and account names that are necessary for operations; these fields are not used as metric labels.
Example for Loki (replace the client IP with the public IP you are looking for):
{namespace="shardpost", app_kubernetes_io_name="shardpost-server"} | json | remote =~ "203\\.0\\.113\\.10:.*"
For further correlation, filter by session_id. At info, NOOP/FETCH/IDLE
produce no individual command logs; open, login and close remain visible.
Connections that were already closed before the rollout cannot be
reconstructed after the fact. Changing the level requires a restart.
Protocol trace (trace)
| Key | Default | Meaning |
|---|---|---|
trace.max_line_bytes |
2048 | logged length of a line (80–65536) |
trace.default_duration_seconds |
1800 | duration when the call does not specify one |
trace.max_duration_seconds |
86400 | upper bound for every activation (60–604800) |
trace.recheck_seconds |
60 | how often a traced session re-reads the switch |
YAML only. It is switched on through the Admin API (role trace), see
Admin API.
Namespaces
| Key | Default | Meaning |
|---|---|---|
server.shared_prefix |
Shared/ |
client-visible prefix of the mailboxes other accounts share, addressed as <prefix><account>/<mailbox> (must end with the separator); empty takes the namespace away from clients |
A prefix is one level and ends with the separator, for example
Other Users/ (Exchange, older Dovecot setups) or shared/ (Dovecot's
own default) — a server whose users come from another one keeps their
names by setting what they are used to. Changing the
prefix renames what clients see: the mailboxes and their permissions
stay as they are, but every client re-discovers the folders under the
new name and treats them as new ones locally — local caches, folder
settings and client-side filter rules that name a folder path have to
be redone. Subscriptions survive: they are kept per account and per
mailbox, not by name.
A prefix may contain non-ASCII characters. Like every mailbox name it
reaches IMAP clients that enabled neither UTF8=ACCEPT nor IMAP4rev2
in modified UTF-7 (Öffentlich/ is &ANY-ffentlich/ in NAMESPACE
and LIST), all other clients in UTF-8.
With an empty prefix no client can reach a shared mailbox, although the
permissions remain in the store and the admin API still shows them. The
same prefix switches the shared accounts of JMAP: with it empty, the
JMAP session lists none (see JMAP: Sharing); so does
an empty server.public_prefix for the public account.
Before changing a prefix on a running server, check that no account owns a mailbox below the new one:
shardpostctl mailbox list <account> | grep -E '^(Shared|Public)/'
Such a mailbox would still be listed but could no longer be opened: its
name resolves to the other namespace. Rename it first
(shardpostctl mailbox rename <account> <old> <new>); the server also
logs a warning, with the mailbox name, when it lists one. Creating,
renaming or importing a mailbox below a prefix is refused by the admin
API for the same reason; over IMAP such a name never reaches the
account's own namespace at all, so CREATE "Shared/Sub" answers
NONEXISTENT instead.
While a namespace is switched off its prefix is not reserved, so an account can create a mailbox there in the meantime. Switching it back on is therefore the same migration step as changing it: check first.
Version 0.26.1 changed the default from Other Users/ to Shared/. A
server that was running before it keeps the old name by setting
server.shared_prefix: "Other Users/" explicitly.
Share notifications
| Key | Default | Meaning |
|---|---|---|
server.share_notifications_max |
50 | share notifications an account keeps (at most 200); a new one beyond it removes the oldest |
server.share_notifications_retention_seconds |
2592000 | how long one is kept (30 days) |
A change of the rights an account holds on another account's mailbox
or on a public folder — through IMAP SETACL or DELETEACL, the admin
API or JMAP shareWith — leaves a notification in that account (RFC
9670 3), which a JMAP client reads as ShareNotification (see
JMAP: Sharing). The notifications of an
account are one server-managed METADATA entry of its own namespace,
bounded by these two settings and by the 64 KiB of one entry; a second
change of the same mailbox replaces the first. Writing one is best
effort: an ACL change never fails because of it, and a failure is
logged (share notification not written).
What an account starts with, and what it is subscribed to
| Key | Default | Meaning |
|---|---|---|
server.autocreate |
the five below | mailboxes every account gets besides its INBOX |
server:
autocreate:
- { name: Drafts, special_use: "\\Drafts" }
- { name: Sent, special_use: "\\Sent" }
- { name: Trash, special_use: "\\Trash" }
- { name: Junk, special_use: "\\Junk" }
- { name: Archive, special_use: "\\Archive" }
- { name: Projekte, subscribe: false }
Each entry has a name, an optional special_use attribute (RFC 6154,
so a client finds the mailbox whatever it is called) and subscribe,
which defaults to true. This is Dovecot's auto = create versus
auto = subscribe, in one place instead of one block per mailbox. An
empty list (autocreate: []) leaves an account with its INBOX.
Two rules decide when this happens:
- A mailbox is created when the account logs in and does not have it, so a mailbox added to the list reaches the accounts that log in afterwards, not only new ones. A mailbox the account deleted comes back the same way.
- A mailbox is subscribed only when it is created, never afterwards. An account that unsubscribed one keeps it unsubscribed; the server does not argue with its user.
Subscriptions are per account everywhere else too: a mailbox of another
account carries its subscription in the pointer, a public folder in the
reading account's registry by mailbox id, so a folder that is renamed
stays subscribed. shardpostctl mailbox subscribe sets the flag for an
account from outside, which is how a migration hands over what the old
server had.
Public folders
| Key | Default | Meaning |
|---|---|---|
server.public_prefix |
Public/ |
client-visible prefix of the public namespace (must end with the separator); empty switches public folders off entirely, for IMAP and JMAP alike; without the delimiter it names the public account in a JMAP session |
server.public_quota_bytes |
0 | limit of the public namespace as a whole, 0 = unlimited |
server.public_quota_msgs |
0 | the same as a message count |
Public folders live in the store namespace ~public and belong to no
account, so they do not count against a user's quota. They are created
and granted through the admin API, see
Admin API: public folders.
Maintenance jobs (jobs)
| Key | Default | Meaning |
|---|---|---|
jobs.enabled |
true | the job system in this process: the jobs API (submit, list, cancel, plans) and the runner; false answers the jobs API and the directory repair with 503. To move the running off a server, set workers.run: false instead (below), which keeps the API |
jobs.poll_seconds |
5 | interval between scans for new jobs; with admin.kv_index: read a runner is also woken at once when a job is submitted or handed over at shutdown, and the poll is the fallback |
jobs.lease_seconds |
60 | how long a claim holds without renewal (another server may take over afterwards); the expiry worker's lock lives this plus 120 s |
jobs.retention_days |
14 | retention of finished jobs, their idempotency keys and plans (after expires) |
jobs.expiry_seconds |
60 | how often the expiry worker (one server of the cluster, admin.kv_index write or read) deletes the jobs, idempotency keys and plans whose retention ended, and how often the other servers try to take its lock (5–3600) |
jobs.plan_ttl_seconds |
3600 | how long a plan stays valid |
jobs.max_parallel |
2 | concurrent jobs per process; environment JOBS_MAX_PARALLEL (0.52.0), so that the runner deployment gets a value of its own — a configmap value reaches the front-ends as well, where it is without effect under workers.run: false |
jobs.settle_seconds |
10 | how long an exclusive job waits after setting the maintenance lock, so that running deliveries can finish and open IMAP/POP3 sessions notice the lock (they check it at most every 5 s when a command starts and disconnect with BYE) |
| jobs.batch_parallel | 2 | how many accounts a batch over all accounts works on at once in the server that runs it (1–32; a batch scrub uses scrub.parallel) |
| jobs.mailbox_delete_online | off | what a plan of mailbox_delete without online gets (since 0.52.0): auto the mailbox lock while the cluster mark mailbox/lock is set (only the mailboxes it removes are locked, users notice nothing else), off the account lock as before. auto needs jobs.settle_seconds of at least 7, the time IMAP sessions take to see the lock. online: true needs the mark either way (409 without) |
| jobs.mailbox_lock_barrier_seconds | 120 | how long a mailbox_delete with the mailbox lock waits for the operations that started before its lock (10–900); after it the job is given back and tried again later |
YAML only, except jobs.max_parallel. For operating them see
Admin API: maintenance jobs
and batches over all accounts.
Background workers (workers.run)
| Key | Env | Default | Meaning |
|---|---|---|---|
workers.run |
WORKERS_RUN |
true | this server runs the background workers beside its listeners: the job runner with the expiry and fts_due workers, the upload expiry, the delayed submission worker and the learning worker. false makes it a front-end in a deployment with job runner pods (shardpost runner, 0.52.0): it keeps the jobs API, the registration, the auth flush and every writer of the queues (learning Put and notify, job submit and notify, uploads, fts_due, submissions, push subscriptions) and runs no worker |
workers.run is the switch of the topology; jmap.submission.delayed.worker
switches one worker inside a process that runs workers. workers.run: false takes precedence: a front-end runs no submission worker whatever
delayed.worker says, and is not warned about it; it warns once at
startup when no job runner and no server with workers is registered.
Set it through the environment (the runner component does): a way
back to an image before 0.52.0 must not break on an unknown configmap
key (the configmap is read strictly, see the strict configmap).
See operations: job runners.
The strict configmap
The configuration file is decoded strictly: a key the image does not
know refuses the start. A new key therefore goes into the configmap only
once no older image can run any more, and a key that switches a
topology (workers.run) or sizes one deployment apart from another
(jobs.max_parallel) comes as an environment variable, which an older
image ignores.
Quota recount (quota)
The online recount of quota_recalc (since 0.52.0, see Quota:
recounting the usage and the runbook in
operations):
| Key | Default | Meaning |
|---|---|---|
quota.online_recount |
off |
what a plan of quota_recalc without online gets: auto online while the cluster mark quota/online is set (no account lock; the writers capture their changes for the run), off offline under the account lock as before 0.52.0. online: true needs the mark either way (409 without) |
quota.recount_barrier_seconds |
120 | how long an online run waits for the reservations and MOVEs that started before it (10–900); after it the run is busy and planned again |
quota.recount_dead_intent_seconds |
600 | how old an intent may be before a run takes it for abandoned (60–3600, at most scrub.intent_grace_seconds); every such intent counts imapceph_quota_recount_dead_intents_total |
quota.recount_switch_attempts |
10 | switch calls (of 16 compare-and-swap rounds each) before a run holds the usage writers (1–50) |
quota.recount_hold_ms |
2000 | how long a run holds the usage writers at most while it switches (0–5000; 0: never, the run is busy instead); logins, flag writes and searches never wait |
quota.recount_max_capture_keys |
1000 | a run whose captures grow beyond this many registry keys ends (10–100000); the writer that crosses the bound ends it |
quota.recount_max_capture_entries |
1000000 | the same for the entries in the captures (1000–10000000) |
quota.recount_release_chunk |
10000 | entries one capture key holds (100–50000; keeps a value below 1 MiB) |
The other quota keys (grace, rules, warnings, the over flag) are in
Quota.
Scrub (scrub)
| Key | Env | Default | Meaning |
|---|---|---|---|
scrub.users |
SCRUB_USERS |
"" | accounts shardpost scrub scrubs, comma-separated; empty: every account (not with --batch) |
scrub.grace_seconds |
SCRUB_GRACE_SECONDS |
3600 | objects younger than this are left alone (operations in flight); the batch scrub uses the servers' value; with scrub.orphan_source: intents and for scrub --sweep --repair at least 600 apply |
scrub.dry_run |
SCRUB_DRY_RUN |
false | report only; scrub --batch then submits a batch of index_check |
scrub.parallel |
SCRUB_PARALLEL |
2 | accounts a batch scrub works on at once in the server that runs it (1–32) |
scrub.interval_days |
SCRUB_INTERVAL_DAYS |
7 | a batch scrub with skip_idle scrubs an idle account again at the latest after this many days (1–365) |
scrub.orphan_source |
SCRUB_ORPHAN_SOURCE |
shadow |
where a scrub finds what a crash left behind: listing lists the account's namespace in both pools (before 0.42.0), shadow lists and repairs from the listing and reports where the intents differ, intents reads the intents only and lists nothing; intents only when every server runs 0.42.0 or later (nothing checks it: messages that older servers move or expunge would look like orphans) |
scrub.intent_grace_seconds |
SCRUB_INTENT_GRACE_SECONDS |
3600 | how old an intent must be before a scrub takes it for the leftover of a crash (600–604800) |
scrub.sweep_clock_margin_seconds |
SCRUB_SWEEP_CLOCK_MARGIN_SECONDS |
60 | how far before reading a namespace scrub --sweep draws the line after which no object is a remnant, when that read is later than now less the grace period (1–3600); object mtimes have whole seconds and come from the writer's clock |
scrub.sweep_spill_shards |
SCRUB_SWEEP_SPILL_SHARDS |
256 | spill objects per pool of a new shardpost scrub --sweep (1–4096); more keep each smaller for a cluster with many objects |
purge.listing |
PURGE_LISTING |
true | a purge lists the account's namespaces and deletes what it finds, as before 0.42.0, and reports what the data model does not name; false deletes what the data model names and lists nothing (see operations) |
scrub.parallel, scrub.interval_days, scrub.orphan_source and
scrub.intent_grace_seconds take effect in the servers that run the
batch, not in the pod that submits it. For the switch from shadow to
intents see operations.
IMAP watches (server.imap_max_watches)
| Key | Env | Default | Meaning |
|---|---|---|---|
server.imap_max_watches |
IMAP_MAX_WATCHES |
8 | change watches one IMAP connection follows (2–1024): IDLE takes one, which is always kept free for it; NOTIFY one, two with a selected mailbox of another account, or one per notified mailbox when the account object cannot be watched (NOTIFY SET then answers NO [LIMIT] if they do not fit, and the earlier NOTIFY state stays); beyond it IDLE polls |
The sessions of a process share their watches: one RADOS watch per
mailbox or account object however many sessions follow it (see
storage and the
metric imapceph_imap_watches).
Admin clients with the scope of all accounts
admin.clients[].all_accounts: true (YAML only) lets an mTLS client
list the account directory and plan, apply and cancel batches over every
account, each with its role; it excludes admin.clients[].accounts.
Socket callers always have it. See
Admin API.
Support tools (admin.search_scan_limit)
| Key | Default | Meaning |
|---|---|---|
admin.search_scan_limit |
50000 | a search request examines at most this many messages (role content); a longer search resumes with its cursor |
YAML only. See Admin API: support tools.
Admin store indexes (admin.kv_index)
| Key | Default | Meaning |
|---|---|---|
admin.kv_index |
read |
off: no hint indexes, no due queues, no expiry worker (the behaviour before the indexes); write: keep them up to date while readers still list the admin namespace (and the listing cleanup of old jobs and plans runs on every runner poll); read: also read from them (job runner, job lists, expiry of old records, push subscription scan) and list nothing on a running path. Environment: ADMIN_KV_INDEX |
admin.kv_queue_shards |
4 | shard objects of a due queue (1–8) when its first writer creates it; an existing queue keeps its count |
read is the default. It is correct on a cluster whose
shardpost index backfill kv completed after the last server without
the indexes (before 0.38.0, or with admin.kv_index: off) stopped.
Without the completion marker a server configured for read logs a
warning and behaves as in write mode until the marker exists (it
looks for it every 30 seconds), so the gate keeps such a cluster on
write until the backfill ran. A new installation runs the backfill
once as well (it finds no records and completes at once). Set the same
mode on every server (a server in read mode does not see what a server
in off mode writes). Back to write is always possible. See
Storage: admin store
and Operations.
Learning queue index (imapsieve.learning.index)
| Key | Default | Meaning |
|---|---|---|
imapsieve.learning.index |
read (write before 0.47.0) |
off: no due queue, no owner index (the behaviour before); write: keep them up to date while the learning worker, the admin API's learning list and the account purge still list the namespace ~learning of the data pool; read: also read from them (the worker is woken by a notify instead of listing every second). Environment: LEARNING_INDEX |
imapsieve.learning.queue_shards |
4 | shard objects of the due queue (1–8) when its first writer creates it; an existing queue keeps its count |
imapsieve.learning.lock_retry_seconds |
20 | how often a process that does not hold the learning worker lease tries to take it, with a jitter of ±50 % (1–3600; environment LEARNING_LOCK_RETRY_SECONDS). The holder works on once a second and when notified (since 0.52.0 only the holder watches the queue); a lease lost to a key rotation is taken again at once |
imapsieve.learning.concurrency |
1 | learning jobs the holder of the lease runs at once (1–32; environment LEARNING_CONCURRENCY, 0.52.0): jobs of different accounts in parallel, jobs of one account one after the other in due order (a semaphore per account, tried without waiting: a job whose account is busy is offered again at the next pass), never the same job twice at once. The program phase is bounded by the learning program's extprograms max_parallel as well (4 without one): set it to at least the concurrency, or the concurrency is not reached (the server warns). A large cluster runs 8 on its runners; two combined pods run 2 |
imapsieve.learning.shutdown_seconds |
10 | how long running learning jobs get to end when the process stops (1–300; environment LEARNING_SHUTDOWN_SECONDS); what still runs is cut off and the next holder runs it again (at least once, as before) |
read takes effect only once shardpost index backfill learning
completed (with a start after the last server without the index
stopped) or shardpost system init wrote the completion marker of the
empty queue of a new cluster. Without the marker a server in read
mode logs a warning and behaves as in write mode until the marker
exists, so the default read changes nothing on a cluster that has not
run the backfill. A cluster of the size of millions of accounts runs
with admin.kv_index: read and imapsieve.learning.index: read: in
write mode the learning worker lists the namespace ~learning of the
data pool every second, a walk over every placement group of the
largest pool.
See Storage: learning queue
and Operations.
Account limits (limits)
Upper bounds on what one account can grow. They keep the omap objects
that hold an account's state (the mailbox registry, a mailbox header,
the METADATA object) far below the size at which Ceph reports
LARGE_OMAP_OBJECTS and one account slows down the OSD that serves it.
The defaults are far above what accounts reach in normal use; 0
switches a limit off.
| Key | Default | Meaning |
|---|---|---|
limits.mailboxes |
10000 | mailboxes of an account. INBOX is never refused. Environment: LIMITS_MAILBOXES |
limits.mailbox_names |
50000 | distinct names an account ever created a mailbox under. The registry keeps the last UIDVALIDITY of every such name for good (so that a mailbox recreated under the same name never reuses one); a name used before can always be used again, RENAME does not count. Environment: LIMITS_MAILBOX_NAMES |
limits.keywords_per_mailbox |
1000 | user keywords one mailbox knows. The keyword list of a mailbox only grows (it is what PERMANENTFLAGS and JMAP show), so the limit counts every keyword ever used in the mailbox; keywords it already knows and system flags are always accepted. Environment: LIMITS_KEYWORDS_PER_MAILBOX |
limits.metadata_entries |
1000 | METADATA entries (RFC 5464) of an account: server and mailbox entries, shared and private, together. Only the number counts (each value is bounded to 64 KiB on its own). Entries the server keeps for itself (/private/vendor/shardpost/jmap/...: JMAP identities, vacation response, Sieve scripts, share notifications) count but are never refused. Environment: LIMITS_METADATA_ENTRIES |
limits.shared_pointers |
10000 | mailboxes of other accounts shared with an account (the pointers ACL grants write into the grantee's registry). A grant that would add one more is refused on the granting side, before the ACL changes; changing the rights of an existing grant, anyone and public folders need no pointer. Environment: LIMITS_SHARED_POINTERS |
What a client sees when it reaches a limit is listed in
Protocols: account limits. Delivery never
loses mail to a limit: a Sieve fileinto that would have to create a
mailbox beyond limits.mailboxes or limits.mailbox_names goes to
INBOX like any other failed fileinto, and keywords a Sieve script adds
beyond limits.keywords_per_mailbox are dropped (the message is stored
with its system flags). The autocreated mailboxes (server.autocreate)
are skipped for an account at its mailbox limit.
An account import (admin API) is held to the mailbox limits, which it checks before it writes anything for a mailbox, but not to the keyword limit: an import reproduces the source mailbox as it is, and refusing it halfway would leave messages whose keywords the mailbox does not list.
The keyword limit is checked in the same compare-and-swap as the change that adds the keywords, so it holds exactly (a MOVE checks it before it starts and then completes). The other limits count omap keys before the write and are advisory: writers that race past the check together can exceed a limit by one entry each.
Full-text index (fts)
| Key | Default | Meaning |
|---|---|---|
fts.fallback_charset |
windows-1252 |
charset of text that is not valid UTF-8 and has no charset label, is labelled US-ASCII or names a charset that is not known (body parts, file names, raw 8-bit header values); any ASCII-compatible charset of the WHATWG list, e.g. iso-8859-15, iso-8859-2, koi8-r; UTF-8, UTF-16 and replacement are refused |
fts.html |
text |
what is made of a text/html part: text its visible text (tags, comments, script, style, template and title dropped, character references decoded, a line break at every block element), raw the markup as it is (what earlier versions indexed), skip nothing |
fts.imap_search |
substring |
how IMAP SEARCH matches text: substring as RFC 3501 and RFC 9051 specify (the index narrows the candidates, every candidate is checked); word lets the index answer SUBJECT, BODY, TEXT and HEADER Subject alone, by word, stem and part of a word |
fts.verify_parallel |
8 | how many messages one IMAP SEARCH checks against its text keys at the same time (1–64), each read under server.memory_budget_bytes |
fts.layout |
3 |
object layout of indexes created from now on: 2 the fixed shards of every version before 0.45.0, 3 the adaptive index generations (see storage); 3 takes effect only while the cluster mark is set (shardpostctl fts layout3 enable), otherwise new indexes stay in layout 2. The default is 3 since 0.46.0 (before: 2), so a cluster without the mark keeps creating layout 2 indexes and the mark alone switches it; 2 keeps layout 2 even with the mark (the first step of the way back). The startup log line full-text layout of new indexes and the metrics imapceph_fts_layout_configured and imapceph_fts_layout_effective show the result. The layout of an existing index is the one it records; fts_layout jobs convert |
fts.shards_min |
1 | posting, dictionary and header objects of a fresh layout 3 index without a size hint (1–4096) |
fts.shards_max |
128 | upper bound of the posting and header objects (S, H) a reshard chooses (shards_min–4096; 32 before 0.49.0, when it bounded the dictionary objects as well); at the cap the trigger is silent and the objects grow past fts.shard_target_keys (metric imapceph_fts_shards_capped_total{kind}, a log warning, once per index generation and server) |
fts.dict_shards_max |
512 | upper bound of the dictionary objects (D) a reshard chooses (shards_min–4096, since 0.49.0); at the cap as fts.shards_max |
fts.shard_target_keys |
100000 | keys a layout 3 posting or header object should hold (T_p, 10000–1000000). A writer that sees an object with more than 1.5·T_p keys queues the account for a reshard, so a value above 133000 is accepted with a startup warning: from 200000 keys per object Ceph reports large omap objects unless osd_deep_scrub_large_omap_object_key_threshold is raised. Up to about 130000 is sensible for large accounts (fewer posting objects a delivery touches) |
fts.dict_target_keys |
100000 | keys a layout 3 dictionary object should hold (T_d, 10000–1000000, since 0.49.0; warning above 133000 as fts.shard_target_keys): more than 1.5·T_d dictionary keys per dictionary object ask for a reshard. The writers decide per object, the plan of fts_layout by the sums, so plan -due-only can find an account due before its writers queue it (see storage) |
fts.convert_buffer_keys |
262144 | least number of keys the copy of a conversion (fts_layout, phase P5) holds before it writes them (16384–4194304, since 0.49.0); it holds up to 1024 per target object when that is more, so that a write carries about 1024 keys or more. The largest pending piece is written early when the buffer is full (imapceph_fts_convert_buffer_keys) |
fts.convert_writers |
8 | writes of a conversion's copy in flight at a time (1–64, since 0.49.0) |
fts.stats_test_accounts |
empty | accounts whose index terms shardpostctl fts stats -classes may classify: path.Match patterns on the lower-cased account, such as bench-* or rbdpoc2@exampleserver.de. For every other account fts stats only counts keys and refuses --classes; terms are never printed (since 0.49.0) |
fts.auto_reshard |
true |
the fts_due worker plans fts_layout jobs for the queued accounts (it runs with the job system) |
fts.frozen_wait_seconds |
10 | how long one indexing (a delivery, an import batch, a removal) waits, once, for a conversion that froze the account's index before it fails with a temporary error (0–60; the undo of a delivery, the upload expiry and the scrubber's orphans never wait) |
fts.import_reshard_sync_seconds |
60 | how long POST /admin/v1/accounts/{account}/import/fts-reshard works before it answers 202 and goes on in the background (1–90; below the 2 minute default client timeout of shardpostctl) |
fts.dict_min_terms |
0 | reserved for a later stage (dictionary only from a vocabulary size); no effect yet |
fts.dict_filter |
none |
identifier filter of the substring dictionary of new layout 3 index generations (since 0.50.0): none lists every body word under each of its trigrams (exact substring search), ids gives a body word with letters and digits of at least fts.dict_id_min_runes characters one start entry instead, so that it is found as a whole word and by its start but not by a part from its inside (see storage, protocols). ids takes effect only while the cluster mark is set (shardpostctl fts dictfilter enable); existing generations change with fts_layout (-dict-filter, or plan -all -due-only fts_layout) |
fts.dict_id_min_runes |
25 | least length in characters of such an identifier (13–40, since 0.50.0); also the length fts_layout -dict-filter ids uses. A generation keeps the length it was built with, so a change makes every filtered generation due for plan -all -due-only fts_layout: a full copy of each under its account lock. Choose it once |
fts.attached_headers |
all |
which header fields of attached messages (message/rfc822, message/global, text/rfc822-headers) belong to the body text (since 0.50.0): all every field, nosig all but the signature and trace fields DKIM-Signature, ARC-Seal, ARC-Message-Signature, ARC-Authentication-Results, Authentication-Results, Received, Received-SPF, X-Google-DKIM-Signature and every X-*-Signature. IMAP SEARCH checks messages by it at once; new index generations are built with it while the cluster mark of the dictionary filter is set, existing ones change with fts_rebuild. A server with all does not trust an index built with nosig (IMAP reads the messages instead). Practically one-way at scale: back to all takes fts_rebuild of every account built with nosig, each reading all its messages under its account lock |
Online runs and the reshard window (since 0.51.0). With the cluster
mark fts/online (shardpostctl fts online enable) fts_layout and
fts_rebuild can run without the account's lock: deliveries, logins and
sessions go on while the target generation is built beside the current
one (see storage).
| Key | Default | Meaning |
|---|---|---|
fts.online_build |
off |
what a plan of fts_layout or fts_rebuild without online gets: auto online while the cluster mark is set (and, for one account, its index has layout 3), off offline under the account lock as before 0.51.0. A plan records the decision; online: true needs the mark either way (409 without). The fts_due worker plans without online, so with auto its reshards run online |
fts.reshard_window |
empty | daily window HH:MM-HH:MM (over midnight allowed, e.g. 23:00-04:00; empty: always) in which the fts_due worker plans its reshards and a batch of fts_layout or fts_rebuild starts an account (plan -all … -anytime ignores it). An account whose largest object holds fts.reshard_urgent_factor times its target is planned at once, in or out of the window and whatever build_max_running and the estimate say; outside the window the worker moves the others to the next window start plus a stable spread per account (within the first half of the window); a batch gives itself back until the next window starts and goes on from its checkpoint. A run starts only when its estimate (build_ms_per_message_* × the documents of the account) fits into what is left of the window; one longer than the whole window is not started (window_too_small: a log line, imapceph_fts_build_window_too_small_total, the worker looks again at the next window start plus the spread, so once a day until the account is planned on its own or the window widened; a batch fails the account). A run an operator plans for one account starts at once. A run that overruns its window ends normally |
fts.reshard_window_tz |
UTC |
IANA time zone of the window (e.g. Europe/Berlin; the binary carries the zone data). On the days the clocks change the window is an hour shorter or longer |
fts.reshard_urgent_factor |
1.8 | load at which the fts_due worker reshards an account at once (1.5–1.95): its largest index object holds this many times its target, near the 2·T (200000 keys at the default target) at which Ceph reports large omap objects. With a window only; without one the worker plans every due account at once anyway |
fts.build_max_running |
1 | jobs the fts_due worker has planned that may run at the same time (1–64), counted in the memory of the server that holds the worker's lease (after a takeover briefly up to twice as many); applies with a window |
fts.build_catchup_rounds |
5 | switch attempts of an online run (1–50): each round indexes what writers recorded since the last one; when the switch still finds new records after these rounds, the run holds new id allocations for fts.build_hold_ms, catches up once more and switches (at most three holds, then the run fails without a change) |
fts.build_hold_ms |
2000 | how long a hold delays new deliveries and appends of the account before the switch (0–5000; 0 never holds: a run that does not converge fails after its rounds). Writers never wait longer than 5 s, whatever the layout record says |
fts.build_ms_per_message_copy |
3 | estimate of an online fts_layout per document (0.1–1000), to decide whether it fits into the window |
fts.build_ms_per_message_reindex |
20 | the same for an online fts_rebuild (1–10000) |
fts.reindex_batch_docs |
200 | messages an online rebuild indexes in one batch (50–5000) |
fts.reindex_batch_bytes |
8388608 | bytes of messages an online rebuild indexes in one batch (1 MiB–64 MiB) |
New keys go into the overlay only after the image that knows them is
running (the configuration is read with UnmarshalExact); on the way
back they leave the overlay first.
YAML only. server.fts_shards keeps the object count of layout 2, which
is also the target of a conversion back from layout 3.
The text. One preparation serves the index, IMAP SEARCH keys
checked against the message, the JMAP header filter and the cached
preview, so they agree:
- Header values are unfolded and decoded: RFC 2047 encoded words in
any charset of the WHATWG list, raw 8-bit text in
fts.fallback_charset. The index has the header field names as well, because IMAPTEXTcovers the whole header. The JMAP Text form of a header value (header:…:asText) is decoded the same way. - Every
text/*part is converted from its charset parameter to UTF-8. Atext/htmlpart whose label is missing, US-ASCII or unknown is read in the charset its<meta charset>or<meta http-equiv="Content-Type">names. A part labelled with a single-byte charset (ISO-8859-x, Windows-125x, KOI8, …) whose bytes are valid UTF-8 with at least one non-ASCII character is kept as UTF-8: that is UTF-8 under a wrong label, which is common, far more often than Latin-1 text that happens to be valid UTF-8. - The file name of every part (
Content-Typename,Content-Dispositionfilename, RFC 2231 in any charset, RFC 2047 words) and an attached message (message/rfc822,message/global) — its header asName: valuelines and its body text — belong to the body text, as does the text of atext/rfc822-headerspart; withfts.attached_headers: nosigwithout their signature and trace fields. Other MIME part header fields do not. - Bounds: at most 4 MiB of text per message; a part is read up to 4 MiB, a part other than HTML only as far as there is room left for its text, and all parts of a message together up to 8 MiB, before conversion. Words of more than 40 characters are indexed as overlapping pieces (see storage).
The preview is cut from the text parts alone (no file names, no
attached messages), and of a multipart/alternative from one part: the
plain text where it has text, else the HTML text (RFC 8970). It follows
fts.html: with text an HTML part gives its visible text (without its
title), with raw its markup, with skip nothing, so an HTML-only
message has an empty preview.
IMAP SEARCH and what it costs. With substring, a text key matches
when its value is a substring of the decoded text, without regard to
case. The index supplies candidates — never fewer than the messages
that match — separately for the header and the body of a key, and
combines them over AND, OR and NOT. Each candidate is checked:
- the header against the decoded header the index keeps per message (read for 256 messages at a time, no message is read);
- the body by reading the message, only where the body candidates include it.
A word of the value narrows the candidates when the index can look it
up completely: a whole word always; the start of a word unless more
than 4096 index terms begin with it; the inside of a word in the
subject, the addresses and the body when it has three or more
characters and lies inside at most 4096 index terms. The inside of a word of another header
field cannot be looked up, so a TEXT or HEADER <name> value that
may begin inside a header word (any value not starting with a space or
punctuation) checks the stored header of every message of the mailbox —
a few omap reads per 256 messages, cheap next to reading them — and
reads only the messages the body index allows. A key with no word the
index can narrow by (BODY ab, a single letter) reads every message of
the mailbox. fts.verify_parallel messages are checked at once, each
under the memory budget; a message that is expunged meanwhile is no
match, and a read the budget refuses is retried before the command
fails. A search stops when its client closes the connection (noticed
without reading what the client sends; not in a session with
COMPRESS=DEFLATE) or the server closes it; the session then ends
without an answer. word costs index lookups only and answers what earlier versions
answered: all words of the value in any order, by stem and by part of
a word.
Existing messages. The index keeps what a message was indexed
with. An account indexed entirely by this version carries a version
marker in its index (set for a new account with its first message and
by fts_rebuild when it completes; a rebuild that stops midway leaves
the account without it). Without the marker, substring search does not
use the index at all — an older index (HTML markup, undecoded words, no
file names) cannot be trusted even for whole words — and checks every
message of the mailbox, reading the headers of messages without a
stored header from the messages themselves: correct, but slow, until
the rebuild. A changed fts.fallback_charset or fts.html reaches
messages stored before only when they are indexed again; until then
substring search can miss such a message where the index lacks its new
words. Removing a message indexed
under other rules leaves terms it was indexed with behind (harmless;
the rebuild drops them). Run the rebuild for every account after the
upgrade, see Admin API: maintenance jobs:
shardpostctl plan fts_rebuild user@example.org
shardpostctl apply -wait <plan-id>
The rebuild does not recompute the cached preview of an existing message. The index, including the stored headers, is in the index pool and does not count against the quota.
JMAP search bounds (jmap.search)
| Key | Env | Default | Meaning |
|---|---|---|---|
jmap.search.snippet_read_bytes |
JMAP_SEARCH_SNIPPET_READ_BYTES |
262144 | how much of each message, from its front, SearchSnippet/get reads to find a match in the body; a match beyond it gives no preview |
jmap.search.max_attachment_reads |
JMAP_SEARCH_MAX_ATTACHMENT_READS |
1000 | messages one Email/query may read to answer hasAttachment for a message stored by an earlier version, whose index record does not carry it; a query past the bound remembers what it read, starts a background fill of the namespace and answers serverUnavailable until the fill is through |
jmap.search.attachment_backfills |
JMAP_SEARCH_ATTACHMENT_BACKFILLS |
2 | background fills of hasAttachment that run at once in a process (one per namespace); a namespace that finds no free slot is filled when a query asks again |
jmap.search.attachment_backfill_seconds |
JMAP_SEARCH_ATTACHMENT_BACKFILL_SECONDS |
600 | how long one background fill may run; what it wrote stays, and the next fill goes on from there |
0 takes the default. The maintenance job attachment_backfill fills an
account ahead of time (Admin API).
A SearchSnippet/get reads at most
jmap.max_objects_in_get messages, so one call reads at most that many
times jmap.search.snippet_read_bytes. See JMAP: Search snippets
and JMAP: hasAttachment. The other jmap.* keys
are described in JMAP: Configuration.
JMAP load (jmap.*)
| Key | Env | Default | Meaning |
|---|---|---|---|
jmap.max_concurrent_requests |
JMAP_MAX_CONCURRENT_REQUESTS |
8 | API requests one account runs at once in one server; advertised as maxConcurrentRequests |
jmap.max_concurrent_upload |
JMAP_MAX_CONCURRENT_UPLOAD |
4 | uploads at once, Blob/upload included; advertised as maxConcurrentUpload |
jmap.max_concurrent_downloads |
JMAP_MAX_CONCURRENT_DOWNLOADS |
8 | blob downloads at once |
jmap.max_event_sources |
JMAP_MAX_EVENT_SOURCES |
4 | open event sources |
jmap.limit_wait |
JMAP_LIMIT_WAIT |
2 | seconds a request queues for a slot at a full limit (0–10; 0 answers 429 at once); event sources never wait |
jmap.retry_after |
JMAP_RETRY_AFTER |
1 | Retry-After in seconds of a 429 and of a 503 from an undecided authentication (1–3600) |
jmap.max_concurrent_per_ip |
JMAP_MAX_CONCURRENT_PER_IP |
0 | requests one client address has in flight, checked before authentication; an event source gives its slot back once it holds its own, so open streams do not count; 0 is off |
For the four limits 0 takes the default. A queue holds at most twice
its limit; a request past it is answered 429 at once. jmap.limit_wait
stays well below the response timeouts in front (the Director's
backend_response_header, two minutes by default, and HAProxy's
timeout server). With jmap.max_concurrent_per_ip set and
server.proxy_protocol_trusted_cidrs empty the server warns at startup:
behind a proxy every client then has the proxy's address. See
JMAP: Load.
Delayed sending
JMAP delayed sending (FUTURERELEASE, see JMAP
and operations):
| Key | Env | Default | Meaning |
|---|---|---|---|
jmap.submission.delayed.max_delay_seconds |
JMAP_SUBMISSION_DELAYED_MAX_DELAY_SECONDS |
0 | the longest delay a client may ask for with HOLDFOR/HOLDUNTIL, advertised as maxDelayedSend (0–31536000); 0 offers no delayed sending. Needs jmap.submission.relay_addr and admin.kv_index write or read |
jmap.submission.delayed.worker |
JMAP_SUBMISSION_DELAYED_WORKER |
true | this process runs the submission worker (with a relay); it runs also with max_delay_seconds 0, so that what is queued is still sent. workers.run: false takes precedence: a front-end runs none |
jmap.submission.delayed.partitions |
JMAP_SUBMISSION_DELAYED_PARTITIONS |
1 | worker partitions: the accounts by hash, each partition with a lease of its own (1–64); all servers should use the same value (a difference costs only duplicate reads: the record is the fence) |
jmap.submission.delayed.max_partitions |
JMAP_SUBMISSION_DELAYED_MAX_PARTITIONS |
0 | partitions one worker holds at most (0: all; at most partitions). The workers times max_partitions must reach partitions, or a partition nobody holds is never sent (counted as imapceph_jmap_submission_queue_entries{state="unowned"} and warned about); for one worker to be able to fail, max_partitions ≥ ⌈partitions / (workers − 1)⌉. Keep the default 0 (every worker takes what is free; the random start spreads them) unless there is a reason: with 16 partitions on four runners, 4 would leave the partitions of a failed runner unsent until it is back |
jmap.submission.delayed.poll_seconds |
JMAP_SUBMISSION_DELAYED_POLL_SECONDS |
30 | how often a worker looks at the queue without a notification, and tries to take free partitions (1–3600); a new submission wakes the workers at once, and a worker sleeps only until the earliest entry it saw is due |
jmap.submission.delayed.sending_timeout_seconds |
JMAP_SUBMISSION_DELAYED_SENDING_TIMEOUT_SECONDS |
3600 | how long a submission may stay in sending before it counts as final with an unknown result and is not sent again (1800–86400); has to exceed the longest conversation with the relay (see below) |
jmap.submission.delayed.concurrency |
JMAP_SUBMISSION_DELAYED_CONCURRENCY |
4 | conversations with the relay one worker holds at once (1–64) |
jmap.submission.delayed.retry_attempts |
JMAP_SUBMISSION_DELAYED_RETRY_ATTEMPTS |
5 | attempts of a submission whose relay failed temporarily before the message was transferred, or whose account directory, registry or message copy could not be read (1–20); the backoff starts at one minute and doubles up to 30 minutes |
The longest conversation with the relay is about ten step timeouts
(jmap.submission.timeout_seconds, 30 by default: connection,
greeting, EHLO, STARTTLS, XCLIENT, MAIL, DATA, the upload), plus the
upload of the largest message at 256 KiB/s (jmap.submission.max_size,
else jmap.max_size_upload: 200 s for 50 MiB), plus ten minutes for the
relay's answer to the message — 1100 s with the defaults — and up to one
step timeout more per recipient when the relay is that slow for each.
sending_timeout_seconds has to stay above it; a server warns at
startup when it does not (counting no recipients). A conversation that
outlasts it is reported as unknown, never sent twice.
Push worker partitions (jmap.push.*)
The JMAP push worker shares 64 partitions among its pods (0.52.0, see JMAP: Push partitions and operations). The push deployment has no configmap; the keys come as environment variables and keep their defaults unless there is a reason.
| Key | Env | Default | Meaning |
|---|---|---|---|
jmap.push.max_partitions |
JMAP_PUSH_MAX_PARTITIONS |
0 | partitions one pod holds at most (0–64; 0: all) |
jmap.push.lease_seconds |
JMAP_PUSH_LEASE_SECONDS |
30 | TTL of a partition lease, renewed every third of it (15–300); a membership record expires after the same time; a holder that could not renew stops the partition five seconds before it is up |
jmap.push.take_seconds |
JMAP_PUSH_TAKE_SECONDS |
5 | cadence of taking and shedding, ± 20 % (1–60); also the deadline of one lock or membership operation |
jmap.push.retry_seconds |
JMAP_PUSH_RETRY_SECONDS |
2 | fast cadence for orphaned, contested and refused partitions (1–60, at most take_seconds) |
jmap.push.orphan_hold_seconds |
JMAP_PUSH_ORPHAN_HOLD_SECONDS |
600 | a partition taken as a replacement is not shed for this long (0–3600) |
jmap.push.full_scan_seconds |
JMAP_PUSH_FULL_SCAN_SECONDS |
3600 | every record of a partition is read once in this time, whatever its checksum says (0: never; else at least scan_seconds) |
jmap.push.rebalance_seconds |
JMAP_PUSH_REBALANCE_SECONDS |
10 | pause between two partitions a pod sheds (1–600) |
jmap.push.rebalance_settle_seconds |
JMAP_PUSH_REBALANCE_SETTLE_SECONDS |
60 | the set of pods has to stand still this long before one sheds (0–3600, at least lease_seconds) |
jmap.push.handoff_seconds |
JMAP_PUSH_HANDOFF_SECONDS |
5 | how long the watchers of a partition get to end when it is given up, and the period of the waves at shutdown (1–30, below lease_seconds − 5 and the termination grace period) |
jmap.push.max_watched_accounts |
JMAP_PUSH_MAX_WATCHED_ACCOUNTS |
50000 | accounts one pod watches at most; it takes no partition that would put it over, by the estimate the membership gives (at least 100) |
jmap.push.start_rate |
JMAP_PUSH_START_RATE |
5000 | watchers a pod starts per second when it takes a partition (10–100000) |
jmap.push.scan_seconds |
JMAP_PUSH_SCAN_SECONDS |
30 | how often a holder reads its shard of the subscription index (as before) |
The relations are checked at startup: lease_seconds ≥ 3 × take_seconds (a partition free for two cadences is contested),
rebalance_settle_seconds ≥ lease_seconds (a dead record is gone
before anyone sheds to it), handoff_seconds < lease_seconds − 5 (a
hand-over ends before the holder would stop the partition itself),
retry_seconds ≤ take_seconds, and full_scan_seconds 0 or at least
scan_seconds. The partition count itself is not configurable: it is
the shard count of the hint index ix.jmap/push/acct (64).
Nauthilus client (nauthilus.timeout, nauthilus.http_max_idle_per_host)
| Key | Env | Default | Meaning |
|---|---|---|---|
nauthilus.timeout |
NAUTHILUS_TIMEOUT |
10 | seconds one request may take: gRPC and HTTP calls to Nauthilus, the token introspection and the OIDC token endpoint (1–300) |
nauthilus.http_max_idle_per_host |
NAUTHILUS_HTTP_MAX_IDLE_PER_HOST |
32 | idle connections per host the one HTTP client keeps that the JSON API, the introspection and the token endpoint share (1–1024) |
nauthilus.max_wait |
NAUTHILUS_MAX_WAIT |
5 | the longest Auth-Wait (seconds) honoured after a refusal over HTTP |
A backchannel token Nauthilus refuses is renewed and the request sent once more, but only when the time left of the login allows a second attempt as long as the first. A login as a whole is bounded to 15 s in IMAP, POP3 and ManageSieve.
Auth cache (auth)
The cache sits in front of Nauthilus only (server.auth_backend: nauthilus; the static test backend is never cached) and behind the
master-user check: MasterUser{Cache{Nauthilus}}. The master password is
never cached, the lookup of the master-user login's target is.
auth:
cache:
ttl: 30 # seconds; 0 switches the whole cache off
negative_ttl: 0 # refusals of identical credentials; 0 = not cached
max_entries: 10000 # per operation (logins, lookups)
key_client_ip: true # the client IP is part of a login's key
lookup_ttl: 30
lookup_negative_ttl: 5 # at most lookup_ttl; ignored with lookup_ttl 0
leader_budget: 0 # seconds for one shared backend call; 0 = 2 × nauthilus.timeout
| Key | Env | Default | Range | Meaning |
|---|---|---|---|---|
auth.cache.ttl |
AUTH_CACHE_TTL |
30 | 0–600 | how long a successful login is answered from memory, from when it was stored; a bearer token introspected with an exp ends there when that comes first. 0 switches off the cache altogether, logins and lookups — the emergency switch |
auth.cache.negative_ttl |
AUTH_CACHE_NEGATIVE_TTL |
0 | 0–10 | how long a refused login is answered from memory, for exactly the same credentials, protocol, TLS state and address; it stops a client that loops with a stale password. A temporary failure is never cached |
auth.cache.max_entries |
AUTH_CACHE_MAX_ENTRIES |
10000 | > 0 | entries of logins and of lookups, each; the least recently used go first |
auth.cache.key_client_ip |
AUTH_CACHE_KEY_CLIENT_IP |
true | the client's IP address is part of a login's key, so that address rules in Nauthilus see every address. Off, one entry serves an account from every address | |
auth.cache.lookup_ttl |
AUTH_CACHE_LOOKUP_TTL |
30 | 0–600 | how long a lookup that found an account (LMTP recipient, master-user target, JMAP identity, admin API, push worker) is answered from memory; 0 caches no lookup |
auth.cache.lookup_negative_ttl |
AUTH_CACHE_LOOKUP_NEGATIVE_TTL |
5 | 0–lookup_ttl |
how long a lookup that found no account is answered from memory; keep it at or below the Nauthilus LDAP negative cache (20 s). Ignored, and no unknown name cached, with lookup_ttl 0 |
auth.cache.leader_budget |
AUTH_CACHE_LEADER_BUDGET |
0 | 0–600 | seconds one backend call made for every waiting caller may take in all (a bearer login is up to three requests: introspection, identity lookup, user name check); 0 takes twice nauthilus.timeout |
What a login's entry is found by: an HMAC-SHA256, under a random key
that lives in the process only, over the mechanism, the user name, the
secret (password or token), the protocol, the TLS state, the local
address and port Nauthilus is told and, with key_client_ip, the
client's address. The login attempt ordinal is not part of it; PLAIN
and LOGIN with the same password are two entries. Results are copied
on the way in and out. A lookup's entry is found by the normalised name (without angle brackets, the domain in
lower case) and the protocol: Nauthilus picks its search per protocol,
and the jmap search returns more attributes.
Identical requests that arrive together share one backend call: the
first starts it on a context of its own, bounded by leader_budget
and carrying its trace span, and every caller, the first included, stops
waiting when its own context ends; the call goes on and its answer is
stored for the next. An Auth-Wait Nauthilus asks for over HTTP is
waited out once, by that call, and every caller waits with it. An answer
that a flush overtook while it was on its way is not stored, and a
caller that comes after a flush starts a call of its own.
Lookups of the admin API (diagnosis, imports, deliveries) go past a cached "unknown" and store a found account, so an account created a moment ago is seen at once.
Entries are flushed with shardpostctl auth flush [<account>] on every
server (see Operations: Auth cache,
with what each change in LDAP costs until it takes effect) and by a
restart. The metrics are imapceph_auth_cache_* and
imapceph_auth_backend_seconds (Observability).
Rendered from docs/configuration.md in the ShardPost repository.