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

Per-recipient pin providers

By default, every inbound mail body a server delivers is pinned to IPFS by the operator’s provider — whatever the [ipfs] section of the server config selects (see [grpc] and [ipfs] — the chain pipeline). That single pin is what the on-chain message references, and its availability rests on that one operator continuing to pin.

A mailbox owner who wants an additional copy under their own control can register their own pinning provider with the server. Once configured, the delivery pipeline pins that recipient’s inbound sealed bodies to their provider in addition to the operator’s default pin — automatically, at delivery time, with no per-message action. It is the standing, server-side counterpart to sithbit mail pin, which re-pins already-delivered mail by hand.

Who it’s for: recipients who want their mail’s availability to outlive the operator’s pin — without running their own mail server. Bring a Pinata account, a Filebase bucket, or your own sithbit-ipfsd daemon.

How it fits into delivery

The recipient pin is deliberately best-effort and additive:

  • The operator pin runs first and stays authoritative — it yields the CID the on-chain message account records. The recipient pin is a second copy of the same sealed bytes; because IPFS is content-addressed, both providers serve the same CID.
  • A recipient-provider failure — provider outage, revoked credentials, a full bucket — is logged and dropped. It never delays, fails, or alters delivery or chain state. Your copy of the mail arrives either way; only the extra pin is missed.
  • The no_ipfs opt-out wins over everything: an opted-out mailbox gets no pin at all — not the operator’s, not yours. The opt-out means “my mail never touches public IPFS”, and a recipient-configured provider doesn’t override that.

Configuring a provider

The surface is three routes on the account API, under the same JWT wallet auth as the rest of /v1/account — so a wallet holder manages their own provider, and only theirs. The examples below assume $JWT holds a token from wallet-challenge login.

The two writes need a fresh wallet signature as well as the token. PUT and DELETE /v1/account/pin-provider are two of the account API’s five step-up gated mutations — they hand a stored credential to the server, or take it away, so a token minted hours ago is not enough on its own. Without a proof they answer 428 {"error":"step_up_required"} and change nothing. The read is not gated.

This one is API-level: there is no GUI control for it. The other credential changes on this surface have buttons on the webmail settings pane, which fetches the challenge and has the wallet sign it for you; a pin provider is registered from your own tooling — a shell, a script, a provisioning job — so running the three-step dance is yours to do:

# 1. A step-up challenge for this token's own wallet (no body, no
#    wallet parameter — it always answers for the token's own).
#    Keep the "nonce" field, verbatim, as $NONCE.
curl -X POST http://127.0.0.1:8180/v1/auth/step-up \
  -H "Authorization: Bearer $JWT"
# → {"nonce":"SithBit step-up nonce: 6f0f…","expires_in":300}

# 2. Sign that exact nonce string's UTF-8 bytes with the wallet's ed25519
#    key: `sithbit mailbox sign-text` is that signer. Only the base58
#    signature reaches stdout — the "Signed as <address>:" line goes to
#    stderr — so the capture below is exactly the header value.
PROOF=$(sithbit mailbox sign-text "$NONCE" --keypair ./wallet.json)
#    Drop --keypair to sign with your Solana CLI config's wallet.

# 3. Send the write with the proof in the x-sithbit-step-up header
#    (see the examples that follow).

Pass the nonce verbatim — nothing is trimmed, prefixed or hashed on either side, so one extra trailing space is a different message and the proof will not verify. Step 2 signs that string and nothing else: it contacts no RPC endpoint and no server, so it runs offline, including on an air-gapped machine holding the wallet. The full reference is sithbit mailbox sign-text. A signature collected at login is not interchangeable with this one: the route checks the SithBit step-up nonce: prefix before it verifies anything. And none of this needs the CLI in a browser — the web clients’ wasm wallet signs the identical bytes in the page, which is how the settings pane’s own buttons answer their challenges.

The challenge lives five minutes and is spent by one write, whatever that write answers — so a PUT followed by a DELETE is two challenges, not one, and a retry after a failure needs a new one: fetch a fresh nonce and re-run step 2 against it.

Set (or replace) a providerPUT /v1/account/pin-provider with a kind-tagged JSON body, one of three kinds:

# Pinata: an API JWT plus your dedicated gateway
curl -X PUT http://127.0.0.1:8180/v1/account/pin-provider \
  -H "Authorization: Bearer $JWT" -H "x-sithbit-step-up: $PROOF" \
  -H "Content-Type: application/json" \
  -d '{"kind":"pinata","jwt":"<pinata-api-jwt>","gateway":"https://example.mypinata.cloud"}'

# Filebase: S3 credentials plus the pinning bucket
# (endpoint is optional, default "https://s3.filebase.com")
curl -X PUT http://127.0.0.1:8180/v1/account/pin-provider \
  -H "Authorization: Bearer $JWT" -H "x-sithbit-step-up: $PROOF" \
  -H "Content-Type: application/json" \
  -d '{"kind":"filebase","access_key":"<key>","secret_key":"<secret>","bucket":"my-mail"}'

# Remote: a sithbit-ipfsd pin daemon you operate
# (endpoint defaults to "http://127.0.0.1:8182"; auth_token is optional,
#  matching the daemon's auth_token setting)
curl -X PUT http://127.0.0.1:8180/v1/account/pin-provider \
  -H "Authorization: Bearer $JWT" -H "x-sithbit-step-up: $PROOF" \
  -H "Content-Type: application/json" \
  -d '{"kind":"remote","endpoint":"https://ipfsd.example.net:8182","auth_token":"<token>"}'

Success is 204 No Content. A missing, stale or already-spent step-up proof is a 428 (fetch another challenge and sign it again — the token itself is fine); a proof that is not a base58 ed25519 signature is a 400, and that one leaves the challenge intact, so fixing the encoding and resending works. A missing or empty required credential field is a 400 naming the field (e.g. filebase.secret_key must not be empty); an unknown kind is a 422; a wallet with no account on this server is a 404. For the remote kind, point the endpoint at a sithbit-ipfsd daemon — and since its pin API is a write surface, expose it beyond loopback only with its auth_token set; the daemon refuses a non-loopback bind without one at startup.

Check what’s configuredGET /v1/account/pin-provider:

curl -H "Authorization: Bearer $JWT" http://127.0.0.1:8180/v1/account/pin-provider
# → {"configured":true,"kind":"filebase"}   (or {"configured":false,"kind":null})

The read surface is deliberately write-only for secrets: it reports presence and the kind tag, never the credentials — not even redacted ones. To rotate credentials, simply PUT the replacement (with a proof of its own — see above). This route needs no step-up: it reveals nothing a token holder cannot already learn, and gating a read would cost a wallet signature per poll for no gain.

Remove itDELETE /v1/account/pin-provider, gated like the PUT:

curl -X DELETE http://127.0.0.1:8180/v1/account/pin-provider \
  -H "Authorization: Bearer $JWT" -H "x-sithbit-step-up: $PROOF"
# → 204; future deliveries pin to the server default only

Deleting is idempotent — clearing an already-unconfigured provider still answers 204. Idempotent on the route, though, not on the proof: the second call needs a second challenge, because the first spent the one it carried.

What the server stores, and who can read it

The credentials are sealed (AES-256-GCM) under the operator’s server-wide credential key and kept in the accounts store — the same mechanism as stored mail passwords (see credential_key_file). Only the kind tag is stored in the clear, so the GET route (and the operator) can answer which provider without opening the secret.

Be clear-eyed about the trust statement this implies: the operator’s server can read these credentials — it has to, to pin to your provider at delivery time. That is the design, and it is the opposite of Lockbox-style end-to-end secrecy: sealing here protects the credentials at rest (a stolen database dump without the credential key reveals nothing), not from the operator. Use a scoped, revocable credential — a Pinata JWT restricted to pinning, a Filebase key for a dedicated bucket, an ipfsd auth_token you can rotate — never a root account key.

Nothing about this setting is on-chain. Contrast the no_ipfs opt-out, which is an on-chain mailbox flag because foreign senders must see it before they pin; your pin provider only matters to the one server that delivers your mail, so it stays operator-local.

Tradeoffs

  • Your copy is only as good as your provider. The recipient pin depends on your provider’s availability and your credentials staying valid — an expired Pinata JWT or a deleted bucket silently costs you the extra copies (watch the server’s logs, or spot-check with GET /ipfs/<cid> against your own gateway). The operator pin remains the authoritative one either way.
  • No backfill. Configuring a provider affects mail delivered from then on. To capture your existing history, run sithbit mail pin once against the same provider — it takes the identical Pinata/Filebase/remote credential shapes.
  • The operator holds your provider credentials (sealed at rest, but readable by the running server — see above). Scope and rotate them accordingly.
  • You manage your provider’s lifecycle. The server only ever adds pins to your provider: settlement and deletion release the operator’s pin, never yours. A deleted message’s extra copy stays pinned on your provider until you remove it there yourself.
  • Opt-out excludes you. A mailbox with the no_ipfs opt-out never gets any pin, including this one — the two settings answer opposite wishes, and the opt-out wins.