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

sithbitd: spooler settings

Part of the configuration reference, continuing sithbitd’s core settings: the [spooler] background workers — relay, DSNs, the smarthost and DKIM signing, MTA-STS and DANE, the auto-settle sweeper — plus DMARC aggregate (RUA) and forensic (RUF) reporting, inbound DMARC report ingestion, SMTP TLS reporting (TLS-RPT), and large-attachment IPFS offload. This is what turns mail a listener accepted (see the SMTP / IMAP, POP & security pages) into delivered mail.

[spooler] — outbound workers

Row markers follow the going-public legend.

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
spooler.hostname"localhost"REQUIRED (public). EHLO name, Reporting-MTA, and the MAILER-DAEMON domain. Inherits the shared top-level hostname while still on the built-in value
spooler.local_domains[]REQUIRED (public). Domains the DSN builder treats as locally deliverable. Inherits the shared top-level local_domains while empty
delay_dsnfalseEmit “delayed” DSNs on retry schedules
mta_ststrueRECOMMENDED (public). Honor recipient domains’ MTA-STS policies (RFC 8461) on direct-to-MX delivery. Ignored when [spooler.smarthost] is configured
danetrueRECOMMENDED (public). Honor 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_days0RECOMMENDED (public). Hourly 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_pinfalseRECOMMENDED (public). Keep the IPFS pin at settlement instead of releasing it
[health], [observability](the shared defaults)RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table

One more hourly prune runs alongside the two retention prunes above and carries no setting at all: the daemon deletes every expired login-challenge row (the account API’s auth_nonces, written by POST /v1/auth/nonce) each hour, unconditionally — even with enabled = false above, since a listeners-only instance shares the store those rows live in. There is nothing to configure because there is no retention policy to pick: each row carries its own ~300-second expiry set at issuance, so an expired challenge is garbage by definition, unlike a dead-lettered job an operator may want to inspect. A deployment running the account API with no daemon over its store keeps expired rows between logins — see account-api.

RECOMMENDED (public). [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.

REQUIRED (public). [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.

Each entry can additionally opt into RFC 8463 dual-signing with the ed25519_selector / ed25519_key_file pair — set both or neither (half a pair refuses to start; with both absent, the default, the entry signs rsa-sha256 only, unchanged). When configured, every signed message carries a second, ed25519-sha256 DKIM-Signature header alongside the rsa-sha256 one, each signing the same headers and body independently, so verifiers honor whichever algorithm they support. ed25519_key_file is the same key source shape as key_file and holds a PKCS#8 PRIVATE KEY PEM — what openssl genpkey -algorithm ed25519 writes. The second selector is DNS-mechanical, not decorative: one _domainkey name publishes one key record, so the ed25519 public key needs its own record at <ed25519_selector>._domainkey.<domain>, shaped v=DKIM1; k=ed25519; p=<key> where p= is the raw 32-byte public key base64 — not a DER-wrapped SubjectPublicKeyInfo like RSA’s.

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 9990 §3.5.2 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.enabledfalseRECOMMENDED (public). Master switch. false (or section absent) records nothing and runs no worker
spooler.dmarc_report.org_name(empty)REQUIRED (public) when enabled. Your reporter identity, written to the report’s org_name. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section.
spooler.dmarc_report.email(empty)REQUIRED (public) when enabled. The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section.
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_hours24Retired — still accepted so an existing config boots, but ignored. The period is fixed at 24 h
spooler.dmarc_report.interval_hours24Retired — as above; a daily report’s drain cadence is its window

Every 24 hours 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 9990 §4 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 fields carry p/sp/np/adkim/aspf, read from the record that governed each evaluation. np is echoed only when the domain actually published an np= tag: the DMARC library fills an absent one in with a copy of sp while parsing, and reporting that would attribute a policy the domain never stated, so the element is simply omitted instead — as it also is for an np= published equal to sp=, which RFC 9091 makes the default reading of an absent one anyway. One element is not emitted at all: discovery_method, deliberately omitted because this MX folds the report’s grouping domain with the public suffix list while its alignment verdicts come from the tree walk — claiming either method would be a false statement rather than a missing one.
  • Reports are emitted in the RFC 9990 urn:ietf:params:xml:ns:dmarc-2.0 namespace, and that is the intended behavior rather than an accident of a library version. RFC 9990 is Standards Track and obsoletes RFC 7489, so emitting the current schema is precisely what running a DMARCbis receiver means; and np — the element the bullet above discusses — exists only in dmarc-2.0, so downgrading the namespace would foreclose ever reporting it. The tradeoff, stated rather than hidden: a consumer that still validates strictly against the RFC 7489 dmarc-1.0 schema will refuse our reports, and there is no switch to emit the older form. That is an accepted consequence of adopting DMARCbis. In the receiving direction we stay lenient — the optional report ingest path accepts both dmarc-1.0 and dmarc-2.0 bodies. The emitted namespace is pinned by a test, so a future library bump that moves it again fails loudly instead of silently.

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

# [spooler.dmarc_report]
# enabled = false
# org_name = "Example Mail"
# email = "dmarc-reports@example.com"
# submitter = "example.com"
# extra_contact_info = "https://example.com/dmarc"
# # Retired: both keys still parse, but the period is fixed at 24 h.
# window_hours = 24
# interval_hours = 24

[spooler.dmarc_ruf] — forensic (ruf) reporting

[spooler.dmarc_ruf] emits DMARC failure/forensic (ruf) reports — the RFC 9991 §2 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)REQUIRED (public) when enabled. Your reporter identity, the report From: display name. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section.
spooler.dmarc_ruf.email(empty)REQUIRED (public) when enabled. The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section.
spooler.dmarc_ruf.subject"DMARC Forensic Failure Report"The report Subject:; an empty value derives this default

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 9991 §5 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 9991 §7.1 content-minimization guidance: 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 §5 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 = "dmarc-reports@example.com"
# subject = "DMARC Forensic Failure Report"

There is no extra_contact_info here, unlike [spooler.dmarc_report]: the ARF forensic format has no field to carry one, so the key never reached a report and was removed.

[spooler.dmarc_rua_ingest] — DMARC report ingestion

The receiving side of DMARC aggregate reporting: when another operator’s receiver mails an RFC 9990 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.enabledfalseRECOMMENDED (public). Master 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.chain_budget] — on-chain publication rate limit

Bounds how fast delivered mail is published to Solana, and with it how fast this daemon can spend from the gateway’s fee-payer wallet.

Why this exists. Recipients pay for postage, but the transaction fee for every SendMail comes out of the operator’s own gateway wallet. Most paths price the sender before anything reaches the chain — except postmaster_wallet, which RFC 5321 §4.5.1 requires to accept mail from anyone and which therefore skips the postage check by design. Without a budget, an unauthenticated sender can drive fee-payer spend at whatever rate the connection limits allow. Set this whenever postmaster_wallet is set.

Over-budget mail is not refused. It is accepted, stored, and readable over IMAP/POP immediately — only its on-chain publication is paced, by enqueueing the chain job invisible until its turn. Nothing bounces and no sender sees an error; the copy simply reaches the chain later. A sustained flood therefore grows the chain queue rather than being shed, which is the deliberate trade: mail is never destroyed to protect the wallet.

max_per_window is the setting that actually holds. On the postmaster_wallet path the envelope sender is unauthenticated and free to vary, so a per-sender budget alone is walked past by changing MAIL FROM on every message. max_per_sender_per_window is fairness on top — it stops one heavy sender consuming the whole shared allowance — never a substitute.

Both budgets count publications per window, not lamports: a SendMail fee is near-constant, so a publication count stands in faithfully for the money spent. The window is fixed rather than sliding, so a burst straddling a boundary can reach up to twice the budget — the same trade the account API’s rate limits make.

KeyDefaultMeaning
spooler.chain_budget.max_per_window0REQUIRED (public) when postmaster_wallet is set. On-chain publications admitted per window across every sender together. 0 = unlimited. This is the budget that bounds fee-payer spend; set it whenever postmaster_wallet is set
spooler.chain_budget.max_per_sender_per_window0RECOMMENDED (public). Publications admitted per window for any one envelope sender, so a single heavy sender cannot consume the whole shared allowance. 0 = unlimited. Fairness only — an unauthenticated sender can vary MAIL FROM, so this never bounds total spend on its own
spooler.chain_budget.window_secs60RECOMMENDED (public). Seconds both budgets reset on. When this daemon shares a store with account_api, keep this at or below that service’s longest rate-limit window — the store’s window sweep takes a cutoff rather than a key prefix, so a shorter-windowed sweeper can retire a live window early, which makes the budget under-enforce for one window

The budgets are charged against the same store-backed counter the account API’s durable rate limits use, so every sithbitd replica over one store shares one budget rather than getting its own. A store that cannot answer fails open — the publication goes out unpaced, and a warning is logged — because a counter outage must not stop mail reaching the chain.

Report traffic this daemon generates itself (DSNs, DMARC forensic reports, TLS-RPT reports) is deliberately not budgeted: it is the operator’s own mail, exactly as it is already exempt from the per-wallet storage cap.

[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.enabledfalseRECOMMENDED (public). Master switch. false (or section absent) records nothing
spooler.tlsrpt.org_name(empty)REQUIRED (public) when enabled. Your reporter identity, written to the report’s organization-name. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section.
spooler.tlsrpt.email(empty)REQUIRED (public) when enabled. The report From: and outbound relay origin. Should be a local, DKIM-signable address (RFC 8460 §3 requires reports to pass DKIM). sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section.
spooler.tlsrpt.contact_info(mailto: the email)The report’s contact-info URI
spooler.tlsrpt.window_hours24Retired — still accepted so an existing config boots, but ignored. The period is fixed at 24 h
spooler.tlsrpt.interval_hours24Retired — as above; a daily report’s drain cadence is its window

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: every 24 hours 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 = "tlsrpt@example.com"
# contact_info = "mailto:tlsrpt@example.com"
# # Retired: both keys still parse, but the period is fixed at 24 h.
# window_hours = 24
# interval_hours = 24

[spooler.offload] — large-attachment IPFS offload

[spooler.offload] keeps oversized attachments out of the stored and pinned message. An attachment larger than threshold_bytes decoded bytes is sealed under its own freshly generated key, pinned through the same [ipfs] provider the chain workers use, and replaced in the delivered message by a placeholder part whose link points at <gateway_url>/ipfs/<cid> — with the key riding in the URL’s #fragment, the one part of a URL a client never puts on the wire. The gateway therefore serves ciphertext it cannot read, and only a recipient holding the whole link can open the attachment.

Two size rules decide which parts leave, and they compose as a union rather than one overriding the other. threshold_bytes takes any part individually over it. aggregate_bytes then caps what one message may leave inline in total, offloading the largest eligible parts until the rest fits — the case a per-part threshold structurally cannot catch, because it is compared per part and only per part, so twenty 1 MB parts under a 5 MiB threshold ride inline as a 20 MB message.

It is off by default, and opting in is deliberate: with the section absent — or present but leaving both threshold_bytes and aggregate_bytes at 0 — no message is ever rewritten and delivered bytes are byte-identical to a daemon without the feature. Naming a gateway_url alone does not arm it. Either size rule arms it on its own, so aggregate_bytes with no threshold set is a valid “cap the total, ignore part size” configuration. Offload also requires the chain pipeline ([grpc] + [ipfs]): without a pinning provider there is nothing to build a fetchable link from, so the settings stay inert.

KeyDefaultMeaning
spooler.offload.gateway_url"http://127.0.0.1:8183"REQUIRED (public) when enabled. Public base URL of the read-only IPFS gateway (sithbit-gateway) serving the pinned ciphertext. Recipients’ clients fetch this, so it must be the address they can reach — never an internal one; the default is that gateway’s own loopback dev bind so the zero-config stack stays coherent. A trailing / is ignored and a path prefix (https://example.com/gw) is honored
spooler.offload.threshold_bytes0RECOMMENDED (public). Decoded attachment size, in bytes, above which a part is offloaded (strictly greater; the comparison is against the decoded size, not the base64 on the wire). 0 — the default — disables the feature outright
spooler.offload.aggregate_bytes0RECOMMENDED (public). Decoded attachment bytes one message may leave inline in total, above which the largest eligible parts are offloaded until the rest fits (strictly greater, decoded, like threshold_bytes). 0 — the default — leaves this rule off; a non-zero value arms the offload on its own. Counts attachment parts only, never the text or HTML bodies, and is a target rather than a guarantee: a part content_id holds inline still counts toward it but is never taken to satisfy it
spooler.offload.content_id"never"What to do with an over-threshold part carrying a Content-ID — the header an HTML body uses to render a part inline as cid:…. "never" holds every such part inline at any size; "orphaned" also offloads the ones no body actually references; "all" additionally offloads referenced ones, rewriting each <img> that rendered one into a link. See What is offloaded — and what never is

A pin failure tempfails the whole submission (451) rather than delivering the attachment inline: the sending MTA retries with the attachment intact, while a silent downgrade would defeat the threshold in exactly the case it exists for.

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

# [spooler.offload]
# gateway_url = "http://127.0.0.1:8183"
# threshold_bytes = 0
# aggregate_bytes = 0
# content_id = "never"

Choosing a threshold. 5 MiB (5242880) is the suggested production value: comfortably above ordinary photo and PDF attachments — which stay inline and behave like ordinary mail — and well under the 25 MiB max_message_size accept ceiling, where a single attachment would otherwise dominate every stored and pinned copy. Set it lower only if you would rather more attachments became links.

Choosing a budget. 10 MiB (10485760) is the suggested production value for aggregate_bytes: it leaves ordinary multi-attachment mail alone while bounding the pathological case the threshold misses. The two numbers answer different questions and are worth setting together — the threshold decides “is this one file too big to store?”, the budget decides “is this whole message too big?” — and because the budget takes the largest parts first, a message that trips only the budget loses as few of its inline attachments as the arithmetic allows.