Configuration reference
Every SithBit binary follows the same contract: an empty or missing
config file is a runnable dev instance. Every setting has an in-code
default — loopback binds, unprivileged ports, a local SQLite store —
so configuration is only ever overriding a default, never satisfying
a required field. The two exceptions are called out below (mail-grpc’s
chain credentials, and TLS certificate paths, which have no sensible
default).
The annotated example files are the canonical per-key documentation and ship with every default shown commented out:
mail_spooler/sithbitd.example.tomlaccount_api/account_api.tomldomain_sithbit/domain_sithbit.example.tomlipfs_daemon/sithbit_ipfsd.example.tomlipfs_gateway/ipfs_gateway.example.toml
How a setting resolves
The TOML-config binaries (sithbitd, account-api, domain-sithbit)
layer each setting, lowest to highest precedence:
- the in-code default,
- the TOML file (
sithbitd.toml/account_api.toml/domain_sithbit.tomlin the working directory, or the path inSITHBITD_CONFIG/ACCOUNT_API_CONFIG/DOMAIN_SITHBIT_CONFIG), ./.env,./.env.$APP_ENV(APP_ENVcomes from the environment or./.env),- the real environment.
Environment variables address individual settings as
{PREFIX}_{PATH}, with __ descending one TOML nesting level:
SITHBITD_STORE__KIND=aws # [store] kind
SITHBITD_SMTP__SERVER__BIND_ADDR=0.0.0.0:2525 # [smtp.server] bind_addr
SITHBITD_STORE__BLOBS__KIND=azure # [store.blobs] kind (tagged enum)
The prefixes are SITHBITD, ACCOUNT_API, and DOMAIN_SITHBIT. Env
files are read from the working directory only, and their values are
exported to the process environment without overriding variables that
are already set.
[health] and [observability] — every binary
Two sections shared by every TOML binary (mail-grpc’s equivalents are
env vars — see its table below):
| Key | Default | Meaning |
|---|---|---|
health.bind_addr | 127.0.0.1:<per-binary port> | The /healthz + /readyz listener; each binary defaults its own port (8190–8198, table in Monitoring) |
health.enabled | true | Disable to serve no health endpoints (--health-probe then exits 1) |
observability.otlp | (absent — no export) | Presence of the section enables OTLP push of traces + metrics |
observability.otlp.endpoint | "http://127.0.0.1:4317" | Collector gRPC endpoint. The standard OTEL_EXPORTER_OTLP_*ENDPOINT env vars silently override this — leave them unset |
observability.otlp.metrics_interval_seconds | 60 | Metric push cadence |
Every binary also accepts a --health-probe argument: load the same
config, GET the health listener’s /readyz, exit 0/1 — this is what
the compose healthcheck: entries run inside the distroless images.
See Monitoring and backups for the endpoints, the
metric list, and the docker-compose.otel.yml collector overlay.
sithbitd
The combined mail daemon: SMTP MX + submission, IMAP, POP, and the
spooler workers in one process. Run exactly one sithbitd per store
while [store] kind = "sqlite" — IMAP IDLE push and per-wallet
SendMail ordering are in-process. The postgres and cloud stores lift
that limit; see Scaling out.
[store] — shared with account-api
| Key | Default | Meaning |
|---|---|---|
kind | "sqlite" | Tables/queues/leases backend: "sqlite", "postgres" (PostgreSQL), "aws" (DynamoDB + SQS), or "azure" (Tables + Queue Storage) |
database | "sithbit.db" | SQLite database path (kind = "sqlite") |
credential_key_file | "credential.key" | Seal key for stored mail passwords — unrecoverable if lost; back it up |
[store.blobs] selects the mail-body blob store: kind = "local"
(default, path = "blobs"), "s3", or "azure" (endpoint, container,
account, access_key).
[store.postgres] (for kind = "postgres"): url (default
"postgres://postgres:postgres@127.0.0.1:5432/sithbit"). The schema is
migrated idempotently at startup; the database itself must already
exist. The URL’s password is redacted from logs and Debug output.
[store.aws] (for kind = "aws"): region, table (one DynamoDB
table, default "sithbit"), queue_prefix (SQS queues named
<prefix>-*, default "sithbit"), endpoint_url /
sqs_endpoint_url (emulators), access_key / secret_key — omit the
keys to use the ambient AWS credential chain (env, profile, IAM role).
The table and queues are created idempotently at startup.
[store.azure] (for kind = "azure"): account, table,
queue_prefix, table_endpoint, queue_endpoint, access_key. The
defaults target a local Azurite emulator (run it with
--skipApiVersionCheck); a real account derives its endpoints and
needs access_key. Note the Azure blob store has no emulator key
fallback: Azurite blob use needs the published well-known key passed
explicitly.
[grpc] and [ipfs] — the chain pipeline
Both sections are required for mail to reach the chain; with either
absent, the chain pipeline is disabled and delivered copies stay in
state received (the dev default).
| Key | Default | Meaning |
|---|---|---|
grpc.endpoint | (unset) | The mail-grpc gateway, e.g. "http://127.0.0.1:50051" |
ipfs.kind | (inferred) | "embedded", "remote", "filebase", or "pinata". Unset: a configured [ipfs.remote]/[ipfs.filebase]/[ipfs.pinata] section implies its kind (pre-selector configs keep working), otherwise the embedded node |
ipfs.blobs | local ipfs/ dir | Embedded-node block/pin storage; same shape as [store.blobs] (local, s3, azure). Use one shared S3 bucket for the cluster model |
ipfs.remote.endpoint | "http://127.0.0.1:8182" | A shared sithbit-ipfsd daemon’s pin API (kind = "remote") — the multi-instance shape: the fleet pins through one node instead of each embedding its own |
ipfs.remote.auth_token | (unset) | Bearer token, when the daemon’s auth_token is set |
ipfs.swarm | (unset) | Embedded-node libp2p swarm; omit the section and no swarm runs. An empty [ipfs.swarm] is an isolated swarm (loopback listeners, no bootstrap, no announcements) |
ipfs.swarm.listen | loopback TCP + QUIC, ephemeral ports | Multiaddrs to listen on; public participation needs e.g. "/ip4/0.0.0.0/tcp/4001", "/ip4/0.0.0.0/udp/4001/quic-v1" |
ipfs.swarm.bootstrap | [] | Bootstrap peers (/…/p2p/<PeerId> multiaddrs) that seed the DHT routing table |
ipfs.swarm.provide | false | Announce pinned mail-blob roots as DHT provider records (re-announced every reprovide_interval_secs, default 22 h) |
ipfs.swarm.kad_protocol | "/ipfs/kad/1.0.0" | The DHT protocol id. On a private network use "/ipfs/lan/kad/1.0.0" — Kubo keeps private-address peers out of the public DHT and discovers them via its LAN DHT instead |
ipfs.swarm.identity_file | (unset) | Persisted ed25519 identity (32-byte JSON seed, created if missing). Without it the PeerId — and every provider record naming it — goes stale each restart |
ipfs.cluster | (unset — solo) | Shared-bucket clustering for the embedded node (embedded kind only — a remote daemon clusters via its own [cluster]). An empty [ipfs.cluster] enables membership + partitioned reprovide (needs [ipfs.swarm] with provide = true) + the GC sweep. See Scaling out |
ipfs.cluster.heartbeat_interval_secs | 15 | Membership renewal + roster check cadence; a roster change triggers an immediate reprovide sweep, so this bounds how fast a dead node’s share reassigns |
ipfs.cluster.member_ttl_secs | 60 | Missed renewals this long mark a member dead; its share of the keyspace reassigns to the survivors |
ipfs.cluster.gc_interval_secs | 3600 | Cadence of the shared-bucket GC sweep (delete blocks no pin manifest references); 0 disables it. Concurrent sweeps from several nodes are safe, just redundant |
ipfs.cluster.gc_grace_secs | 3600 | Minimum age before an unreferenced block is deleted — must comfortably exceed the longest plausible pin upload (a pin writes blocks before its manifest) |
ipfs.repin.from | (unset — no migration) | "filebase" or "pinata": migrate legacy pins from that service (its credential section stays configured) onto the active provider, which must be explicitly "embedded" or "remote". See below |
ipfs.filebase.access_key / secret_key | (unset) | Filebase S3 credentials |
ipfs.filebase.bucket | (unset) | Pinning bucket, e.g. "sithbit-mail" |
ipfs.filebase.endpoint | "https://s3.filebase.com" | Override for testing |
An empty [ipfs] section (plus [grpc]) is a complete chain setup: the
embedded node imports mail blobs with the fixed CID profile (CIDv1,
sha2-256, raw leaves, 256 KiB balanced dag-pb — byte-identical to Kubo)
and stores blocks/pin manifests in ipfs.blobs.
[ipfs.repin] runs the repin-and-verify migration: an hourly sweep
enumerates every fully chained copy and, per message, fetches the sealed
bytes back from the legacy service, re-pins them on the active
embedded/remote node, and releases the legacy pin only when the
re-imported root CID matches the recorded on-chain one. On a mismatch
(the legacy service imported with a different profile) the legacy pin is
kept — the on-chain CID is immutable and must stay resolvable — and the
sithbit.repin.outcomes{kind="mismatch"} counter grows. Watch that
counter; once it stabilizes the migration is done: remove [ipfs.repin]
(and, if nothing mismatched, the legacy credentials). Requires a store
backend with candidate enumeration (SQLite today); sithbitd refuses the
section otherwise at startup.
kind = "remote" delegates pin/unpin/fetch to a shared
sithbit-ipfsd daemon over HTTP instead of running a
node in-process — the multi-instance shape: the fleet pins through one
node (one swarm identity, one block store) rather than each daemon
embedding its own. [ipfs.blobs] and [ipfs.swarm] are ignored in
this mode; they are the daemon’s to configure.
With [ipfs.swarm] configured the node also joins the IPFS DHT: it
learns peers via identify, and with provide = true announces every
pinned root so stock Kubo peers can discover this node as the content’s
provider (pin manifests are the source of truth — a reprovide sweep
reconciles the DHT records against them on every tick, immediately at
startup). While the swarm runs, the node serves its pinned blocks over
bitswap (/ipfs/bitswap/1.2.0): any connected peer — or one that
found us via a provider record — can ipfs get the content straight
out of the configured blob store. Serving is one-way: the node answers
wantlists but never fetches foreign CIDs. For public retrievability the
listen addresses must be reachable from the internet (an open inbound
port); behind a closed firewall the DHT records carry addresses nobody
can dial.
[smtp], [submission] — the two SMTP listeners
[smtp] is the MX listener (enabled by default); [submission] is the
authenticated-submission listener (disabled by default). Both share the
same shape:
| Key | Default | Meaning |
|---|---|---|
enabled | true / false | MX on, submission off by default |
hostname | "localhost" | EHLO greeting name |
mode | "mx" / "submission" | Listener role |
sender_auth | "spf" | MX only: "spf" (reject on hardfail), "dmarc-lite" (DMARC alignment, reject only on p=reject — kinder to forwarded mail), or "none" |
local_domains | [] | Domains accepted for local delivery (empty: the hostname itself). One deployment may list several — see the startup check below |
dnsbl_zone | (unset) | DNSBL zone to check peers against, e.g. "zen.spamhaus.org" |
With the chain pipeline enabled, sithbitd checks every configured
local domain against the chain at startup (via the gateway’s
GetMailDomain): a domain that is unregistered, deactivated, or whose
on-chain authority is not the gateway’s signing key gets one loud
warning in the log — mail to it would otherwise fail silently
per-message at SendMail. The check never blocks or fails the boot,
and the chain-disabled dev stack skips it.
[imap], [pop]
| Key | Default | Meaning |
|---|---|---|
enabled | true | Per-protocol toggle |
imap.watch_poll_seconds | 0 | Split deployments only: poll for mailbox changes every N seconds to feed IDLE when deliveries happen in another process. 0 trusts in-process push |
[*.server] — the shared listener section
Every listener ([smtp.server], [submission.server],
[imap.server], [pop.server]) takes the same fields:
| Key | Default | Meaning |
|---|---|---|
bind_addr | 127.0.0.1:2525 / :2587 / :2143 / :2110 | Listen address (the only per-listener default that differs) |
implicit_tls | false | Wrap the socket in TLS at accept instead of STARTTLS/STLS |
proxy_protocol | false | Expect a PROXY protocol preamble and attribute sessions to the client it names. Only behind an L4 balancer — never on a directly reachable listener (the preamble is spoofable), and clients that don’t send one are dropped |
limits.max_connections | 1024 | Concurrent connections across the listener |
limits.max_per_peer | 16 | Concurrent connections per peer IP |
limits.idle_timeout_secs | 600 | Session idle timeout |
[smtp.tls] / [submission.tls] / [imap.tls] / [pop.tls] each
take certs and key (PEM file paths, e.g. fullchain.pem /
privkey.pem). With no TLS section the listener runs plaintext —
fine on loopback, not on the internet.
[spooler] — outbound workers
| Key | Default | Meaning |
|---|---|---|
hostname | "localhost" | EHLO name, Reporting-MTA, and the MAILER-DAEMON domain |
local_domains | [] | Domains the DSN builder treats as locally deliverable |
delay_dsn | false | Emit “delayed” DSNs on retry schedules |
[spooler.smarthost] routes all outbound mail through a fixed relay
instead of MX resolution: host, port, user, password,
require_tls. [spooler.dkim] signs authenticated submissions:
domain, selector, key_file (see DNS setup for the
matching DNS record). A multi-domain server writes one entry per
sending domain with the [[spooler.dkim]] array form (the single-table
form keeps working); the signer is selected by the sender’s domain so
each domain’s signature aligns for DMARC, and a sender domain with no
entry spools unsigned. A domain listed twice refuses to start.
account-api
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8180" | HTTP listen address |
admin_wallets | [] | Wallets allowed on the /v1/admin routes (account/queue inspection, dead-letter requeue — see Monitoring). Empty disables the admin surface: every admin call is 403 |
[store] | (same as sithbitd) | Point it at the same store so one database serves both |
jwt.issuer / jwt.audience | "sithbit" | Token claims |
jwt.key_file | "jwt.key" | 32-byte signing key — auto-generated if missing, then unrecoverable; back it up (losing it invalidates all sessions) |
jwt.ttl_hours | 24 | Token lifetime |
[chain] | (absent) | Enables the authenticated /v1/chain on-chain read proxy + signed-transaction relay (the Thunderbird extension’s surface). Absent = those routes answer 503; an empty section uses the two defaults below |
chain.grpc_endpoint | "http://127.0.0.1:50051" | The mail-grpc gateway serving mailbox/key/alias/frombox/tx-status reads |
chain.rpc_url | "http://127.0.0.1:8899" | Solana JSON-RPC node for SOL balances and relaying client-signed transactions |
mail.local_domains | [] | Domains whose recipients live in this store: compose (POST /v1/mail/send) delivers them locally, resolving aliases and prechecking stamps over the [chain] gateway. Mirror sithbitd’s local_domains. Empty = every recipient rides a relay job |
[mail.dkim] | (absent) | DKIM keys for composed mail — the same one-or-many shape as sithbitd’s [spooler.dkim], selected by the sender’s domain. Absent = composed relay mail goes unsigned |
[static] | (absent) | Serves a static directory on the API’s own origin (browser pages reach /v1/… without CORS) — the Outlook add-in’s built bundle is the intended occupant. Absent = no static routes; a missing root 404s per request |
static.route | "/addin" | Route prefix the files appear under |
static.root | "wwwroot" | Directory to serve (point it at webclients/outlook/staging) |
[tls] | (absent) | Terminates TLS on the API listener itself (dev sideloads, small deployments — production guidance is still a reverse proxy). Both keys are required when present; a missing/invalid file fails at startup, never per-connection |
tls.cert_file | (required) | PEM certificate chain |
tls.key_file | (required) | PEM private key |
sithbit-console
The management TUI over the account API’s /v1/admin routes: accounts,
mailboxes, messages with chain states, queue depths, and dead-letter
requeue/discard (see Monitoring). It never touches the
store directly — every read and action rides the API. Config file
sithbit_console.toml (or SITHBIT_CONSOLE_CONFIG), env prefix
SITHBIT_CONSOLE.
| Key | Default | Meaning |
|---|---|---|
api_url | "http://127.0.0.1:8180" | Base URL of the account API |
keypair_file | ~/.config/solana/id.json | Solana keypair that signs the wallet-challenge login — its wallet must be on the API’s admin_wallets allowlist |
domain-sithbit
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8181" | HTTP listen address |
wwwroot | "wwwroot" | Static files (the verification front-end) |
postmaster_key_file | (unset) | Solana keypair that signs on-chain domain authorizations. Unset, POST /domain replies 503 (DNS lookups still work). Guard this key like a wallet |
Its on-chain calls resolve the RPC endpoint the way the sithbit CLI
does: the config file at ~/.config/solana/cli/config.yml, managed with
sithbit config get/set,1 overridden by a JSON_RPC_URL
environment variable.
sithbit-ipfsd
The self-hosted IPFS node as its own daemon: the same embedded
repo/swarm sithbitd can run in-process, behind a small HTTP pin API
(POST/DELETE /pins/{name}, GET /ipfs/{cid}) for fleets that share
one node via [ipfs] kind = "remote". Config file sithbit_ipfsd.toml
(or SITHBIT_IPFSD_CONFIG), env prefix SITHBIT_IPFSD_.
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8182" | HTTP listen address |
auth_token | (unset — open) | Bearer token required on every request when set. Bind beyond loopback only with a token or network isolation — the pin API is a write surface |
max_pin_bytes | 33554432 (32 MiB) | POST body limit; larger uploads are refused with 413 |
[blobs] | local ipfs/ dir | Block/pin storage — the same shape and semantics as sithbitd’s [ipfs.blobs] |
[swarm] | (unset — no swarm) | Identical to sithbitd’s [ipfs.swarm] (listen/bootstrap/provide/kad_protocol/identity_file); every pin announces to it, and pinned blocks serve over bitswap |
[cluster] | (unset — solo, no GC) | Identical to sithbitd’s [ipfs.cluster] (heartbeat/TTL/GC knobs): N daemons over one [blobs] bucket heartbeat a membership roster in the bucket, partition the DHT reprovide keyspace by rendezvous hashing, and GC unreferenced blocks. See Scaling out |
sithbit-gateway
The read-only IPFS HTTP path gateway: GET/HEAD /ipfs/{cid}
(deserialized, plus trustless ?format=raw|car) over the same
block/pin bucket the node writes; locally-absent CIDs answer 404 — it
never fetches from the IPFS network. Config file ipfs_gateway.toml
(or IPFS_GATEWAY_CONFIG), env prefix IPFS_GATEWAY_.
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8183" | HTTP listen address. The surface is read-only, so no auth gate exists; bind it wherever readers live |
[blobs] | local ipfs/ dir | Block/pin storage — the same shape as sithbit-ipfsd’s [blobs]; point it at the shared bucket the node/cluster pins into. The gateway only reads it |
mail-grpc
mail-grpc is configured by environment only (it reads ./.env first):
| Variable | Default | Meaning |
|---|---|---|
GRPC_SERVER_ADDRESS | required | Listen address, e.g. 0.0.0.0:50051 |
JSON_RPC_URL | required | Solana RPC endpoint |
DEFAULT_KEYPAIR | required | The fee-payer/signing keypair JSON array itself, not a path. Keep it in .env or a secret manager |
ALIAS_INDEX_DB | alias_index.db | SQLite path for the alias-enumeration index. Set explicitly empty to disable the indexer (ListAliases then answers UNAVAILABLE) |
ALIAS_INDEX_POLL_SECONDS | 5 | How often the indexer polls for new alias transactions |
ALIAS_CACHE_SECONDS | 300 | TTL for the ResolveAlias cache (0 disables caching) |
HEALTH_BIND | 127.0.0.1:8193 | The /healthz + /readyz listener; explicitly empty disables it |
OTLP_ENDPOINT | (unset — no export) | Set to a collector endpoint (e.g. http://127.0.0.1:4317) to push traces + metrics |
OTLP_METRICS_INTERVAL_SECONDS | 60 | Metric push cadence when export is on |
The alias index is derived state: it backfills from chain history on an empty database, so the file needs no backup (see Monitoring and backups).
Which services get a .env
mail_spooler, account_api, domain_sithbit, ipfs_daemon, and
ipfs_gateway each ship a sample .env and .env.development in their
crate directory — commented-out templates for every environment-variable
override, using the precedence chain above. Copy the ones you need and
uncomment.
Other crates in the workspace deliberately don’t have one:
mail-grpcloads its configuration through a plaindotenvy::dotenv()call (which walks up from the working directory, unlike the layered.env/.env.$APP_ENVmechanism the four binaries above use) and has no.env.$APP_ENVoverlay concept at all. It’s already served by the workspace-root.env, which deliberately lives there holding devnet secrets (see “Important: secrets in the tree” in the workspace’sCLAUDE.md) — a secondmail_grpc/.envwould just duplicate or shadow it.- The
sithbitCLI (mail_client) reads no.envat all; its only configuration inputs are the config filesithbit configmanages1 and theJSON_RPC_URLenvironment variable. mail_program/alias_programare on-chain SBF programs with no runtime environment.- Every other workspace crate is a library with no binary target, so there’s nothing to configure at runtime.
-
This is the same file (
~/.config/solana/cli/config.yml, same location on Windows too) the Solana CLI’s ownsolana configcommand reads and writes, if you already have it installed — see Getting Started. ↩ ↩2