mail-grpc: the chain gateway
Standard port(s): gRPC 50051, health 8193 (both in-code defaults,
loopback-bound).
The gRPC gateway other servers use to reach Solana — it implements the
SolanaMail service’s full RPC surface. MX/ mail servers call this
instead of talking to Solana directly. See Route index below
for the complete method list.
Route index
Every RPC the SolanaMail service answers (mail_api/protos/sithbit.proto),
grouped by area. Authentication is the transport’s job, not any RPC’s — see
Authenticating services below.
Alias directory
| RPC | Request → Response | Purpose |
|---|---|---|
ResolveAlias | AliasRequest → AliasResponse | resolve an email local-part alias to a wallet address |
ListAliases | ListAliasesRequest → ListAliasesResponse | list every alias local-part pointing at a wallet, off the alias indexer |
Mailbox and postage
| RPC | Request → Response | Purpose |
|---|---|---|
GetMailbox | MailboxRequest → MailboxResponse | read a mailbox’s mail count, default postage, and no_ipfs flag |
GetMailboxKey | MailboxKeyRequest → MailboxKeyResponse | fetch a wallet’s published encryption key (X25519/RSA/none) plus no_ipfs |
GetFrombox | FromboxRequest → FromboxResponse | look up a (from, to) frombox: required postage, stamp count, stamp fee terms |
Sending and receiving mail
| RPC | Request → Response | Purpose |
|---|---|---|
SendMail | SendMailRequest → SendMailResponse | submit a mail send on-chain, with an optional reply-bounty escrow |
DeleteMail | DeleteMailRequest → DeleteMailResponse | delete a received message on-chain |
RefundMail | RefundMailRequest → RefundMailResponse | refund a received message back to its sender |
FindMessage | FindMessageRequest → FindMessageResponse | locate a landed message by IPFS CID — a dedupe check after a lost SendMail response |
Reply bounties
| RPC | Request → Response | Purpose |
|---|---|---|
ClaimBounty | ClaimBountyRequest → ClaimBountyResponse | claim an escrowed reply bounty using a reply message as evidence |
RefundBounty | RefundBountyRequest → RefundBountyResponse | reclaim an expired, unclaimed reply bounty |
Transactions
| RPC | Request → Response | Purpose |
|---|---|---|
GetTransactionStatus | TransactionStatusRequest → TransactionStatusResponse | poll the commitment status of a previously submitted transaction |
Domains
| RPC | Request → Response | Purpose |
|---|---|---|
GetMailDomain | MailDomainRequest → MailDomainResponse | check a MailDomain account: exists, active, authority, gateway_is_authority |
ListAuthoritativeDomains | ListAuthoritativeDomainsRequest → ListAuthoritativeDomainsResponse | list active domains whose on-chain authority is this gateway’s own signing key |
GetSenderAttestation | SenderAttestationRequest → SenderAttestationResponse | check whether a domain has attested a sender wallet as legitimate |
Marketplace
| RPC | Request → Response | Purpose |
|---|---|---|
BrowseListings | BrowseListingsRequest → BrowseListingsResponse | browse open marketplace listings (aliases and domains for sale) |
ListSales | ListSalesRequest → ListSalesResponse | read the marketplace sales ledger (by wallet, name+kind, or newest overall) |
ListParticipants | ListParticipantsRequest → ListParticipantsResponse | search on-chain participant opt-in beacons by tag bitmap filter |
Reputation and pinning
| RPC | Request → Response | Purpose |
|---|---|---|
GetSenderReputation | SenderReputationRequest → SenderReputationResponse | look up a wallet’s cumulative stamp-spend reputation and resulting price-rate bps |
GetPinLease | PinLeaseRequest → PinLeaseResponse | look up IPFS pinning-lease deposits/count on a CID |
When you need it: always, in any deployment where mail should actually
reach the chain — without it, sithbitd runs with its chain pipeline
disabled.
Network posture: anyone the gateway serves can sign with its keypair, so on any address but loopback the gRPC surface requires mutual TLS — a non-loopback
bind_addrwithout a complete[auth]section refuses to start, and every caller is authenticated by the Ed25519 key in its client certificate. Loopback with no[auth]serves unauthenticated. Keep the gateway on loopback or a private interface, and follow Authenticating services before it is reachable from anywhere else. Why the gateway is a separate private service at all is recorded in the topology design note.
Quickstart:
cargo run -p mail-grpc
Run it as an OS service with mail-grpc service install — the shared
subcommand is documented in
Running as an OS service.
Config file mail_grpc.toml, or point MAIL_GRPC_CONFIG at an alternate
path — the same layered TOML/env mechanism as every other SithBit binary
(in-code default → TOML → ./.env → ./.env.$APP_ENV → environment;
MAIL_GRPC_* variables override individual settings, e.g.
MAIL_GRPC_BIND_ADDR or MAIL_GRPC_ALIAS_INDEX__DATABASE). An empty
or missing config file is a runnable dev gateway: it binds
127.0.0.1:50051 — the private-network posture above is the default the
code enforces now, not a convention — and resolves the chain endpoint and
the signing keypair from the operator’s Solana CLI config
(~/.config/solana/cli/config.yml; a missing file means the stock CLI
defaults, i.e. mainnet-beta), exactly like the sithbit CLI, so
solana config set governs a zero-config gateway. As with the CLI, a
bare JSON_RPC_URL environment variable overrides the endpoint (it wins
over a configured json_rpc_url), which is handy for pointing a gateway
at a local validator without editing config. The annotated
mail_grpc/mail_grpc.example.toml documents every key with its default;
the Configuration reference has the
key/default table.
Migration (clean break):
mail-grpcno longer reads its legacy environment-only configuration.GRPC_SERVER_ADDRESS,DEFAULT_KEYPAIR(andDEFAULT_KEYPAIR_VAULT_URI/DEFAULT_KEYPAIR_SECRET_NAME),ALIAS_INDEX_DB,ALIAS_INDEX_POLL_SECONDS,ALIAS_CACHE_SECONDS, andHEALTH_BINDare all ignored. The one legacy name still honored isJSON_RPC_URL(bare, un-prefixed), for parity with thesithbitCLI and the standard Solana convention — it overrides the configuredjson_rpc_url. In particular, there is no keypair-content-in-an-environment-variable shape anymore:DEFAULT_KEYPAIRused to hold the raw JSON keypair array itself, while the TOMLkeypairnames a key source — a keypair file path, or a cloud secret-manager secret. The nearest env-var equivalent isMAIL_GRPC_KEYPAIR=/path/to/id.json.
Signing keypair: a file or a cloud secret manager
keypair is a
key source like
the workspace’s other file-loaded secrets. A bare string is a Solana
keypair file; the table form fetches the keypair from a cloud secret
manager instead — the secret’s value holds exactly what the file would,
the raw JSON keypair array. Authentication is each cloud’s ambient
chain (Azure managed identity, the AWS credential chain, Google ADC):
no new auth to configure, and no key material in the config file — only
the secret’s coordinates.
keypair = "signer.json"
keypair = { kind = "akv", vault_uri = "https://<vault>.vault.azure.net/",
secret_name = "signer" }
keypair = { kind = "asm", secret_id = "sithbit/signer" }
keypair = { kind = "gsm", project = "my-project", secret = "signer" }
Unset (the default), the keypair comes from the Solana CLI config’s
keypair_path — ~/.config/solana/id.json unless solana config set
moved it.
mail-grpc also runs the alias indexer that backs Aliases’s
ListAliases lookups ([alias_index]; an explicitly empty database
disables it). See the
Configuration reference for the full
key/default table.
Authenticating services: a step-by-step guide
Summary. Every process that calls mail-grpc from anywhere but the
gateway host’s own loopback must present an Ed25519 client certificate
whose public key the gateway lists in [auth] authorized_keys, and must
pin the gateway’s own Ed25519 key. There is no certificate authority on
either side: the key is the identity. Setting it up is five steps —
mint a keypair and certificate for each calling process, mint one for the
gateway, write the gateway’s [auth] section, write each caller’s TLS
table, and read the two log lines that tell you it worked.
Introduction: why the transport authenticates
The gateway signs and pays for on-chain writes with its own fee-payer wallet on behalf of whoever calls it, so “who may call it” decides who may spend that wallet (the topology note records why the gateway is a separate service and how this posture was reached). Three rules in the gateway’s config validation make the transport, not the network, answer that question:
- A reachable bind requires a complete
[auth]section. Naming anybind_addrother than loopback withoutcert,keyandauthorized_keysrefuses startup with a message that says the fee-payer wallet would otherwise be handed to the network (mail_grpc/src/config.rs,GrpcConfig::validate). There is noallow_insecure_bindopt-out here, unlike the mail listeners. - A half-written
[auth]is refused, not ignored. Any one of the three settings present without the other two fails startup naming the missing ones (mail_grpc/src/config.rs,AuthConfig::validate), so a typo can never quietly serve unauthenticated. - A present
[auth]is enforced on every bind, loopback included (mail_grpc/src/config.rs, theGrpcConfig::validatedoc). The loopback exemption keys off the absence of the section, which is what keeps a zero-config dev gateway runnable and lets the test suites exercise the authenticated path on an ephemeral loopback port.
At accept time the gateway’s listener handshakes every socket with an
acceptor that requires a client certificate, reads the Ed25519 key out
of that certificate’s SubjectPublicKeyInfo, checks it against the decoded
allow-list, and hands only an authorized stream to the gRPC server — an
unknown caller is dropped before HTTP/2 starts, let alone a handler
(mail_grpc/src/listener.rs, Authorizer::admit). The authorized key
rides every request as a CallerInfo extension so a handler can attribute
the call to the process that made it (mail_grpc/src/listener.rs,
CallerInfo). On the caller’s side the same primitive is applied in
reverse: the client accepts exactly one server certificate, the one whose
Ed25519 key equals gateway_key, ignoring chain, validity dates and
server name (server_common/src/grpc_client.rs, PinnedServerVerifier).
That is why every certificate in this scheme is a self-signed Ed25519
leaf, and why the 32-byte key is written in base58 — it is the same
transport identity the MX servers bind for SASL EXTERNAL and the same
primitive as a wallet address (mail_grpc/src/config.rs, the
AuthConfig doc). It is also why the recommended way to mint one is the
sithbit CLI’s wallet-certificate command rather than a CA workflow.
Step 1: mint a keypair and certificate for each calling process
Give every calling process — sithbitd, a standalone smtp-server,
account-api — its own keypair, so that revocation and attribution are
per process (the four protocols inside one sithbitd share one channel
and therefore one key). Use a dedicated keypair for this: it is a
transport identity, never funded and never the gateway’s fee payer.
solana-keygen new --no-bip39-passphrase -o sithbitd-client.json
sithbit mailbox create-cert --keypair sithbitd-client.json --out sithbitd-client
solana-keygen pubkey sithbitd-client.json
create-cert builds a self-signed Ed25519 certificate whose public key is
the keypair’s public key and writes sithbitd-client.crt (the
certificate, PEM), sithbitd-client.key (its PKCS#8 private key, PEM,
written owner-only) and sithbitd-client.p12 (a combined bundle mail
apps import — not needed here; delete it or keep it owner-only, it embeds
the private key) (mail_client/src/commands/mailbox/client_cert.rs,
ClientCert::invoke). The third command prints the base58 public key:
that string is the process’s entry in the gateway’s authorized_keys.
Because the certificate’s key is the keypair’s key, you never need to
parse a certificate to learn the value the gateway wants.
Any other tool that produces a self-signed Ed25519 certificate works too —
openssl req -x509 -newkey ed25519 -keyout key.pem -out cert.pem -nodes -subj "/CN=sithbitd" is the one-liner — but then the base58 key has to
be discovered another way; the easiest is the gateway’s own refusal log,
which prints the key a rejected caller presented (Step 5).
Step 2: mint the gateway’s own certificate
Repeat Step 1 once for the gateway, with a keypair of its own:
solana-keygen new --no-bip39-passphrase -o mail-grpc-tls.json
sithbit mailbox create-cert --keypair mail-grpc-tls.json --out mail-grpc-tls
solana-keygen pubkey mail-grpc-tls.json
The printed key is what every caller pins as its gateway_key. The
gateway’s certificate must be Ed25519 for the same reason the callers’
must: a caller pins the key, not a CA, and its verifier refuses a
non-Ed25519 certificate with the same error as a wrong key
(server_common/src/grpc_client.rs, PinnedServerVerifier). Keep this
keypair separate from the gateway’s signing keypair (the fee payer); the
two have nothing in common but the algorithm.
Step 3: configure the gateway
bind_addr = "10.0.1.5:50051"
[auth]
cert = "mail-grpc-tls.crt"
key = "mail-grpc-tls.key"
authorized_keys = [
"<sithbitd-client public key>",
"<account-api-client public key>",
]
cert and key are key sources:
a bare string reads a PEM file, and the table form fetches the PEM from a
cloud secret manager (kind = "akv", "asm" or "gsm", the same shapes
keypair takes) so no key material lives in the config file. Every row is
in the configuration reference.
Start the gateway; a wrong or incomplete section is refused at startup
with the messages described in the introduction, and a good one logs the
listen address as before.
Step 4: configure each caller
Every caller takes the same three settings — cert, key,
gateway_key — all or none, and its endpoint becomes https://. The
client refuses to start on an https:// endpoint with no TLS table and on
an http:// endpoint with one, so the config cannot describe a connection
that can never work (server_common/src/grpc_client.rs, check_scheme);
and gateway_key must decode to exactly 32 bytes of base58 or the client
refuses it naming the value (server_common/src/grpc_client.rs,
GrpcClientTls::gateway_key).
sithbitd, in sithbitd.toml
(reference):
[grpc]
endpoint = "https://10.0.1.5:50051"
[grpc.tls]
cert = "sithbitd-client.crt"
key = "sithbitd-client.key"
gateway_key = "<mail-grpc-tls public key>"
A standalone smtp-server, in smtp_server.toml
(reference):
grpc_endpoint = "https://10.0.1.5:50051"
[grpc_tls]
cert = "smtp-client.crt"
key = "smtp-client.key"
gateway_key = "<mail-grpc-tls public key>"
account-api, in account_api.toml
(reference):
[chain]
grpc_endpoint = "https://10.0.1.5:50051"
[chain.grpc_tls]
cert = "account-api-client.crt"
key = "account-api-client.key"
gateway_key = "<mail-grpc-tls public key>"
cert and key are key sources here too. sithbit-console has no such
table and reaches only an unauthenticated loopback gateway. The caller’s
half is an operator obligation: a caller cannot see the gateway’s
configuration, so nothing on its side warns when the table is missing —
the symptom is the handshake failing at the gateway (Step 5).
Step 5: verify from the logs
Start the callers and watch the gateway’s log. Each connection ends in
exactly one of four lines (mail_grpc/src/listener.rs, Authorizer::admit):
| Gateway log line | Meaning |
|---|---|
gateway caller authorized (debug, with key=) | the caller’s key is in authorized_keys; the stream was handed to gRPC |
gateway caller not in authorized_keys (warn, with key=) | the handshake succeeded but the presented Ed25519 key is not listed — the printed base58 value is the string to add, which is how you recover a key you minted with a tool other than sithbit |
gateway caller presented a non-Ed25519 certificate (warn) | the caller’s certificate is RSA/ECDSA; mint an Ed25519 one |
gateway mTLS handshake failed (warn, with the error) | no client certificate at all, or a TLS-level failure — the usual cause is a caller still dialing http:// or missing its TLS table |
Two refusals happen on the caller instead: a gateway_key that does not
match the certificate the gateway presented fails the handshake on the
client side (the pinned verifier), and the scheme/table mismatches from
Step 4 refuse the caller’s own startup. The gateway’s health endpoint is
unaffected by [auth] and keeps answering on its own port.
Rotation, revocation and what this does not change
- Revoking one process is removing its key from
authorized_keysand restarting the gateway; the other callers are untouched, which is the point of one keypair per process. - Rotating the gateway’s certificate changes the key every caller pins,
so every caller’s
gateway_keymust change with it — plan the two edits together, since a caller holding the old pin fails its next handshake. - The guardrails stay in force for authorized callers:
max_bounty_lamportsandfee_payer_floor_lamportsstill bound what a compromised-but-authorized process could cost (the topology note). - Reachability still matters. Keep the gateway on a private segment
as the
iac/templates do;[auth]decides who is served, the network narrows who can try.