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 one exception is called out below (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
  • mail_grpc/mail_grpc.example.toml

How a setting resolves

The TOML-config binaries (sithbitd, account-api, domain-sithbit, mail-grpc) layer each setting, lowest to highest precedence:

  1. the in-code default,
  2. the TOML file (sithbitd.toml / account_api.toml / domain_sithbit.toml / mail_grpc.toml in the working directory, or the path in SITHBITD_CONFIG / ACCOUNT_API_CONFIG / DOMAIN_SITHBIT_CONFIG / MAIL_GRPC_CONFIG),
  3. an optional cloud app-config source — AWS AppConfig or Azure App Configuration, opted into per binary by a bootstrap env var; skipped entirely when neither var is set,
  4. ./.env,
  5. ./.env.$APP_ENV (APP_ENV comes from the environment or ./.env),
  6. 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, DOMAIN_SITHBIT, and MAIL_GRPC. 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.

Key sources: files or cloud secret managers

Six file-loaded secrets take a key source rather than a bare path: the account-api JWT signing key (jwt.key_file), the DKIM signing key(s) ([spooler.dkim] / [mail.dkim] key_file), the credential-sealing key (credential_key_file), every server’s TLS certificate and key (certs / key, or the account-api’s tls.cert_file / tls.key_file), domain-sithbit’s delegate signing key (delegate_key_file), and mail-grpc’s signing/fee-payer keypair (keypair). A key source is either a local file (the default) or a cloud secret manager’s secret — Azure Key Vault (akv), AWS Secrets Manager (asm), or Google Secret Manager (gsm) — in one of these TOML forms:

key_file = "jwt.key"                    # 1. bare path string  -> a local file
key_file = { path = "jwt.key" }         # 2. table, kind omitted -> a local file
key_file = { kind = "akv",              # 3. an Azure Key Vault secret
             vault_uri = "https://<vault>.vault.azure.net/",
             secret_name = "jwt-signing-key" }
key_file = { kind = "asm",              # 4. an AWS Secrets Manager secret
             secret_id = "sithbit/jwt-signing-key" }
key_file = { kind = "gsm",              # 5. a Google Secret Manager secret
             project = "my-project",
             secret = "jwt-signing-key" }

The representation defaults to a file, so a config that names a plain path — or omits kind — keeps the historical, zero-config file behaviour byte-for-byte; only an explicit cloud kind opts into a secret manager. This holds for all six fields whatever the field is named (key_file, credential_key_file, delegate_key_file, keypair, certs / key, cert_file / key_file).

A cloud secret’s value holds exactly what the file would have — the raw key bytes, or the PEM. The TLS pair therefore fetches two secrets — the certificate-chain PEM and the private-key PEM — one per certs/key (or cert_file/key_file) entry. No secret material lives in the config file itself; it carries only the secret’s coordinates. Per cloud:

  • akv names the vault_uri and secret_name. Authentication reuses the same managed-identity credential chain as the Azure storage backend (azure_identity’s ManagedIdentity): no new auth to configure.
  • asm names the secret_id — a secret name or a full ARN — with optional region (defaults to the ambient AWS configuration’s) and endpoint_url (an emulator such as LocalStack, mirroring the AWS storage backend’s setting). Authentication is the ambient AWS credential chain (environment, profile, IAM role). A string secret’s UTF-8 bytes are used; a binary secret’s raw bytes serve as the fallback when no string value is present.
  • gsm names the project and secret, with optional version (default "latest"). Authentication is Application Default Credentials (workload identity, GOOGLE_APPLICATION_CREDENTIALS, or a gcloud user login).

Every kind parses in every build. The clouds themselves are cargo features of the key-source crate (akv / asm / gsm, all on by default); a build that compiles one out still accepts the config but fails at load with an error naming the feature to enable, so a slimmed operator build can drop the SDKs it never uses (see Slim-build features for per-binary slim build commands).

(Cloudflare has no equivalent backend by design: its Secrets Store / Workers secrets are write-only over the API — only a deployed Worker binding can read a value — so a fetch-style key source cannot exist.)

There is no local Key Vault emulator, so the live AKV fetch is exercised only by an #[ignore]d probe gated on the SITHBIT_TEST_KEYVAULT_URI environment variable (pointed at a real vault). The ASM and GSM twins gate on SITHBIT_TEST_ASM_SECRET_ID (plus optional SITHBIT_TEST_ASM_ENDPOINT/SITHBIT_TEST_ASM_REGION — an ASM probe can target LocalStack) and SITHBIT_TEST_GSM_PROJECT / SITHBIT_TEST_GSM_SECRET. The per-kind dispatch is unit-tested against a fake fetcher, so CI covers the dispatch and a real cloud covers the round trip.

Cloud app-config sources: AWS AppConfig or Azure App Configuration

When mounting a TOML file into every container or VM is the awkward part of a deployment — orchestrated fleets, serverless units, config that several instances must share — a binary can pull its settings from a managed configuration store instead: AWS AppConfig or Azure App Configuration. The cloud tier slots into the resolution chain above directly after the TOML file: it overrides the file, and is itself overridden by ./.env, ./.env.$APP_ENV, and the real environment — so a {PREFIX}_{PATH} override still beats a cloud value, exactly as it beats the file. This tier carries settings, not secrets: key material keeps going through key sources, and a cloud config value holds at most a secret’s coordinates, never the secret.

Opting in is pure environment — no TOML key, no code change. Each binary derives six bootstrap variables from its prefix (SITHBITD, ACCOUNT_API, DOMAIN_SITHBIT, MAIL_GRPC, SITHBIT_IPFSD, IPFS_GATEWAY):

VariableMeaning
{PREFIX}_AWSAPPCONFIGSelect AWS AppConfig: application/environment/profile (exactly three non-empty segments)
{PREFIX}_AWSAPPCONFIG_REGIONOptional region override (default: the ambient AWS configuration’s)
{PREFIX}_AWSAPPCONFIG_ENDPOINTOptional endpoint URL, for emulators/local fakes
{PREFIX}_AZAPPCONFIGSelect Azure App Configuration: the store’s https://<name>.azconfig.io endpoint
{PREFIX}_AZAPPCONFIG_LABELOptional label filter; unset reads the NULL label only, never every label
{PREFIX}_AZAPPCONFIG_PREFIXOptional key prefix, server-filtered and stripped before mapping (e.g. sithbitd:)

With neither primary variable set the tier is skipped entirely — the zero-config contract is untouched. Setting both is a load error (pick one provider per binary). The bootstrap variables are control inputs, not settings, and may themselves come from a .env file.

The payload idiom differs per provider:

  • AWS AppConfig holds one whole TOML document in a freeform configuration profile. It deep-merges over the config file: nested tables merge key-wise, so one cloud document can override a section without erasing its siblings; scalars and arrays replace wholesale. Each load opens a fresh AppConfigData session and makes a single GetLatestConfiguration call — configuration is read once at startup, so picking up a new deployment means restarting the binary.
  • Azure App Configuration holds per-key values: : in a key descends one TOML nesting level (store:kind sets store.kind), and values get the same TOML-scalar parsing as env overrides. Keys are case-sensitive — spell them exactly like the TOML keys. A scalar-vs-table collision between keys fails the load rather than letting listing order decide.

Both providers authenticate ambiently — the AWS credential chain, the Azure managed identity — the same posture as the key sources above. The backends are cargo features of the app-config crate (awsconf / azconf, both on by default); a build that compiles one out fails at load with an error naming the feature to rebuild with when its provider is selected, so a slimmed operator build can drop the SDK it never uses (see Slim-build features for per-binary slim build commands).

The mapping logic (bootstrap parsing, TOML merge, key nesting) is unit-tested against injected fake fetches, so CI covers it without credentials. The live round trips are #[ignore]d probes gated on environment variables: SITHBIT_TEST_AWSAPPCONFIG_APPLICATION / _ENVIRONMENT / _PROFILE (plus optional _REGION / _ENDPOINT — an AWS probe can target an emulator) with ambient AWS credentials, and SITHBIT_TEST_AZAPPCONFIG_ENDPOINT (plus optional _LABEL / _PREFIX) with an ambient managed identity.

You do not have to author the store content from scratch: the repository ships ready-to-import production documents for both providers — a complete six-service deployment shape with dummy secret coordinates — under iac/appconfig/, together with the az appconfig kv import / aws appconfig runbooks and the generator that keeps the two flavors in lock-step. See iac/README.md and the deployment chapter.

[health] and [observability] — every binary

Two sections shared by every TOML binary — mail-grpc included, since it moved onto the same layering:

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), "azure" (Tables + Queue Storage), "turso" (libSQL local file / embedded replica), or "cloudflare" (D1 + Queues + Workers KV + R2)
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. A key source: a file path (default) or a cloud secret-manager secret

[store.blobs] selects the mail-body blob store: kind = "local" (default, path = "blobs"), "s3" (endpoint, bucket, region, access_key, secret_key), or "azure" (endpoint, container, account, access_key).

[store.postgres] (for kind = "postgres"): url (default "postgres://postgres:[email protected]: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), sqs_wait_time_seconds (SQS receive long-poll seconds, default 10; 0 = short polling), access_key / secret_key — omit the keys to use the ambient AWS credential chain (env, profile, IAM role) — and kms_master_key_id (a KMS key ID, alias, or ARN for at-rest encryption of the table and queues; unset, the default, keeps provider-managed SSE). 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.

[store.turso] (for kind = "turso"): database (local libSQL file, default "sithbit.db"), sync_url (libsql://… / https://… — set it to run an embedded replica against a remote Turso/libSQL primary; unset = a pure-local file, on-disk-identical to kind = "sqlite"), auth_token (bearer secret for the remote; redacted from logs — unset for local dev or an unauthenticated self-hosted sqld), and sync_interval_secs (seconds between background replica→remote pulls; unset defaults to 60s for a replica). Blobs still come from [store.blobs] (local/s3) — libSQL has no object store, exactly like SQLite. The schema is migrated idempotently at startup.

[store.cloudflare] (for kind = "cloudflare"): the daemon reaches Cloudflare’s edge primitives over plain HTTP with one bearer API token — it is not hosted on Workers. Three native services back the five store traits: D1 (SQLite-over-HTTP) for accounts + mail + keyed leases, Cloudflare Queues for the job queue, and R2 (S3-compatible) for blobs.

KeyMeaning
account_idCloudflare account id (the /accounts/<id>/… REST path segment, and the subdomain of the derived R2 endpoint)
api_tokenBearer token authorizing the D1/Queues calls; redacted from logs and Debug output
d1_database_idD1 database id (the /d1/database/<id>/query segment)
kv_namespace_idRetired 2026-07-19 — leases now ride D1’s leases table. Accepted so existing configs keep parsing, but ignored
api_baseREST base URL override (mock servers / proxies); unset = https://api.cloudflare.com/client/v4
[store.cloudflare.queues]The five queue ids — chain, relay, chain_delete, dsn, dead (Cloudflare assigns each queue its own id, so there is no name-prefix derivation as with SQS)
[store.cloudflare.r2]R2 blob store — an s3-shaped BlobConfig (endpoint, bucket, region, access_key, secret_key). Unlike the other backends, blobs come from this section, not [store.blobs], because R2’s endpoint is account-derived (https://<account_id>.r2.cloudflarestorage.com) unless you set endpoint explicitly

Provisioning — auto vs prerequisite. The D1 schema (leases table included) is provisioned automatically at startup: the shared migration set is replayed over D1’s HTTP query API, idempotently (a _d1_migrations tracking table makes a restart a no-op). The D1 database and the five Cloudflare Queues themselves are a manual prerequisite — creating them is a Cloudflare management-API/dashboard step with no local emulator, so the backend does not auto-create them. Selecting kind = "cloudflare" without those ids configured fails fast at startup with a clear config error rather than an opaque runtime 404.

Local-first testing. The full shared store-conformance suite — D1 (accounts, mail, DMARC, keyed leases) and Cloudflare Queues (the job queue) — now runs with no Cloudflare account over the same production wire path the daemon uses in the field: the real ReqwestTransport and the http.rs/queue.rs request-builders and response-parsers, driven against a loopback fake that speaks Cloudflare’s public REST envelopes (D1 /query, Queues push/pull/ack/info) over a real TCP socket. Because D1’s dialect is SQLite, the fake replays the identical SQL against an in-process SQLite; queues run over stateful in-memory state behind the REST surface. R2 blobs ride the same minio-backed S3 path the other S3-shaped backends use.

This exercises the request-building and response-parsing that the earlier in-process Transport fakes bypassed, and proves the trait semantics survive a real socket. Be clear about what it does not prove: a self-written fake only shows that http.rs/queue.rs are internally consistent with the envelopes we assumed Cloudflare speaks — it cannot confirm those assumptions against the real service. A live real-Cloudflare conformance run therefore remains deferred: it needs a paid account (R2 requires dashboard enablement, and Queues requires a Workers Paid plan) and is gated behind real credentials.

[grpc] and [ipfs] — the chain pipeline

The two sections are independent halves of chain access. [grpc] alone enables everything gateway-backed — SMTP recipient verification at RCPT time (alias resolution + postage checks), at-rest sealing, alias logins — with the chain pipeline off: delivered copies stay in state received, and boot logs the verification-only posture at info. This is the MX posture: an edge that verifies postage but never writes the chain needs no [ipfs] provider. Adding [ipfs] as well enables the full pipeline (the encrypt → pin → SendMail workers), so mail actually reaches the chain. [ipfs] without [grpc] does nothing — no gateway means no chain to announce pins to — and logs a warning at boot. With neither section, the chain pipeline is disabled entirely (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" (the pinning provider). 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, keyed by PeerId
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(discovered)EHLO greeting name. Unset, the daemon adopts the first local_domains entry (the first configured one, else the alphabetically-first discovered domain), then the machine’s /etc/hostname, then "localhost" — see Identity defaults from the chain below
mode"mx" / "submission"Listener role
sender_auth"spf"MX only: "spf", "dmarc-lite", "dmarc", or "none" — see Sender authentication below
local_domains(discovered) Domains accepted for local delivery. Unset, the daemon adopts every domain the gateway’s signing key is authoritative for on-chain; without a chain connection the empty list falls back to the hostname itself. One deployment may list several — see the startup check below
dnsbl_zone(unset)DNSBL zone to check the connecting peer’s IP against at connect, e.g. "zen.spamhaus.org"
dbl_zone(unset)DBL zone to check the sender domain against at EHLO and MAIL FROM. A listed domain (or EHLO host) is refused 554 5.7.1. Unset = off. See Domain block list below for the zone string and DQS key
client_cert_authfalseRequest a TLS client certificate and offer SASL EXTERNAL on this listener — meaningful on [submission] (the MX listener does no SASL AUTH). Inert without [submission.tls]. Client auth stays optional, so password clients keep working on the same listener
self_service_base_url(unset)Public base URL of the operator’s self-service pages. Set, the postage refusals link the funding page and (under sithbitd) the do-not-disturb refusal links the schedule page — see Self-service refusal links below. Unset keeps every refusal byte-identical to the linkless text
postmaster_wallet(unset)Wallet delivered mail for bare postmaster / postmaster@<local-domain> (RFC 5321 §4.5.1), bypassing alias resolution and the frombox/postage gate so external senders — notably DMARC reporters targeting the [spooler.dmarc_rua_ingest] mailbox — can reach it without stamps. Unset keeps postmaster on the normal postage path, byte-identical refusals
accept_wallet_literalsfalseChain-less/dev instances only (no [grpc] gateway): accept a syntactically valid 32-byte base58 wallet address as the recipient local part, mirroring the account API’s chain-less compose route. No postage check applies without a chain — which is why the default is off: unset keeps the postage gate and every refusal byte-identical. Inert with a gateway configured, since that path already resolves wallet literals and keeps the postage gate

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.

Identity defaults from the chain

With the chain pipeline enabled, the chain itself is the best source for these two identity fields: at startup sithbitd asks the gateway once (ListAuthoritativeDomains) for every active domain whose on-chain authority is the gateway’s own signing key, and any field you left unset adopts the answer — local_domains takes the whole list, hostname the first local domain (alphabetically-first, when discovered). Explicitly configured values are never overridden, the lookup never blocks or fails the boot (trouble degrades to the static defaults with one warning), and the boot log states each adopted value and its source (configured / discovered / system / fallback). The chain-disabled dev stack skips the lookup and falls through to the machine’s /etc/hostname, then "localhost".

One caveat: on-chain authority proves protocol authority, not DNS plumbing. The discovered name becomes the EHLO greeting, and legacy relays may compare that greeting against forward and reverse DNS — a domain apex with no matching A/PTR records can cost you deliverability even though every SithBit-side check passes. If this server fronts legacy SMTP peers, set hostname explicitly to the listener’s real FQDN (the one its PTR record names).

Wallet submission envelopes (local_domains)

local_domains has a second job beyond local delivery: it bounds the envelope sender a wallet-authenticated submission session may use. A session that logged in as a bare wallet address — the mail password or a client certificate, both of which authenticate the wallet itself — may present exactly

<its own wallet base58>@<a domain this listener is authoritative for>

and nothing else. Another wallet’s address, its own address at a domain this server does not serve, a case-variant of its own base58, and the null sender MAIL FROM:<> are each refused 553 5.7.1. Base58 is case-sensitive — Alice and alice decode to different keys — so the local part is compared exactly. Submission by alias with a password is unaffected: the rule is consulted only for wallet-literal identities.

The domain leg has two scopes that stack. local_domains is always the server’s authority — “is this one of the domains I serve?” — and it alone gates a chain-disabled listener. But when the listener has a chain gateway ([grpc] configured), a second, per-wallet check rides on top: the envelope domain must also be one the authenticated wallet owns on-chain — i.e. the wallet is that domain’s recorded authority, looked up through the gateway’s GetMailDomain RPC (an exact base58 authority match). So local_domains scopes which domains the listener will serve at all, and the on-chain authority check scopes which of those the authenticated wallet may actually send as. On a multi-domain instance a wallet may therefore send as itself only at the domains it owns on-chain, not at every domain the instance serves.

When the listener has no chain gateway (a chain-disabled or empty-config dev MX, e.g. MemoryVerifier / an empty [grpc]), the per-wallet lookup is unavailable and the check falls back to local_domains alone, exactly as before — so an empty-config dev stack still sends. The daemon (sithbitd) submission path, which wraps the same driver behind its away-schedule handling, enforces the tightened rule identically.

The dev-stack trap. When local_domains is empty the check falls back to hostname alone, and an empty config’s hostname is "localhost" — the chain pipeline is disabled there, so discovery never fills the list. Submitting as <wallet>@sithbit.net against that stack is refused 553 5.7.1 Sender address does not match authenticated user, whose text names the sender and never hints that the domain was what failed. The fix is one line — list the domain explicitly on the submission listener, which carries its own list; the MX section’s copy does not carry over:

[submission]
local_domains = ["sithbit.net"]

Production instances are largely immune: sithbitd fills local_domains from chain discovery at startup (see Identity defaults from the chain above), so the domains the server serves are exactly the ones its wallets may send from.

Domain block list (dbl_zone)

Where dnsbl_zone scores the connecting IP at connect time, dbl_zone scores the sender domain: the MX listener queries the Spamhaus DBL at both EHLO (the greeting host) and MAIL FROM (the envelope-sender domain), and a listed domain is refused with a permanent 554 5.7.1 naming it. The value is the full zone string, and which zone you use is a Spamhaus registration question, not a syntax one:

  • DQS (recommended) — the current Spamhaus form is <key>.dbl.dq.spamhaus.net, where <key> is your 26-character per-customer Data Query Service code from a free registered DQS account. Example: dbl_zone = "abcdefgh1234567890ijklmnop.dbl.dq.spamhaus.net".
  • Public dbl.spamhaus.org (deprecated) — the legacy public zone still resolves, but Spamhaus deprecates it for anything beyond small non-commercial volumes and blocks it from the big public resolvers (Google 8.8.8.8, Cloudflare 1.1.1.1, Quad9): a query through one of those returns no useful answer. If you use it, point the host at your own recursive resolver, not a public one.

Default None = off: no domain lookups happen and no dbl_zone line is needed. Following the repo convention, leave the example commented out with its default when you do add it:

[smtp]
# dbl_zone = ""   # off; set to "<key>.dbl.dq.spamhaus.net" to enable

One setting, two pages. Set self_service_base_url to the public base URL where the self-service pages for refused senders are hosted (they ship in the onboarding web bundle, normally the account API’s [static] root), and the RCPT-time refusals start telling senders how to fix themselves:

  • the two postage refusals — 450 4.7.0 (frombox out of stamps) and 550 5.7.0 (no frombox) — append ; fund it at {base}/fund.html?to=<recipient>&from=<sender>;
  • under sithbitd, the do-not-disturb refusal — 450 4.2.1 (recipient away) — appends ; schedule at {base}/dnd.html?to=<recipient>.

Query values are percent-encoded and a trailing / on the base is trimmed. Unset (the default), every refusal stays byte-identical to the legacy linkless text — same code, same enhanced status, same line. The standalone smtp-server dev binary honors the same key but carries only the funding link: the DND gate (and so the schedule link) is sithbitd’s. What each page shows the sender is on Do not disturb.

Sender authentication (sender_auth)

The MX listener’s sender_auth selects how inbound relayed mail is authenticated, from lax to strict. It has no effect on [submission] (authenticated submission trusts the logged-in user). The four values:

  • "spf" (default) — SPF at MAIL FROM, rejecting only a published hardfail (RFC 7208 §8.4); DKIM is verified and recorded in the Authentication-Results header but never rejects.
  • "dmarc-lite" — full DMARC alignment (RFC 7489) at end-of-DATA, but it bounces only on p=reject with neither SPF nor DKIM aligned. A raw SPF hardfail no longer rejects on its own, so legitimately forwarded mail carrying an aligned DKIM signature survives. p=quarantine is recorded but not enforced, and pct sampling is not applied — enforcement is all-or-nothing.
  • "dmarc" — the full DMARC disposition. Alignment is evaluated as in dmarc-lite, but the domain’s entire published policy applies: p=reject bounces (550 5.7.1), and p=quarantine accepts the message but files the recipient’s copy into their Junk folder (auto-created on first delivery — no operator setup). It also honors pct: a deterministic per-message roll drops enforcement one step (reject→quarantine, quarantine→none) for the unsampled fraction, per RFC 7489 §6.6.4, so pct=100 always enforces and pct=0 never does. A "dmarc" MX can also emit RFC 7489 aggregate (rua) reports back to the domains it evaluates — off unless you enable [spooler.dmarc_report] — and per-failure forensic (ruf) reports, off unless you enable [spooler.dmarc_ruf].
  • "none" — no sender authentication. Intended for tests, offline dev, and submission-only instances.

For an internet-facing MX, run "spf" or stricter; see the threat model for why a lax MX is worse than ordinary spam.

Outbound quotas ([smtp.quota] / [submission.quota])

Both listener sections carry a [quota] sub-section: rolling per-account limits on the external recipients an authenticated sender may relay per hour and per day. Only relayed foreign-domain recipients count — local, on-chain-stamped mail never does, because stamps already price it. External sends never touch the chain, so no on-chain fee prices them; this quota is the off-chain counterpart, and its new-account ramp (below) is the operational form of “reputation reduces sender friction”: a week-old account earns double a new one’s allowance, doubling each week up to the cap.

KeyDefaultMeaning
enabledtrueMaster switch for the quota math only. Suspension (below) is independent — a suspended account is refused even with quotas off — and accepted external recipients are still counted while disabled, so the ledger is truthful if enforcement is enabled later
base_hourly50Hourly external-recipient allowance for a brand-new account
base_daily200Daily external-recipient allowance for a brand-new account
max_hourly500Ceiling the hourly allowance ramps up to
max_daily2000Ceiling the daily allowance ramps up to

The effective allowance is min(cap, base × 2^account_age_weeks) — at the defaults:

Account ageHourlyDaily
0 weeks50200
1 week100400
2 weeks200800
3 weeks4001,600
4+ weeks500 (cap)2,000 (cap)

How enforcement behaves on the wire:

  • Per external RCPT: an over-quota external recipient is refused 452 4.5.3 (transient — “try again later”); local recipients in the same transaction are unaffected, and quota refusals deliberately do not burn the session’s recipient-error budget, so a well-behaved client finishes the transaction for its accepted recipients and retries the refused one after the window rolls.
  • Recording happens on acceptance only — a refused or all-local message adds nothing to the counters.
  • Counters key on the wallet: an alias login resolves to its wallet first, so aliases share the wallet’s counters (and its suspend flag) rather than getting their own.
  • The counters live in the account store — hour-bucketed rolling windows, on every [store] backend alike.

The account API enforces the same policy on compose through its own twin [quota] section (below) — the two are twins by design and must be kept in step, so a sender meets one policy whichever submission surface they use. Following the zero-config rule, the defaults are complete; the commented block:

# [submission.quota]      # ([smtp.quota] takes the same keys)
# enabled = true
# base_hourly = 50
# base_daily = 200
# max_hourly = 500
# max_daily = 2000

Alongside the quotas rides the account suspension flag, set and cleared over the admin API and honored by every server (SMTP AUTH/MAIL, IMAP, POP, compose) regardless of enabled. The refusal codes per surface, the admin endpoints, and complaint handling are in Monitoring — outbound quotas and suspension.

One scope note: the standalone smtp-server dev binary parses the [quota] section (same config shape) but wires no account store, so the gate is inert there — enforcement is sithbitd’s (and the account API’s) job.

[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 — except on an IMAP-only instance (both SMTP roles disabled), where nothing delivers in-process, so a 0 auto-adopts 5 with an info log; an explicit value is always honored. See Role-split topologies
imap.client_cert_authfalseRequest a TLS client certificate and offer SASL EXTERNAL. Inert without [imap.tls]; client auth stays optional
pop.client_cert_authfalseSame, for POP — but EXTERNAL is offered on implicit-TLS (POP3S/995) listeners only (see the caveat below). Inert without [pop.tls]

[*.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 / :1430 / :1100Listen address (the only per-listener default that differs: SMTP 2525, IMAP 1430, POP 1100; the submission listener has no distinct default — set it explicitly when enabling)
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
proxy_trusted[]CIDR allowlist of the socket peers permitted to speak PROXY protocol, e.g. ["10.0.0.0/8", "2001:db8::/32"] (bare addresses count as /32 or /128; a v4 entry also matches v4-mapped peers on dual-stack listeners). Untrusted peers are refused before a single header byte is read, so a stray direct client can’t spoof its address even if it reaches the port. Empty (the default) trusts any peer — suitable when only the balancer can reach the port. Ignored unless proxy_protocol is on; a malformed entry fails startup naming it
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 (the PEM certificate chain and private key, e.g. fullchain.pem / privkey.pem). Each is a key source: a file path (default), or a cloud secret-manager secret holding the PEM. With no TLS section the listener runs plaintext — fine on loopback, not on the internet.

Production posture: implicit TLS and require_tls (RFC 8314)

The dev defaults are loopback plaintext, but the production posture is RFC 8314: TLS on connect for submission and access, and no credentials offered before the connection is protected. Two settings carry it:

  • implicit_tls = true on the [submission.server] / [imap.server] / [pop.server] listeners, bound to the standard secure ports — 465 (submission, SMTPS), 993 (IMAPS), 995 (POP3S) — so the socket is wrapped in TLS at accept, with no STARTTLS/STLS round trip. The MX listener on 25 stays plaintext-with-STARTTLS by nature (foreign MTAs reach it that way). The STARTTLS/STLS secondaries on 587/143/110 (implicit_tls = false) are an opt-in for legacy clients; advertise them at a lower SRV preference (DNS setup).
  • require_tls refuses AUTH / LOGIN / USER until TLS is active. It is on by default for the SMTP submission edge (mode = "submission"), IMAP, and POP, so the production posture needs no config entry for it — the MX listener does no SASL AUTH and is unaffected. The three protocol-appropriate enforcement guards are detailed in the conformance reference.

mail_spooler/sithbitd.example.toml ships the full implicit-TLS stack as commented [submission.server] / [imap.server] / [pop.server] + [*.tls] blocks, and docker-compose.prod.example.yml publishes the 465/993/995 primaries (plus 25 MX) with the STARTTLS ports commented out — see Running a mail server: Production.

Client-certificate auth (SASL EXTERNAL)

The client_cert_auth toggle on [submission], [imap], and [pop] turns on the passwordless login path: a client presents a self-signed Ed25519 TLS client certificate whose public key is the wallet’s 32-byte signing key (the wallet address, and so the mailbox/maildrop identity), and the server authenticates it as that wallet via SASL EXTERNAL — the completed TLS client-auth handshake is the proof of key possession, so nothing is stored server-side. It is the alternative to the derived mail password (sithbit mailbox credentials, SASL PLAIN); sithbit mailbox create-cert mints the certificate (see Thunderbird / Outlook). The certificate’s subject/SAN are ignored — only the SPKI key binds — and an empty or wallet-matching SASL authzid is accepted while a mismatching one is rejected.

The toggle is off by default and inert unless the matching […tls] section is present, since client-cert auth exists only over TLS. When it is on the listener merely requests the client certificate (optional TLS client auth), so password clients keep connecting on the same port; EXTERNAL is advertised and accepted only on a connection actually running under client-auth TLS. One caveat for POP: a POP listener fixes its mechanism list at connection start, so EXTERNAL is offered on implicit-TLS listeners (POP3S, port 995) only — a plaintext socket upgraded with STLS will not retroactively advertise it. SMTP submission and IMAP have no such restriction (STARTTLS or implicit TLS both work).

[spooler] — outbound workers

KeyDefaultMeaning
enabledtrueRun the background workers — relay, DSN, chain pin/send + delete, the auto-settle sweeper, the reconciler, and the repin migration — as one unit. false makes a listeners-only role instance: mail is still accepted and spooled, and a worker-enabled sibling over the same shared store drains the queues. The DMARC RUA/RUF workers keep their own switches, and the embedded IPFS swarm is unaffected. See Role-split topologies
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
mta_ststrueHonor recipient domains’ MTA-STS policies (RFC 8461) on direct-to-MX delivery. Ignored when [spooler.smarthost] is configured
danetrueHonor recipient MX hosts’ DANE TLSA records (RFC 7672) on direct-to-MX delivery, DNSSEC-validated; preferred over MTA-STS where both exist. Ignored when [spooler.smarthost] is configured
dead_retention_days30Hourly prune of dead-lettered jobs buried longer ago than this many days; 0 = never prune. Entries with no readable bury date count as older than any cutoff — see Monitoring
report_retention_days0Hourly prune of stored report blobs older than this many days — ingested DMARC aggregate reports under dmarc_rua/ and pending TLS-RPT result rows under tlsrpt/pending/; 0 (the default) = never prune, keep forever. dmarc_rua/ is the data GET /v1/admin/dmarc-reports serves — enabling retention removes reports from that admin surface once they age past the window, which is exactly why the default never does so un-asked. The worker only runs with enabled = true above
spooler.settle.enabledtrueRun the auto-settle sweeper (only when the chain pipeline is enabled)
spooler.settle.after_days30Post-delivery grace window before a delivered copy is settled
spooler.settle.keep_pinfalseKeep the IPFS pin at settlement instead of releasing it

[spooler.smarthost] routes all outbound mail through a fixed relay — a smarthost — instead of MX resolution: host, port, user, password, require_tls, implicit_tls. implicit_tls = true (default false) dials the smarthost with TLS from the first byte — the port-465 “SMTPS” style, named after the listeners’ switch — instead of the default in-band STARTTLS; the port is not auto-switched to 465, it stays whatever you set. Certificate verification stays the smarthost path’s strict webpki check, and because implicit TLS is TLS the conversation is encrypted even with require_tls = false. [spooler.dkim] signs authenticated submissions with DKIM: domain, selector, key_file (see DNS setup for the matching DNS record). The key_file is a key source — a file path (default) or a cloud secret-manager secret. 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.

With no smarthost, the relay resolves each recipient domain’s published MTA-STS policy (RFC 8461) before dialing its MXes — on by default via mta_sts. An enforce policy restricts delivery to the MX hosts matching the policy’s mx patterns, each contacted over TLS with a verified certificate; any TLS failure — STARTTLS missing or refused, a handshake or certificate error, or zero matching MXes — defers the mail on the normal retry schedule rather than falling back to plaintext. A testing policy delivers opportunistically and logs each MX target that would fail under enforce (with [spooler.tlsrpt] enabled, TLS-RPT reports cover these attempts too); a none policy, no policy, or a transient DNS failure with nothing cached keeps today’s opportunistic TLS (RFC 7435). Policies are cached in memory per domain for their max_age (clamped to one year), so a cached enforce policy keeps applying even if the DNS record is stripped. mta_sts = false is a deliverability-debugging escape hatch only; the switch is not consulted when a smarthost is configured, since that path never resolves MXes.

DANE (RFC 7672) rides the same direct-to-MX path — on by default via dane. When an MX host publishes a DNSSEC-validated TLSA record set at _25._tcp.<mx-host>, the STARTTLS handshake must match the published certificate data (the handshake is pinned to the records, not to the webpki roots), and any mismatch or TLS failure defers the mail rather than falling back — for that host DANE outranks an MTA-STS policy, including its mx pattern filter. A validated TLSA set whose records are all unusable for SMTP still demands TLS (unauthenticated — unless an MTA-STS enforce policy applies, which then stays the stricter floor). Hosts whose TLSA lookup fails DNSSEC validation are not dialed at all; domains without DNSSEC or without TLSA records keep today’s opportunistic TLS, so the switch only ever tightens delivery to domains that opted in. The switch also turns on DNSSEC validation for MX resolution itself: DANE requires a validated MX answer — or, for a domain with no MX record, a validated denial. When the negative answer’s SOA proves Secure, the implicit-A fallback is DANE-eligible and TLSA records at _25._tcp.<domain> apply (the domain itself is the connect host); a denial that cannot be validated keeps the fallback on opportunistic TLS as before. Like mta_sts, dane = false is a deliverability-debugging escape hatch only, and the switch is not consulted when a smarthost is configured.

[spooler.settle] runs the auto-settle sweeper: an hourly scan reclaims the on-chain stamp value of every delivered copy older than after_days (via DeleteMail) while keeping the local IMAP/POP copy — so the recipient keeps reading their mail, but its stamp value stops being locked on-chain. It is on by default at a 30-day window, and by default it also unpins the sealed IPFS copy at settlement, since settling removes the on-chain message account and the reclaimed copy no longer needs serving. That interplays with trustless web-client retrieval: once a message settles past the window, its decentralized copy is no longer fetchable — the on-chain CID is gone and the pin is released. Set keep_pin = true to leave the pin in place so the sealed copy stays fetchable after settlement, or enabled = false to never auto-settle. The sweeper only runs when the chain pipeline is enabled ([grpc] + [ipfs]); with either absent there is nothing on-chain to settle.

Pinning leases override the unpin leg per-CID, with no configuration: before releasing a pin the sweeper asks the gateway whether the copy’s CID carries a live on-chain lease. A leased copy still settles — DeleteMail reclaims the stamp on schedule — but keeps its pin for as long as the lease account exists. The check fails closed: if the gateway cannot answer (chain unreachable, or a gateway predating the GetPinLease RPC), the whole copy is left unsettled and retried next sweep, because a settled copy is never re-examined and a wrongly released pin cannot be won back. One accepted asymmetry follows: closing a lease after its copy settled does not retroactively release the pin — that storage is reclaimed by ordinary operator garbage collection, not by the sweeper.

[spooler.dmarc_report] — aggregate (RUA) reporting

[spooler.dmarc_report] emits DMARC aggregate (rua) reports — the RFC 7489 §7.2.1 gzip XML feedback a receiver sends back to each domain whose mail it evaluated. It is off by default: with the section absent (or enabled = false) the MX records no aggregation data and no reporting worker runs, so an empty/commented config stays a complete dev stack. Recording only happens on an MX running sender_auth = "dmarc" and only while this section is enabled.

KeyDefaultMeaning
spooler.dmarc_report.enabledfalseMaster switch. false (or section absent) records nothing and runs no worker
spooler.dmarc_report.org_name(required when enabled)Your reporter identity, written to the report’s org_name
spooler.dmarc_report.email(required when enabled)The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment
spooler.dmarc_report.submitter(the email domain)The reporting-MTA domain used in the report filename/subject
spooler.dmarc_report.extra_contact_info(none)Optional contact URI/text carried in the report
spooler.dmarc_report.window_hours24Aggregation window length reported in the report metadata
spooler.dmarc_report.interval_hours24How often the worker drains and sends

Each interval the worker drains the DMARC evaluations recorded since the last tick and emails one aggregate report per policy domain to the rua addresses that domain publishes in its DMARC record. Before sending to any address outside the policy domain it enforces the RFC 7489 §7.1 external-destination check — the target must publish a <policy-domain>._report._dmarc.<target> authorization record — so a report is only delivered where the receiving domain has opted in. Reports go out through the normal outbound relay path from email and are DKIM-signed like any other outbound mail (configure a matching [spooler.dkim] entry for that domain).

Scope and failure behavior, stated honestly:

  • A policy domain that publishes no rua address gets no report.
  • A rua target that fails the external-destination check is skipped (definitively discarded for that window).
  • A transient DNS or spool failure defers rather than drops: the affected rows are re-recorded for the next tick instead of being lost.
  • The report’s policy_published policy fields (p/sp/adkim/aspf) are not populated — the store does not retain the reported domain’s published policy, only the per-message evaluation results.

Commented block from sithbitd.example.toml (defaults shown):

# [spooler.dmarc_report]
# enabled = false
# org_name = "Example Mail"
# email = "[email protected]"
# submitter = "example.com"
# extra_contact_info = "https://example.com/dmarc"
# window_hours = 24
# interval_hours = 24

[spooler.dmarc_ruf] — forensic (ruf) reporting

[spooler.dmarc_ruf] emits DMARC failure/forensic (ruf) reports — the RFC 7489 §7.3 per-message feedback a receiver sends back the instant a message fails DMARC, wrapping the offending message in an RFC 5965 ARF message/feedback-report. Like aggregate reporting it is off by default: with the section absent (or enabled = false) the MX builds no forensic reports, so an empty/commented config stays a complete dev stack. Reporting only happens on an MX running sender_auth = "dmarc" and only while this section is enabled.

KeyDefaultMeaning
spooler.dmarc_ruf.enabledfalseMaster switch. false (or section absent) builds and sends nothing
spooler.dmarc_ruf.include_bodyfalseAttach the full offending message (message/rfc822) instead of the headers-only default — see the privacy note below
spooler.dmarc_ruf.org_name(empty — set when enabled)Your reporter identity, the report From: display name
spooler.dmarc_ruf.email(empty — set when enabled)The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment
spooler.dmarc_ruf.subject"DMARC Forensic Failure Report"The report Subject:; an empty value derives this default
spooler.dmarc_ruf.extra_contact_info(none)Optional operator contact URI. Accepted for parity with [spooler.dmarc_report], but the ARF forensic format carries no such field — informational only

Unlike aggregate reporting there are no window or interval settings: forensic reports are per-message, not batched. On a DMARC failure whose published policy carries a ruf= URI and whose fo= failure-options match, the server builds one ARF report and relays it directly and best-effort — no store, no worker, no retry queue. Before sending to any ruf= target it enforces the RFC 7489 §7.1 external-destination check (the target must publish a <policy-domain>._report._dmarc.<target> authorization record), exactly as aggregate reporting does, so a report is only ever delivered where the receiving domain has opted in. Reports relay from email through the normal outbound path and are DKIM-signed like any other outbound mail (configure a matching [spooler.dkim] entry for that domain). Each report also carries the offending message’s envelope identifiers — the RFC 5965 Original-Mail-From, Original-Rcpt-To, and Original-Envelope-Id fields — so the receiving operator can correlate the failure to the delivery attempt.

Privacy — headers-only by default. A forensic report carries the offending message itself to whoever the sender domain’s ruf= URI names, so it is a content-exposure surface aggregate reports never are. SithBit follows the RFC 7489 §7.3 content-minimization default: only the offending message’s headers are attached (text/rfc822-headers). Setting include_body = true attaches the full message/rfc822 — leaking the message’s entire content to the ruf= operator. Leave it off unless you specifically need full-body forensics and trust every domain you evaluate.

Scope and failure behavior, stated honestly:

  • A policy domain that publishes no ruf address (or whose fo= does not select the failure) gets no report.
  • A ruf target that fails the §7.1 external-destination check receives nothing — it is dropped, not retried.
  • A transient resolver or spool failure also drops the report: there is no persistence and no retry queue. A forensic report lost to a transient failure is acceptable by design (unlike aggregate reporting, which defers and re-records affected rows for the next tick).

Commented block from sithbitd.example.toml (defaults shown):

# [spooler.dmarc_ruf]
# enabled = false
# include_body = false
# org_name = "Example Mail"
# email = "[email protected]"
# subject = "DMARC Forensic Failure Report"
# extra_contact_info = "mailto:[email protected]"

[spooler.dmarc_rua_ingest] — DMARC report ingestion

The receiving side of DMARC aggregate reporting: when another operator’s receiver mails an RFC 7489 aggregate (rua) report to one of your operated domains, this section makes the delivery path parse it and store the result as JSON for the account API’s GET /v1/admin/dmarc-reports surface. Off by default — with the section absent (or enabled = false) inbound reports are ordinary delivered mail. Pair it with postmaster_wallet above so external reporters (who never hold a prefunded frombox) can reach the mailbox at all.

KeyDefaultMeaning
spooler.dmarc_rua_ingest.enabledfalseMaster switch. false (or section absent) delivers reports as ordinary mail, parsing nothing
spooler.dmarc_rua_ingest.recipients["postmaster"]Delivered recipients that trigger parse+store; each entry is a bare local-part (matched at any local domain) or a full address. The raw message still lands in the mailbox either way — parsing is additive, never a diversion. Parsed reports are stored under the fixed dmarc_rua/ blob prefix (deliberately not configurable — it is the admin surface’s read contract)

[spooler.tlsrpt] — SMTP TLS reporting (TLS-RPT)

[spooler.tlsrpt] records SMTP TLS Reporting (TLS-RPT, RFC 8460) results for outbound mail: one row per relay attempt against a recipient MX host — including hosts a policy excluded before dialing — noting whether the TLS session succeeded and, on failure, the derived RFC 8460 result code plus the policy (MTA-STS, DANE TLSA, or none) that governed the attempt. It is off by default: with the section absent (or enabled = false) the relay records nothing, so an empty/commented config stays a complete dev stack. Recording happens on the direct-to-MX path only — a configured smarthost is not the recipient domain’s TLS posture, so nothing is recorded when one routes everything.

KeyDefaultMeaning
spooler.tlsrpt.enabledfalseMaster switch. false (or section absent) records nothing
spooler.tlsrpt.org_name(required when enabled)Your reporter identity, written to the report’s organization-name
spooler.tlsrpt.email(required when enabled)The report From: and outbound relay origin. Should be a local, DKIM-signable address (RFC 8460 §3 requires reports to pass DKIM)
spooler.tlsrpt.contact_info(mailto: the email)The report’s contact-info URI
spooler.tlsrpt.window_hours24Aggregation window length reported in the report metadata
spooler.tlsrpt.interval_hours24How often the drain worker runs

Recorded rows accumulate as JSON blobs under the fixed tlsrpt/pending/ blob prefix (deliberately not configurable). The one enabled switch also starts the report drain worker: at each interval_hours tick it folds the pending rows into one RFC 8460 report per recipient domain, discovers the domain’s rua= targets from its _smtp._tls TLSRPT record, and delivers over both channels — mailto: targets ride the normal outbound relay, DKIM-signed on spool entry (hence the local, DKIM-signable email, whose domain doubles as the report’s submitter identity), https: targets receive the gzip-compressed JSON directly. A domain that publishes no TLSRPT record gets no report and that window’s rows are dropped; rows are deleted only after delivery to every target, so a crash between send and delete can re-deliver a window — the deterministic report-id lets receivers de-duplicate. See the conformance appendix for the full recording and reporting semantics.

Scope and failure behavior, stated honestly:

  • Recording is strictly observational: a failed row write logs a warning and never changes the delivery outcome, and a recorded TLS failure still defers/retries exactly as before.
  • Success rows are flag-truthful: a success is recorded only when the completed conversation actually ended on TLS. A completed plaintext opportunistic delivery — TLS never negotiated, including a declined STARTTLS offer that continued in the clear — records no row at all: under RFC 8460 it is neither a TLS session nor a failed attempt. The seam cannot tell “STARTTLS never offered” apart from “offered but declined” — both go unrecorded; starttls-not-supported failure rows are reserved for enforced postures that abort the delivery.
  • Handshake failures are recorded with the general validation-failure code and the TLS error detail in failure-reason-code; the specific certificate codes (certificate-expired, certificate-host-mismatch, …) cannot be distinguished at this seam.
  • Hosts a policy excludes before dialing record never-dialed failure rows: an unusable DANE TLSA set records dnssec-invalid, an MX target outside an enforce-mode MTA-STS policy records sts-policy-invalid, each with the planner’s diagnostic in failure-reason-code. Unreachable or timed-out hosts still produce no row, and MTA-STS testing-mode mismatches stay warn-log only.

Commented block from sithbitd.example.toml (defaults shown):

# [spooler.tlsrpt]
# enabled = false
# org_name = "Example Mail"
# email = "[email protected]"
# contact_info = "mailto:[email protected]"
# window_hours = 24
# interval_hours = 24

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). A key source: a file path (default) or a cloud secret-manager secret (auto-generation applies to the file form only)
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
[quota](enabled, sithbitd’s defaults)Outbound-relay quotas + suspension on compose — the same keys, defaults, and ramp as sithbitd’s [smtp.quota] / [submission.quota]; the two are twins by design, keep them in step. Over-quota external recipients at compose answer 429, a suspended composer 403
[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 — a key source: a file path (default) or a cloud secret-manager secret holding the PEM
tls.key_file(required)PEM private key — same file-or-cloud key source as cert_file

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; full tutorial and key reference in the sithbit-console appendix). 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
gateway_endpoint"http://127.0.0.1:50051"mail-grpc gateway the balances pane reads on-chain mailbox/frombox state through — a SolanaMail gRPC endpoint (GetMailbox default stamp price + mail count, GetFrombox per-sender stamps)
rpc_url"http://127.0.0.1:8899"Solana JSON-RPC node the balances pane fetches native SOL balances from (getBalance)

The balances pane is the one console view that reads chain state directly rather than through the account API: press b on a wallet to see its native SOL, its mailbox’s default stamp price and mail count, and the prepaid stamps each other loaded wallet holds toward it. Those figures come straight from mail-grpc and the JSON-RPC node above — so a console used only for the admin panes can leave both endpoints at their loopback defaults (or unset the whole file). Every entry defaults, so an empty or absent sithbit_console.toml targets the loopback dev stack:

# api_url = "http://127.0.0.1:8180"
# keypair_file = "~/.config/solana/id.json"
# gateway_endpoint = "http://127.0.0.1:50051"
# rpc_url = "http://127.0.0.1:8899"

domain-sithbit

KeyDefaultMeaning
bind_addr"127.0.0.1:8181"HTTP listen address
wwwroot"wwwroot"Static files (the verification front-end)
delegate_key_file(unset)Solana keypair of the postoffice’s standing delegate — the key that signs on-chain domain authorizations. A key source: a file path (default) or a cloud secret-manager secret. Re-loaded from the configured source on every POST /domain (for a cloud kind, a fresh fetch per request), so a delegate rotation is picked up by swapping the file or cloud secret, no restart. Boot-validated: an unreadable or malformed key fails startup, not the first request. Unset, POST /domain replies 503 (DNS lookups still work). A hot key by design — rotate it on a schedule

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.

[mail_hosts] — client autoconfiguration

The public IMAP/POP/SMTP coordinates domain-sithbit advertises to unmodified mail clients through its autoconfig/autodiscover routes (see domain-sithbit: client autoconfiguration). Every sub-server is dev-defaulted to a loopback stack on the standard implicit-TLS mail ports, so an empty config still serves a valid document; point the hosts at your real, publicly reachable servers before advertising them.

KeyDefaultMeaning
[mail_hosts.imap] host"127.0.0.1"Public IMAP hostname a client connects to
[mail_hosts.imap] port993IMAP port
[mail_hosts.imap] socket_type"SSL"Transport security (see below)
[mail_hosts.pop] host"127.0.0.1"Public POP3 hostname
[mail_hosts.pop] port995POP3 port
[mail_hosts.pop] socket_type"SSL"Transport security
[mail_hosts.smtp] host"127.0.0.1"Public submission hostname
[mail_hosts.smtp] port465Submission port — implicit-TLS SMTPS by default (RFC 8314); set 587 with socket_type = "STARTTLS" to advertise the opt-in secondary instead
[mail_hosts.smtp] socket_type"SSL"Transport security

socket_type takes the Thunderbird tokens "SSL" (implicit TLS from connect), "STARTTLS" (opportunistic upgrade), or "plain" (no encryption, dev only); lowercase aliases ("ssl", "starttls") are also accepted, and the Outlook POX <SSL>/<Encryption> flags are derived from it. A present [mail_hosts.<server>] sub-table must spell out all three fields (a partial table fails startup loudly); an omitted whole sub-server falls back to its defaults. Env overrides use the usual __ descent, e.g. DOMAIN_SITHBIT_MAIL_HOSTS__SMTP__HOST=mail.example.com.

[mta_sts] — MTA-STS policy publication

The MTA-STS policy (RFC 8461) this instance publishes at GET /.well-known/mta-sts.txt for the domains it fronts (see domain-sithbit: publishing the MTA-STS policy). The whole section is opt-in: absent, the endpoint replies 404. Senders fetch the policy as https://mta-sts.<domain>/.well-known/mta-sts.txt, so front the service with a TLS proxy holding a certificate for that hostname.

KeyDefaultMeaning
mode"testing"What the policy demands of senders: "enforce" (MX mismatch or TLS failure = do not deliver), "testing" (failures are reported, delivery proceeds), or "none" (the domain withdraws its policy). The default reports without blocking mail
mx[]MX identity patterns senders match delivery targets against — exact hostnames (mx.example.com) or a *. wildcard covering exactly one leftmost label (*.example.com). Must cover every host your MX records name. Required — at least one — unless mode = "none"
max_age604800 (one week)Policy lifetime in seconds — how long senders cache it. RFC 8461 recommends weeks. Values above one year (31557600, the §3.2 ceiling senders clamp to anyway) are rejected

Validation is fail-fast at startup, never per request: a mode outside the RFC vocabulary, an enforce/testing policy with an empty mx list, or an over-ceiling max_age all refuse to boot rather than serve a broken policy. Remember to bump the _mta-sts.<domain> TXT record’s id whenever you edit this section — senders re-fetch the policy only when that id changes (see DNS setup).

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 settings): 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

[swarm] — service-record freshness

When a node advertises itself for decentralized service discovery — publishing a signed service record on the DHT so clients can find its POP/IMAP endpoints without DNS SRV — two settings in the swarm section govern how fresh that advertisement stays. They live under [ipfs.swarm] for sithbitd and under [swarm] for sithbit-ipfsd (the same section that carries listen/bootstrap/provide/identity_file), and both have in-code defaults, so an empty swarm section is still valid — a plain node that never advertises a service simply ignores them.

KeyDefaultMeaning
service_record_ttl_secs900 (15 min)How long an advertised record stays fresh from its created_at stamp. Deliberately minutes-scale — service liveness wants minutes, unlike the ~22 h content-reprovide cadence — so a node that stops heartbeating ages out of discovery quickly. Validated on the client’s DHT get, so a lapsed record is dropped before it is ever trusted
service_heartbeat_interval_secs300 (5 min)How often the node re-stamps created_at and re-publishes its records. Keep it comfortably below service_record_ttl_secs so a record never lapses between heartbeats (the default 5 min ≪ 15 min TTL leaves two missed beats of slack)

These settings affect only advertisement freshness. Authority — who may serve the domain — is proved separately by the node’s delegation chaining to the on-chain MailDomain.authority, checked by the client both on the DHT record and in the self-authenticating TLS handshake, never by these timers. A public DHT advertising POP/IMAP endpoints is enumerable, so the usual [*.server] connection limits and DNSBL/DBL still apply to the listeners those records point at.

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
public_host(unset — path gateway only)Base domain for the subdomain (Host-based) gateway: set it to also serve <base32-cidv1>.ipfs.<public_host> browser-origin-isolated requests. Unset keeps path routing only
[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

Config file mail_grpc.toml (or MAIL_GRPC_CONFIG), env prefix MAIL_GRPC. An empty or missing file is a runnable dev gateway: the chain endpoint and the signing keypair fall back to the operator’s Solana CLI config (~/.config/solana/cli/config.yml),1 exactly like the sithbit CLI — a missing CLI config means the stock CLI defaults (mainnet-beta). This replaced the legacy environment-only configuration as a clean break: GRPC_SERVER_ADDRESS, DEFAULT_KEYPAIR, and the other old names are no longer read — see the mail-grpc chapter for the migration note. The one legacy name still honored is bare JSON_RPC_URL, which overrides the endpoint for parity with the CLI (see the json_rpc_url row below).

KeyDefaultMeaning
bind_addr"127.0.0.1:50051"gRPC listen address. Loopback by default — the gateway is private-network-only by design; expose it deliberately, never by default
json_rpc_url(unset — Solana CLI config)Solana RPC endpoint; unset falls back to the CLI config’s json_rpc_url. A bare JSON_RPC_URL environment variable overrides this (env > this key > CLI config), matching the sithbit CLI
keypair(unset — Solana CLI config)The fee-payer/signing keypair, a key source: a keypair-file path (not the keypair content) or a cloud secret-manager secret holding the keypair JSON. Unset falls back to the CLI config’s keypair_path (~/.config/solana/id.json by default)
alias_cache_seconds30TTL for the ResolveAlias cache (0 disables caching). Kept short because sold/transferred aliases must not resolve stale; when the alias indexer runs, marketplace events evict entries within its poll interval anyway
[alias_index] database"alias_index.db"SQLite path for the alias-enumeration index. Set explicitly empty to disable the indexer (ListAliases/ListSales then answer UNAVAILABLE)
[alias_index] poll_seconds5How often the indexer polls for new alias transactions
[health], [observability]health on 127.0.0.1:8193The two shared sections above

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 uses the same layered .env/.env.$APP_ENV mechanism as the binaries above (it reads them from the working directory, with MAIL_GRPC_* overrides), but ships no sample: its whole surface is the short table above, and mail_grpc/mail_grpc.example.toml already shows every key. The workspace-root .env that used to configure it is retired — its legacy names (GRPC_SERVER_ADDRESS, DEFAULT_KEYPAIR, …) are no longer read by anything, and the file survives only as commented-out devnet fixture history.
  • 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 / domain_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 CLI Quickstart. ↩2 ↩3