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_ipfsopt-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 provider — PUT /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 configured — GET /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 it — DELETE /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 pinonce 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_ipfsopt-out never gets any pin, including this one — the two settings answer opposite wishes, and the opt-out wins.