The mail-grpc gateway topology (design note)
This note records a deliberate architecture decision (2026-07-15): the
mail-grpc chain gateway stays a separate
service rather than being folded into the servers that consume it. It
explains what the gateway actually is, who talks to it, why the
alternatives were rejected, and what would reopen the question — so the
“why is there a gateway?” answer survives in one place.
What mail-grpc actually is: three roles in one process
The gateway is not one thing but three, and any topology decision has to place all of them:
- A remote-signing write gateway. Five RPCs (
SendMail,DeleteMail,RefundMail,ClaimBounty,RefundBounty) build, sign, and submit Solana transactions. Every one of them signs with a single process-wide keypair — thekeypairdescribed in the mail-grpc chapter — which acts as fee payer and sole signer. For the bounty RPCs that key is the on-chain wallet being settled: the gateway is, in effect, the operator’s on-chain mail identity. - A chain-read gateway. Thirteen RPCs (
ResolveAlias,GetFrombox,GetMailbox,GetMailboxKey,GetTransactionStatus,FindMessage,GetMailDomain,ListAuthoritativeDomains,BrowseListings,ListParticipants,GetSenderAttestation,GetPinLease,GetSenderReputation) read accounts and signatures directly from finalized chain state —GetPinLeaseas a filteredgetProgramAccountsscan for a CID’s leases,GetSenderReputationas two account fetches (the wallet’s reputation PDA plus the postoffice) folded through the on-chain pricing rule. No key material is involved beyond the operator identity two of them imply (ListAuthoritativeDomainsanswers “which domains is my signing key authoritative for”). - The only home of the off-chain alias/sales indexer.
ListAliasesandListSalesare served from a SQLite index the gateway builds by scanning transaction history, because alias names are not recoverable from chain state alone (accounts key on hashes). This role is an irreducible singleton: deleting the gateway would not delete a deployable role, it would relocate one — and hand every consumer that wants alias listings a new protocol to reach it.
Two keys, two services — a common conflation
The gateway’s signing key is not the postmaster’s standing delegate key, though the two are easy to conflate:
- mail-grpc’s signing
keypairis a general fee-payer/signing key for mail traffic (sends, deletes, bounty settlement). It can be loaded from Azure Key Vault. - The standing delegate (
delegate_key_file) lives indomain-sithbit, authorizes domains on-chain, and is re-read from disk on every request — see Postmaster key custody.
Two keys, two services, two custody stories. Keeping the write gateway separate keeps the mail-signing key in exactly one process — the only AKV-capable holder — instead of copying it to every worker.
Fund the signing key as a hot wallet
The gateway signs unattended, for every chain write the fleet makes, on a port whose only protection is where it sits. Treat the key accordingly:
- Narrowly funded. Keep a working balance — enough transaction fees for the traffic between top-ups, plus room for whatever bounties the spool escrows — and top it up on a cadence rather than parking a treasury there. What is in the wallet is what is at risk.
- Never doubles as anything else. It must not be a program’s
--upgrade-authority, the postmaster’s domain authority, a marketplace treasury, or a user wallet. Those keys should be offline and separately custodied; see Postmaster key custody. - Monitored. The balance is an operational signal: a drop faster than the fleet’s send volume explains means either a misconfigured bounty or an exposed port.
Who consumes it — and who never does
Every consumer is another server; no end-user client ever dials the gateway:
sithbitdis the only writer. Its chain workers driveSendMailandDeleteMailoff store-backed job queues, serialized per wallet by a store lease — which is why a cloud-store fleet of manysithbitdinstances can safely share one gateway. Its SMTP accept path asks the gateway for postage at RCPT time, and its at-rest sealing asks it for reader keys.- A standalone
smtp-serverMX performs read-only postage verification (ResolveAlias+GetFromboxper recipient). account-apiroutes compose recipients (alias → postage check) and backs its/v1/chainREST proxy with gateway reads. Its balance and transaction-submit routes deliberately use its own direct Solana JSON-RPC instead — client-signed transaction relay and plain balance reads need no gateway semantics. That split is documented here as intentional; it is not drift to “fix”.sithbit-console’s balances pane readsGetMailbox/GetFromboxdirectly.- Web and plugin clients never touch it. There is no gRPC-web anywhere: the webmail/marketplace/onboarding panes reach chain data through account-api’s REST proxy, and trustless webmail goes straight to a public Solana RPC endpoint with client-side decoding — bypassing the operator’s servers entirely.
The dependency contract matters as much as the call graph: consumers
link only the mail_api proto crate (wire types + generated client, a
handful of dependencies) instead of the full Solana host stack
(~a dozen solana-* crates plus their transitive weight) that
mail-grpc absorbs once. The same seam is the test seam — the
protocol servers’ suites fake the SolanaMail service in-process, which
is what keeps them hermetic and fast.
Network posture: private-network-only, by design
The gateway signs with a process-wide fee-paying wallet on behalf of whoever calls it, so the question “who may call it” decides who may spend that wallet. The answer is now given by the transport, not by the network alone:
A non-loopback
bind_addrrequires mutual TLS.mail-grpcrefuses to start on any reachable address without a complete[auth]section, and authenticates each caller by the Ed25519 key in its client certificate. Loopback with no[auth]serves unauthenticated, which is what keeps a zero-config dev run and the test suites working.
Note the asymmetry: the loopback exemption keys off the absence of
configuration, not off the address being privileged. An [auth]
section that is present is enforced on every bind, loopback included —
so configured authentication can never be silently ignored, and the
authenticated path stays reachable from tests, which bind loopback.
Keeping the gateway on a private segment is still correct and still
recommended — the iac/ templates bring a private subnet with no public
ingress in all three clouds. It is simply no longer the only thing
standing between the fee payer and the network. Reachability narrows who
can attempt a call; [auth] decides who is served.
This reverses a posture recorded twice. Until 2026-08-26 the surface carried no TLS and no authentication, and a security review that raised the unauthenticated write surface was answered with guardrails rather than authentication. That decision named its own reopen trigger — a gateway reachable across a segment the operator does not trust — and the trigger has been exercised deliberately, on a zero-trust reading of the network. Do not re-derive the old posture from an older document.
The guardrails around that posture
[auth] decides who is served; these two settings bound what an
authorized caller can cost if one is ever compromised, and they are
unchanged by the move to mutual TLS:
max_bounty_lamports(default100000000, 0.1 SOL) — aSendMailrequest’sbounty_lamportsis escrowed from the gateway’s own wallet, so an uncapped caller-chosen amount is a wallet-draining primitive rather than a protocol parameter. Over-cap requests are refused withINVALID_ARGUMENTbefore anything reaches the chain;0refuses bountied sends outright.fee_payer_floor_lamports(default10000000, 0.01 SOL) — the balance below which every write RPC is refused withUNAVAILABLEand/readyzreports not-ready. The balance is sampled on a 30-second timer rather than per request, so the check costs no round trip; a balance never read successfully permits writes, which is what keeps library embedders and the test suites on the unguarded path. This is a floor on spending, not a bound on loss — see the funding guidance above — but it converts a drained wallet from a queue of slow preflight failures into one refusal and an alert.
None of these substitutes for the network boundary — they are what keeps a misconfiguration from being unbounded.
One operational caveat follows: an
administrator running sithbit-console from outside that network needs
a route in (VPN or bastion) for the balances pane — everything else the
console does goes through account-api.
Options considered
| Ops surface | Key custody | Test seam | Dep graph | |
|---|---|---|---|---|
| A. Keep separate (chosen) | one extra process (chain profile only) | one AKV-capable holder | intact | consumers stay proto-only |
| B. Embed in every consumer | −1 process, but the indexer needs a new home and protocol | key copied to N processes | all in-process fakes destroyed | Solana stack linked everywhere, incl. RCPT-only MXes |
C1. Writes into sithbitd only | 2 chain-access paths forever | key copied to every worker | write-path fakes broken | sithbitd links the full stack |
C2. Optional in-process hosting in sithbitd | both hostings maintained forever | unchanged | intact | sithbitd links the full stack |
Decision and rationale
Option A: the gateway stays a separate, private-network service.
- The indexer forces a standing service anyway. The marginal cost of the read/write gateway roles riding in the same process is near zero; every integration variant still runs a process and pays migration.
- Custody stays singular. One key, one process, Key-Vault-capable. Integration multiplies copies across workers and pushes the key-loading machinery into async consumer code for no custody gain.
- The latency argument is empty. The hop is one loopback/private gRPC round-trip in front of a Solana RPC round-trip that is orders of magnitude larger and dominates every call.
- The failure modes integration would “fix” are already correct.
Gateway trouble at RCPT tempfails
451 4.4.3(the sending MTA queues; operator trouble never bounces mail); sealing fails closed rather than falling back to plaintext; chain jobs retry from durable queues; every consumer dials lazily, so a gateway restart never crash-loops anything. - The proto seam is one of the workspace’s best assets — the in-process fakes keep four consumers’ test suites hermetic. Every non-A option damages or bifurcates it.
What would reopen this decision
The gateway ever needs to be reachable across a hostile network segment: unauthenticated remote signing becomes untenable — add authentication.FIRED 2026-08-26. Authentication was added (mutual TLS,[auth]above) on a zero-trust reading of the network, andallow_remote_bindwas retired with it. This trigger is spent; do not requeue it, and do not restore the unauthenticated posture.- A deliberate product decision to ship a single all-in-one binary for operator simplicity: revisit C2.
- Per-user signing keys replacing the process-wide operator key: the write gateway’s signing model dies regardless; redesign then.
Hardening queued with this decision
Three in-place improvements were queued rather than bundled here:
(1) move mail-grpc onto the same TOML/env config layering as every
other service, with dev-friendly in-code defaults (loopback bind, port
50051) so the private-only posture is the default rather than a
convention — landed 2026-07-15 as a clean break (the legacy
env-only configuration is gone; see
the mail-grpc chapter); (2) make
domain-sithbit’s delegate key loadable from a key source (file or
Azure Key Vault) like the workspace’s other secrets — landed
2026-07-15 (see
key sources);
(3) give mail-grpc a presence in the infrastructure-as-code
templates, in a private subnet — landed 2026-07-15 as an opt-in,
BYO-network unit in both templates (ECS Fargate on AWS, a
VNet-integrated ACI container group on Azure; see
Provisioning with IaC). The related seam —
chain-enabled sithbitd requiring an [ipfs] section even when an MX
only wants RCPT verification — also landed 2026-07-15 with the
config work: [grpc] alone is now the verification-only MX posture
(see the chain-pipeline
section).