Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.toml
  • account_api/account_api.toml
  • domain_sithbit/domain_sithbit.example.toml
  • ipfs_daemon/sithbit_ipfsd.example.toml
  • ipfs_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:

  1. the in-code default,
  2. the TOML file (sithbitd.toml / account_api.toml / domain_sithbit.toml in the working directory, or the path in SITHBITD_CONFIG / ACCOUNT_API_CONFIG / DOMAIN_SITHBIT_CONFIG),
  3. ./.env,
  4. ./.env.$APP_ENV (APP_ENV comes from the environment or ./.env),
  5. 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):

KeyDefaultMeaning
health.bind_addr127.0.0.1:<per-binary port>The /healthz + /readyz listener; each binary defaults its own port (8190–8198, table in Monitoring)
health.enabledtrueDisable 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_seconds60Metric 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

KeyDefaultMeaning
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).

KeyDefaultMeaning
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.blobslocal ipfs/ dirEmbedded-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.listenloopback TCP + QUIC, ephemeral portsMultiaddrs 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.providefalseAnnounce 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_secs15Membership 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_secs60Missed renewals this long mark a member dead; its share of the keyspace reassigns to the survivors
ipfs.cluster.gc_interval_secs3600Cadence 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_secs3600Minimum 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:

KeyDefaultMeaning
enabledtrue / falseMX 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]

KeyDefaultMeaning
enabledtruePer-protocol toggle
imap.watch_poll_seconds0Split 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:

KeyDefaultMeaning
bind_addr127.0.0.1:2525 / :2587 / :2143 / :2110Listen address (the only per-listener default that differs)
implicit_tlsfalseWrap the socket in TLS at accept instead of STARTTLS/STLS
proxy_protocolfalseExpect 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_connections1024Concurrent connections across the listener
limits.max_per_peer16Concurrent connections per peer IP
limits.idle_timeout_secs600Session 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

KeyDefaultMeaning
hostname"localhost"EHLO name, Reporting-MTA, and the MAILER-DAEMON domain
local_domains[]Domains the DSN builder treats as locally deliverable
delay_dsnfalseEmit “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

KeyDefaultMeaning
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_hours24Token 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.

KeyDefaultMeaning
api_url"http://127.0.0.1:8180"Base URL of the account API
keypair_file~/.config/solana/id.jsonSolana keypair that signs the wallet-challenge login — its wallet must be on the API’s admin_wallets allowlist

domain-sithbit

KeyDefaultMeaning
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_.

KeyDefaultMeaning
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_bytes33554432 (32 MiB)POST body limit; larger uploads are refused with 413
[blobs]local ipfs/ dirBlock/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_.

KeyDefaultMeaning
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/ dirBlock/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):

VariableDefaultMeaning
GRPC_SERVER_ADDRESSrequiredListen address, e.g. 0.0.0.0:50051
JSON_RPC_URLrequiredSolana RPC endpoint
DEFAULT_KEYPAIRrequiredThe fee-payer/signing keypair JSON array itself, not a path. Keep it in .env or a secret manager
ALIAS_INDEX_DBalias_index.dbSQLite path for the alias-enumeration index. Set explicitly empty to disable the indexer (ListAliases then answers UNAVAILABLE)
ALIAS_INDEX_POLL_SECONDS5How often the indexer polls for new alias transactions
ALIAS_CACHE_SECONDS300TTL for the ResolveAlias cache (0 disables caching)
HEALTH_BIND127.0.0.1:8193The /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_SECONDS60Metric 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-grpc loads its configuration through a plain dotenvy::dotenv() call (which walks up from the working directory, unlike the layered .env/.env.$APP_ENV mechanism the four binaries above use) and has no .env.$APP_ENV overlay 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’s CLAUDE.md) — a second mail_grpc/.env would just duplicate or shadow it.
  • The sithbit CLI (mail_client) reads no .env at all; its only configuration inputs are the config file sithbit config manages1 and the JSON_RPC_URL environment variable.
  • mail_program / alias_program are 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.

  1. This is the same file (~/.config/solana/cli/config.yml, same location on Windows too) the Solana CLI’s own solana config command reads and writes, if you already have it installed — see Getting Started. ↩2