Monitoring and backups
What to back up
The store — the store volume in the compose files, or wherever
[store] points — is the only state that matters, and its pieces have
very different values:
| File | Loss means | Back up? |
|---|---|---|
credential.key | every sealed mail credential is orphaned — users must set new mail passwords | yes, first |
jwt.key (account-api) | every login session invalidated; auto-regenerates, users just log in again | yes |
sithbit.db (+ -wal, -shm) | accounts, mailboxes, message metadata, queued jobs | yes |
blobs/ (local blob store) | message bodies not yet pinned to IPFS | yes |
DKIM key, TLS keys, postmaster keypair, DEFAULT_KEYPAIR | re-issuable with DNS/CA churn — except the postmaster key, which is an on-chain authority: guard and back it up like a wallet | yes |
alias_index.db (mail-grpc) | nothing — it re-syncs from chain history on an empty file | no |
Two SQLite copy rules: a live database is only complete with its
-wal and -shm sidecar files, and the distroless images have no
shell — so from a container, docker cp all three files out (a copy
missing the WAL reads as empty or stale). For a consistent snapshot
prefer stopping the service first, or run sqlite3 sithbit.db ".backup ..." from the host against a mounted volume.
Cloud stores (kind = "aws" / "azure") move this problem to the
provider: durability comes from DynamoDB/S3/Azure Storage, and only the
key files above still need your own backups.
Logs
Every binary — sithbitd, account-api, domain-sithbit, and
mail-grpc — logs structured tracing lines to stdout — docker logs <service> in the compose stacks. The default level is info; filter
with RUST_LOG using target=level directives:
RUST_LOG=info,mail_spooler=debug,sqlx=warn
The same RUST_LOG filter also shapes what the OTLP export (below)
sends — it sits in front of both the console and the exporter.
Telemetry export (OTLP)
Every binary can push traces and metrics to an OpenTelemetry collector
over OTLP/gRPC. Export is off by default and enabled per service by
the presence of the [observability.otlp] config section (defaults
shown commented in every example config):
[observability.otlp]
# endpoint = "http://127.0.0.1:4317"
# metrics_interval_seconds = 60
mail-grpc is env-configured like the rest of its settings:
OTLP_ENDPOINT (absent or empty = no export) and
OTLP_METRICS_INTERVAL_SECONDS.
The design is push only — no Prometheus scrape endpoint. Prometheus
users run an OTel collector with a Prometheus exporter and point the
services at it. For a working dev example, the
docker-compose.otel.yml overlay boots a collector with the debug
exporter and turns every service’s export on:
docker compose -f docker-compose.yml -f docker-compose.otel.yml up -d
docker compose logs -f otel-collector # spans + metrics print here
Two gotchas:
- The standard
OTEL_EXPORTER_OTLP_ENDPOINT/OTEL_EXPORTER_OTLP_TRACES_ENDPOINT/…_METRICS_ENDPOINTenv vars override the configured endpoint inside the exporter — leave them unset for the config file to be authoritative. - Traces ride the
RUST_LOGfilter: a target silenced for logging is also not exported.
The metrics (all under the sithbit. prefix, labeled as noted):
| Metric | Kind | Labels | Meaning |
|---|---|---|---|
sithbit.sessions | counter | port | accepted SMTP/IMAP/POP sessions |
sithbit.sessions.active | up/down | port | sessions currently served |
sithbit.rcpt.refusals | counter | reason | RCPT TO refusals (unknown_mailbox, relay_denied, temporary_failure, custom_<code>) |
sithbit.jobs | counter | queue, outcome | job dispositions (done / retry / bury) |
sithbit.queue.depth | gauge | queue | backlog incl. delayed + claimed jobs |
sithbit.queue.oldest_age_seconds | gauge | queue | age of the oldest queued job (SQLite store only; cloud queues don’t expose it — use CloudWatch/Azure metrics there) |
sithbit.chain.stuck | gauge | — | non-terminal chain copies past the sweep horizon, per reconciler pass |
sithbit.repin.outcomes | counter | kind | repin-and-verify migration outcomes (migrated / already / mismatch / source_missing / skipped); mismatch stabilizing means the migration is done — see the [ipfs.repin] reference |
sithbit.alias_index.staleness_seconds | gauge | — | seconds since the alias index last synced (mail-grpc) |
The job queues
All background work rides durable queues: chain (encrypt → IPFS
pin → on-chain SendMail), relay (outbound SMTP), chain_delete
(expunge teardown), dsn (status notifications), and — only while an
[ipfs.repin] migration is configured — repin. Jobs are
at-least-once with a visibility timeout; failures retry with backoff,
and a job that keeps failing is buried to a dead-letter queue with
a reason. The two numbers worth watching are depth (backlog) and
age of the oldest job (a stuck consumer).
SQLite — the jobs and dead_jobs tables:
-- depth and oldest job per queue (timestamps are unix seconds)
SELECT queue, COUNT(*), MIN(created_at) FROM jobs GROUP BY queue;
-- poison jobs, with why they died
SELECT queue, reason, died_at, payload FROM dead_jobs;
AWS — SQS queues named <queue_prefix>-chain, -relay,
-chain-delete, -dsn, and -dead; watch the standard
ApproximateNumberOfMessagesVisible / ApproximateAgeOfOldestMessage
CloudWatch metrics.
Azure — Storage queues under the same <queue_prefix>-* names,
with -dead for buried jobs; watch approximate message counts.
A buried job’s payload is self-describing JSON (a type field plus
the job’s parameters). The interactive way to inspect and re-drive
dead jobs is the sithbit-console TUI (its Queues tab lists depths
and dead letters with confirmed requeue/discard keys — see
Configuration); underneath it is an account-api
admin call (a wallet on its admin_wallets allowlist):
GET /v1/admin/dead-jobs lists
buried jobs with their queue, reason, and payload, and
POST /v1/admin/dead-jobs/requeue (echo a listed entry back) fixes it
onto its source queue with attempts reset —
POST /v1/admin/dead-jobs/discard deletes it instead. Queue depths are
GET /v1/admin/queues. On the cloud backends a listing claims each
returned entry for five minutes (the id is a claim token, like a
worker’s receipt handle), so requeue/discard within that window; a
lapsed entry simply lists again later. Without the admin API, the
manual fallback still works: fix the cause and re-insert the payload
(SQLite: copy the row back into jobs with attempts = 0,
visible_at = now; SQS/Azure: send the body to the source queue).
Chain states
Every delivered message copy tracks its progress to the chain in
messages.chain_state:
| State | Meaning | Terminal? |
|---|---|---|
local | local-only copy (e.g. a sent-folder copy); the pipeline ignores it | yes |
received | delivered, waiting for the chain worker — the resting state when the chain pipeline is disabled (dev stacks) | no |
pinned | body encrypted and pinned to IPFS; SendMail pending | no |
sent | on chain | yes |
no_key | the recipient cannot receive encrypted mail (e.g. off-curve address); the local copy stays readable, a warning is logged, no bounce | yes |
chain_failed | gave up permanently; the reason is in the logs and usually a buried chain job | yes |
SELECT chain_state, COUNT(*) FROM messages GROUP BY chain_state;
A copy sitting in a non-terminal state (received, pinned) for more
than 15 minutes is picked up by the reconciler, which sweeps every
5 minutes and re-enqueues a chain job for it — duplicates are
harmless by design. A growing received count on a
chain-enabled deployment therefore means the pipeline itself is
unhealthy: check the chain queue depth, the dead-letter queue, and
connectivity to mail-grpc and IPFS.
Liveness
Every binary serves two HTTP health endpoints on a loopback health
listener ([health] in the TOML configs; HEALTH_BIND for
mail-grpc, empty disables). Default ports, one per binary so a dev
host can run them all:
| Binary | Health listener |
|---|---|
sithbitd | 127.0.0.1:8190 |
account-api | 127.0.0.1:8191 |
domain-sithbit | 127.0.0.1:8192 |
mail-grpc | 127.0.0.1:8193 |
pop-server | 127.0.0.1:8194 |
smtp-server | 127.0.0.1:8195 |
imap-server | 127.0.0.1:8196 |
sithbit-ipfsd | 127.0.0.1:8197 |
sithbit-gateway | 127.0.0.1:8198 |
GET /healthz— 200 while the process serves (liveness).GET /readyz— 200 once startup finished (listeners bound); 503 lists what’s still waiting (readiness).
Because the runtime images are distroless (no shell, no curl), every
binary also takes a --health-probe flag: it loads the same
config, GETs its own /readyz, and exits 0/1. The compose files use
exactly that as their healthcheck: (see docker-compose.yml), and
docker/smoke.sh waits for the services to report healthy. One
caveat: mail-grpc runs host networking in the chain profile, so
its probe port 8193 lives on the host — move it with HEALTH_BIND
if something else holds it.
Also useful:
- SMTP/IMAP/POP answer with a protocol banner on connect —
docker/smoke.shscripts exactly this for the dev stack. mail-grpc’sListAliasesRPC answersUNAVAILABLE(“still backfilling”) until the alias index has completed its first sync — a finer-grained readiness signal for the index than/readyz, which only tracks the gRPC listener (thesithbit.alias_index.staleness_secondsmetric covers ongoing sync health).