account-api: the account service
Standard port(s): bind 8180, health 8191. Run it as an OS service
with account-api service install — the shared subcommand is documented
in Running as an OS service.
Wallet-challenge login (issuing a JWT), plus mail passwords, timezone, and do-not-disturb schedule management.
Route index
Every route the service answers, method + path + auth + one-line purpose,
kept in step with the router account_api::lib builds. This section is the
map; where a route’s behavior has more to it than that, the table links to
the section that covers it.
Auth & session
| Route | Auth | Purpose |
|---|---|---|
POST /v1/auth/nonce | none | issue a login challenge (mints no account row; rate-limited per requested pubkey) |
POST /v1/auth/token | none | verify the signed challenge → JWT (optionally carries the reading secret) |
POST /v1/auth/logout | JWT | end the session: drop its reading secret and the summaries decrypted under it |
POST /v1/auth/step-up | JWT | issue a step-up challenge for the token’s own wallet |
GET /v1/whoami | JWT | the caller’s wallet |
Account settings
| Route | Auth | Purpose |
|---|---|---|
GET/PATCH /v1/account | JWT | account settings (timezone, DND exposure opt-in, current auth epoch) |
PUT /v1/account/password | JWT + step-up | set the POP/IMAP/SMTP mail secret |
DELETE /v1/account/password | JWT + step-up | delete the stored mail secret |
POST /v1/account/auth-epoch | JWT + step-up | rotate the wallet-signature mail credentials |
GET /v1/account/pin-provider | JWT | which custom IPFS pin provider is configured (kind only, never the credentials) |
PUT/DELETE /v1/account/pin-provider | JWT + step-up | set or clear the custom IPFS pin provider — see Per-recipient pin providers |
GET/PUT /v1/account/dnd | JWT | own away-schedule exclusions (replace-set) |
GET /v1/dnd/{wallet} | none | public do-not-disturb check |
“JWT + step-up” is the five routes gated behind a freshly signed wallet challenge, and sharing one per-wallet rate-limit budget.
The on-chain proxy (/v1/chain)
Served only when a [chain] section is configured (503 otherwise); every
route answers for the token’s own wallet unless noted. See
The on-chain proxy.
| Route | Auth | Purpose |
|---|---|---|
GET /v1/chain/balance | JWT | native SOL balance (lamports) |
GET /v1/chain/mailbox | JWT | on-chain mailbox (mail count) |
GET /v1/chain/key | JWT | published delegated encryption key |
GET /v1/chain/cert?email= | none | resolve an email/alias/wallet → recipient’s published key (HKP/WKD-style keyserver) |
GET /v1/chain/aliases | JWT | aliases held by the wallet |
GET /v1/chain/listings | JWT | every open marketplace listing (aliases and domains for sale) — global, so the wallet is authenticated but unused |
GET /v1/chain/sales?wallet=&kind=&name= | JWT | marketplace sale history, all filters optional — global, so the wallet is authenticated but unused |
GET /v1/chain/participants?tags=&limit= | JWT | global participant pool, narrowed to beacons carrying every listed tag bit |
GET /v1/chain/frombox?from= | JWT | stamp balance one sender holds with us |
GET /v1/chain/account/{address} | JWT | one raw account (owner + base64 data), the escape hatch API-mode web shells decode client-side |
GET /v1/chain/blockhash | JWT | recent blockhash for client-built transactions |
POST /v1/chain/submit | JWT | relay a client-signed transaction |
GET /v1/chain/tx/{signature} | JWT | commitment status of a submitted tx |
Mail (/v1/mail)
The user-facing mail surface over the same store the IMAP/POP servers serve
— every route answers for the token’s own wallet, and mailbox names travel
as query/body values because they carry /. See
The mail routes and
Sealed bodies and keyed sessions.
| Route | Auth | Purpose |
|---|---|---|
GET /v1/mail/mailboxes | JWT | folder tree with per-folder counts |
POST /v1/mail/mailboxes | JWT | create a folder (and missing parents) |
POST /v1/mail/mailboxes/rename | JWT | rename a folder subtree |
DELETE /v1/mail/mailboxes?mailbox= | JWT | delete a folder + chain teardown |
GET /v1/mail/messages?mailbox=&limit=&before_uid= | JWT | newest-first summary page (keyset cursor) |
GET /v1/mail/messages/{uid}?mailbox= | JWT | full JSON rendering (headers, text, html, attachments) |
GET /v1/mail/messages/{uid}/raw?mailbox= | JWT | the stored .eml bytes (message/rfc822) |
GET /v1/mail/messages/{uid}/parts/{part}?mailbox= | JWT | one decoded part as a download |
PATCH /v1/mail/messages/flags | JWT | merge flag adds/removes onto stored flags |
POST /v1/mail/messages/move | JWT | copy + expunge + chain teardown (IMAP MOVE semantics) |
POST /v1/mail/messages/delete | JWT | expunge + chain teardown |
GET /v1/mail/search?mailbox=&q=&body=&limit=&before_uid= | JWT | capped scan with a resume cursor |
POST /v1/mail/send | JWT | compose and spool a message, subject to suspension and the outbound quota |
Admin (/v1/admin)
JWT whose wallet is on the configured admin_wallets allowlist; the default
empty list disables them all with 403. See Monitoring for
the operator side of the queue, quota and suspend panels.
| Route | Purpose |
|---|---|
GET /v1/admin/accounts?after=&limit= | paged wallet enumeration |
GET /v1/admin/accounts/{wallet}/mailboxes | mailbox tree |
GET /v1/admin/accounts/{wallet}/messages?mailbox= | copies with chain states |
GET /v1/admin/queues | job-queue depth/staleness |
GET /v1/admin/dead-jobs?limit= | buried jobs (stable id; cloud: claims entries ~5 min, token lives that long) |
POST /v1/admin/dead-jobs/requeue | re-drive on the source queue (echo the listed token) |
POST /v1/admin/dead-jobs/discard | delete permanently (echo the listed token) |
GET /v1/admin/accounts/{wallet}/quota | rolling outbound usage, suspend flag, and the allowances in force |
PUT /v1/admin/accounts/{wallet}/suspend | set/clear the outbound-suspend flag |
GET /v1/admin/dmarc-reports | ingested DMARC aggregate reports (id + last-modified) |
GET /v1/admin/dmarc-reports/{id} | one stored report’s JSON |
When the account row is created
The wallet-challenge login is two calls: POST /v1/auth/nonce hands out a
challenge for a wallet, and POST /v1/auth/token takes the signature over
it and answers with the JWT. The account row is created by the second
call, not the first — it is written the moment the signature verifies and
before the token is minted, so a store failure is a 500 with no session
handed out rather than a token for an account that was never written.
That the row waits for the signature is deliberate, and it is the half of
the login gate
that gate cannot reach: /v1/auth/nonce takes no credential at all, so
provisioning there let an unauthenticated caller mint an account row for any
address merely by asking for a challenge. Asking is not proof; the signature
is. Nothing else moved — an honest login still provisions on first use, so a
client that has never logged in before needs no separate enrolment step.
One residue is worth an operator’s attention: the challenge row itself is
still written unauthenticated. It is keyed on the public key and issued as an
upsert, so the count is bounded by the number of distinct valid ed25519 keys
somebody bothers to generate rather than by request volume, and each row is
around a hundred bytes. Two controls now blunt that residue, and neither is a
cap. The route sits behind a per-pubkey budget — the
[nonce_rate_limit] section,
on by default at 30 requests per key per 300-second window — which stops any
one key being hammered but honestly does not tighten the distinct-keys bound
above: every fresh pubkey an attacker generates arrives with a fresh budget.
And a sithbitd sharing the store deletes expired challenges hourly — an
unconditional sweep with no setting, noted with the daemon’s other prune workers
under [spooler] — so an
abandoned row outlives its ~300-second expiry by at most an hour there. A
deployment running the account API with no daemon over the same store
still retains expired challenges between logins: the API itself deletes a
challenge only when it is consumed.
Mail passwords and pin providers
A stored mail password is now optional (item 17). The POP/ IMAP/SMTP
servers accept a wallet signature as the credential — username = the
wallet address, password = a signature the wallet produces (see
deriving the mail password)
— so an account needs no stored secret to collect or send mail. (The
servers accept a wallet-minted TLS client certificate as a second
passwordless path — SASL EXTERNAL,
gated by the listener’s client_cert_auth.) The
account surface reflects this:
PUT /v1/account/passwordtakes an optionalpasswordfield. A non-empty value is graded by the usual strength policy, sealed, and stored (the classic shared-secret path). An absent or empty value is a no-op that declares the wallet-signature-auth state (the account authenticates by wallet signature, no stored secret required). It does not clear a previously stored secret: an account that already set a stored password keeps it. Deleting one is a route of its own —DELETE /v1/account/password— precisely so no client can wipe a credential by omitting a field.GET /v1/accountreturns a booleanmail_password_settelling a client whether a stored password exists — so a UI can offer “copy wallet mail password” versus “set a stored password” without ever reading the secret back.GET /v1/accountalso returnsauth_epoch, au64: the rotation counter the wallet-signature challenge mixes in. A client must read it before deriving a mail password, because a signature over the wrong epoch is refused like a forged one — at0, an account that has never rotated, exactly as firmly as anywhere else. The older epoch-less signature0once also accepted is retired at every epoch. See Rotating the wallet mail password.
The same surface carries the /v1/account/pin-provider routes, where a
recipient registers their own IPFS pinning provider for inbound mail —
see Per-recipient pin providers.
Every credential-changing route here — both password writes, both pin-provider writes, and the epoch bump — is step-up gated: a valid token is no longer the whole gate, and the wallet has to sign a fresh challenge for each change.
Rotating the wallet mail password (POST /v1/account/auth-epoch)
The wallet-signature credential has no expiry: the signature is
deterministic, so a copy of it works forever. POST /v1/account/auth-epoch
is the revocation handle — it adds one to the account’s auth_epoch, and
because that counter is part of the bytes a valid password signs over,
every outstanding wallet mail password for the account stops verifying the
moment the call returns.
- JWT-authenticated and step-up gated, no request body, no path parameter. It always acts on the token’s own wallet. That is deliberate: a route that took a wallet from the caller would be a way to lock other people out of their own mail.
200 {"auth_epoch": <new value>}— the value the client must sign over from now on.428when the request brings no freshly signed challenge,404when the token’s wallet has no account row; a bump never provisions one.- Not idempotent, on purpose. The epoch is a counter, so a retried or double-clicked POST rotates twice. That is the safe direction to fail — every credential minted before either call is dead either way — and the client learns the authoritative value from the response rather than assuming it.
- Guarded exactly as the surface’s other sensitive account mutations
(
PUT/DELETE /v1/account/password, the pin-provider writes): the token gets a caller no further than a428, because the step-up gate demands a freshly signed wallet challenge alongside it, and the attempt is charged to the same per-wallet budget. The blast radius is the caller’s own credentials, and the work is one store increment.
What a bump does not touch — three things, each of which a reader will reasonably assume it covers:
- A stored mail password. Rotation is scoped to the wallet-derived
credential. An account that also set a secret through
PUT /v1/account/passwordkeeps it, and it keeps logging in — clearing that one is the separateDELETE /v1/account/password. “Revoke all mail credentials” would be a false description of this endpoint. - The caller’s session. The JWT is stateless and unrevoked, exactly as
after
POST /v1/auth/logout: the same token keeps authenticating every route to itsexp. Only mail clients need re-provisioning. - A client-certificate login.
SASL EXTERNAL
proves identity from the key in the presented certificate, checked against
the account’s on-chain key — no signature, and so no epoch, is involved. An
epoch bump therefore does not revoke a client-certificate login; only
turning
client_cert_authoff for the listener does.
The user-facing side of the same lever — the armed, two-step control and the
warning it shows — is on the
webmail settings page;
the offline derivation for the new epoch is
sithbit mailbox credentials --epoch <N>.
Removing a stored mail password (DELETE /v1/account/password)
The stored-secret counterpart to an epoch bump. It deletes the sealed mail
secret outright, so the mail servers’ credential lookup finds nothing and every
login presenting the old password fails. Afterwards GET /v1/account reports
mail_password_set: false, and the account is back on the wallet-signature-auth
state a fresh one starts in — a new stored password can be set at any time.
- JWT-authenticated and step-up gated, no request body, no path or query parameter. It always acts on the token’s own wallet, for the same reason the epoch bump does: a route taking a wallet from the caller would be a way to strip other people’s credentials.
204 No Content, empty body.401re-login,428re-sign a challenge,404only when the token’s wallet has no account row,500on a store error.- Idempotent, unlike the epoch bump: deleting an account that has no stored
secret succeeds all the same, so a retried or double-clicked call is a second
204. There is no409/412and no “nothing to delete” error. - A route of its own, not a mode of
PUT /v1/account/password. An emptyPUTbody still only declares the wallet-auth state and leaves any stored secret in place, so no client can wipe a credential by omitting a field. PUT-ing an empty password is not a way to clear one. - The value is unrecoverable. The server keeps only a hash, so a removed password can never be read back or shown — a mail client configured with it has to be given a new password, stored or wallet-derived.
What a removal does not touch, the mirror of the bump’s three:
auth_epoch. This route does not rotate: the counter is unchanged, so every wallet-derived mail password for the account keeps verifying. The two levers are independent —POST /v1/account/auth-epochretires the wallet-signature credential,DELETE /v1/account/passwordthe stored one, and neither implies the other.- The caller’s session. The JWT
is stateless and unrevoked; the same token keeps authenticating every route to
its
exp. - A client-certificate login.
SASL EXTERNAL
proves identity from the key in the presented certificate — no password is
involved, so deleting one does not close that door. Only turning
client_cert_authoff for the listener does.
The user-facing side is the armed, two-step control and its warning on the webmail settings page, which is where a reader should be sent: it appears only while a stored password exists, and names the consequences before it commits.
Step-up: proving present control of the wallet
A JWT proves who the caller
was when they logged in — up to a day earlier, by default
(jwt.ttl_hours). For a mutation that can
change or revoke an account’s mail credentials that is not enough on its
own, so five routes additionally demand a freshly signed wallet
challenge: proof that whoever holds the token holds the wallet key right
now. A borrowed or stolen token on its own can no longer rotate an epoch,
wipe a stored password, or repoint a pin provider.
The gated surface is exactly the rate-limited one — the same five method/path pairs the per-wallet budget below charges:
PUTandDELETE /v1/account/passwordPUTandDELETE /v1/account/pin-providerPOST /v1/account/auth-epoch
Nothing else is gated. The reads, the wallet-challenge login, the timezone
and do-not-disturb writes and compose all answer to a bearer token alone —
and so does GET /v1/account/pin-provider, which shares its path with two
gated routes and stays an ordinary JWT read.
Most people never meet any of this. In the browser clients the webmail settings pane runs the flow for you: the page fetches a challenge, your wallet signs it — invisibly with an in-app wallet, as an approval prompt on a Phantom or Ledger one — and the change goes through. What follows is the wire contract, for a client that has to implement it.
The flow
POST /v1/auth/step-up— bearer token only, no request body and no path parameter (a caller-supplied wallet would be a way to clobber somebody else’s outstanding challenge). It answers{"nonce": "SithBit step-up nonce: <uuid>", "expires_in": 300}.- Sign the
noncestring’s raw UTF-8 bytes with the wallet’s ed25519 key — the same signing contract login uses, so a browser wallet needs no new capability — and base58-encode the 64-byte signature. In the browser the wallet does this in the page; from a shell,sithbit mailbox sign-textis that signer. - Resend the mutation with that signature in the
x-sithbit-step-upheader:
POST /v1/account/auth-epoch
authorization: Bearer <jwt>
x-sithbit-step-up: <base58 signature>
The challenge lives 300 seconds. That is a constant in the code, not a setting: it is a protocol fact every client’s re-challenge flow is written against rather than a deployment preference, and five minutes is already the loosest value defensible for a freshness proof. There is no configuration setting for it.
The refusal, and the four statuses a client branches on
A gated route reached without a usable proof answers, in full:
HTTP/1.1 428 Precondition Required
content-type: application/json
{"error":"step_up_required"}
That body is byte-exact — it is the string a client keys its re-challenge
flow off, so it is pinned as text rather than as parsed JSON — and the
refusal carries no Retry-After, because waiting is not the remedy.
| Status | What it means | What the client does |
|---|---|---|
401 | the session itself is over | log in again — a whole new wallet-challenge login |
428 | the session is fine, the proof is missing, stale or spent | fetch a challenge, sign it, resend with the token already held |
400 | the header is not a base58 ed25519 signature | fix the encoding and retry; the challenge is untouched, so an honest retry works |
429 | the wallet’s mutation budget is spent | honour Retry-After and try later |
The 401/428 split is the whole contract in one line: 401 means log in
again, 428 means re-sign with the token you have. The 428 is deliberately
uniform across every step-up failure — no proof at all, no outstanding
challenge, an expired one, one issued for login rather than step-up, a
signature that does not verify — so it never becomes an oracle about the
challenge’s state.
One proof, one mutation
- The challenge is consumed whatever the outcome — a success, a wrong signature and an expired one all spend it. A caller that fails re-challenges; nothing is ever reused.
- A spent proof cannot be replayed onto another route. Signing once and
sending the same header to the password write and then the epoch bump
fails the second call: the slot is already empty, and the answer is a
428. So it is one round trip per mutation — a UI applying three gated changes at once needs three challenges, not one. - One nonce slot, two kinds. An account holds a single outstanding
challenge, so issuing a step-up challenge replaces an outstanding login
challenge and vice versa. Each carries a kind prefix —
SithBit login nonce:againstSithBit step-up nonce:— checked where it is consumed, so neither can ever be spent as the other, in either clobber order. The cost of a clobber is one extra re-challenge; what it buys is that a signature collected at login can never be presented as a freshness proof.
A 428 still spends the budget
The per-wallet budget is charged
by a layer sitting in front of the gate, so a proof-less attempt costs an
attempt on its way to the refusal. That is the intended direction —
hammering a gated route with no proof is exactly the traffic the budget
exists to blunt — but it makes one client behaviour a mistake: never
retry a 428 blindly. Fetch a challenge, sign it, send the mutation once
more, and treat whatever that answers as final; a second 428 (someone
else’s challenge landed in the wallet’s one slot in between) is a refusal to
surface, not a loop to enter. A client that follows the flow spends one
attempt per real mutation. One that re-sends the bare request burns the
wallet’s whole window and ends on a 429.
Both outcomes are metered, so which behaviour your clients actually
produce is readable off telemetry rather than off a fronting proxy’s access
log: sithbit.api.refusals counts the 428 and the 429 alike, labeled
with the status and the matched route template. The label carries no HTTP
method, so the whole gated surface is three templates — the five
method/path pairs collapse onto three route series per status — and the
counter also sees the rest of the API, so filter to those three. What each
spike means, and why the two series replace each other under sustained
abuse rather than rising together, is in
Monitoring.
Rate limits on the sensitive mutations
The five step-up gated routes can change or revoke an account’s mail credentials, so they also sit behind a per-wallet budget the rest of the API does not pay into — the same five method/path pairs, guarded twice over.
Nothing else is limited by this control — not the reads, not the token
exchange or the step-up challenge (deliberately unlimited: it demands a
valid token and can only clobber its holder’s own slot), not the timezone
or do-not-disturb routes, and not compose (which answers to a quota of its
own, below).
Hammering those buys an attacker nothing worth the risk of throttling a
legitimate client. Login-challenge issuance (POST /v1/auth/nonce) is
metered by a budget of its own — keyed on the requested pubkey, since
the route is unauthenticated and names no wallet — with the same settings
and defaults; see the
[nonce_rate_limit] section.
The budget is one count per wallet, shared by all five pairs, so a
caller cannot spread a burst across password / pin-provider / auth-epoch
and earn three allowances, and every attempt is charged, including the
refused ones — the hammering is the thing being blunted, not just its
successes. It defaults to on, at 30 attempts per 300-second window;
the settings and the rest of the semantics (fixed window, 0 = no limit,
the bounded table that fails open) are in the [rate_limit]
section of
the configuration reference.
Out of budget, the whole response is:
HTTP/1.1 429 Too Many Requests
retry-after: 47
content-type: application/json
{"error":"too many account changes; retry in 47 seconds"}
Three properties of that refusal are worth knowing before you build a client or read a log against it:
Retry-Afteris always delta-seconds, and never0. RFC 9110 §10.2.3 allows either a count of seconds or an HTTP-date; this header is always the count, because a relative budget cannot honestly be expressed as a date and a count needs no clock agreement with the client. The value is the whole seconds left on the wallet’s window, floored at1— a sub-second remainder truncates to zero, and aRetry-After: 0tells a client to retry immediately, which is precisely what the budget exists to prevent. A client that honours the header recovers on its own; one that retries blind just keeps the window full.- The body is the crate’s ordinary
{"error": …}prose, with no machine-readable field. The status code is the branch here, unlike the sealed-mail403 {"error": "sealed"}, which needs a body field to distinguish a refusal a bare 403 cannot. The one machine-readable datum in a 429 is the delay, and the header already standardizes a channel for it; putting it in the body too would be two sources of truth for one number. The header and the prose render from the same value and cannot disagree. - An unauthenticated request is never charged. The budget is keyed on
the authenticated wallet, and a request that names no wallet gets the
usual
401without touching a counter — a shared anonymous bucket would let one client spend everybody’s allowance. The consequence an operator must plan for: this limiter does not blunt unauthenticated hammering of these paths. Volume against a route by a caller holding no valid token is a job for the network layer in front of the API — a reverse proxy, a WAF, or the platform’s connection limits — not for[rate_limit].
One more deployment fact, because it changes the number you should
configure: the counters are in-process and per-replica. Nothing is
shared through the store, so two API replicas behind a load balancer
grant a wallet two budgets. Size max_per_window per replica and expect
the effective ceiling to scale with the instance count.
Which refusals carry Retry-After, and which cannot
This API turns a caller away with 429 when a budget is spent and with
428 when the step-up gate wants a fresh proof, and those refusals are
deliberately not uniform — an operator meeting them in one log should not
read the differences as an inconsistency:
| Refusal | Raised by | Retry-After |
|---|---|---|
429 too many account changes; retry in N seconds | the five guarded mutations above, over the [rate_limit] budget (per wallet) | yes — whole seconds left on the fixed window |
429 too many login challenges; retry in N seconds | POST /v1/auth/nonce, over the [nonce_rate_limit] budget (per requested pubkey) | yes — whole seconds left on the fixed window |
428 {"error":"step_up_required"} | the same five mutations, reached without a fresh proof | no — the remedy is a signature, not a wait |
429 the outbound quota (see Compose and outbound quotas) | POST /v1/mail/send, external recipients only | no — prose only |
Those first two rows are two wordings of one shape, not one shared
string: each names the budget the caller actually spent, so a throttled
login challenge is never told it made too many account changes when it
asked to change nothing. Everything else about the two is identical —
429, a Retry-After header, whole seconds left on a fixed window,
floored at 1, and one too many …; retry in N seconds sentence rendered
from the same code with only the noun differing. Match on the status and
the header, not on the prose: the noun names whichever budget refused
you, and a budget added later will word its own refusal the same way.
Both budgets in those rows are fixed windows with a known end, so “how
long until you may proceed” is a fact the server holds and can state exactly.
The step-up gate holds no
such fact and needs none: nothing about waiting turns a 428 into a
success, so a header telling a client when to try again would be telling it
to do the one thing that cannot work. It re-challenges instead, at once.
The outbound quota is a rolling hour/day count over hour-bucketed
counters: when allowance returns depends on which past bucket ages out
of the window, so any Retry-After it emitted would be a guess — and a
client that faithfully honours a wrong hint retries at exactly the wrong
moment. A refusal that cannot state an honest delay states none, and
leaves the client to back off on its own. The absent header there is the
considered answer, not a gap to be filled later.
Two of the refusals also read as one metric, and the other two do not
join them. sithbit.api.refusals carries the mutation 429 and the 428
on the same three gated route templates, a series per status, which is
where a dashboard reads their interplay. The quota 429 is raised on
POST /v1/mail/send against the rolling window instead, so it lands under
its own route series and never joins those three — count it with the
outbound-quota panels, not with the gated pair — and the nonce budget’s
429 likewise lands under its own /v1/auth/nonce series (the counter
wraps the whole surface), so per-key hammering of challenge issuance is
readable apart from everything above. The gated readings are in
Monitoring.
The request-body ceiling
Every body-accepting route on this API sits behind one crate-wide request-body ceiling of 2 MiB — axum’s own implicit default, named explicitly in code so a dependency bump can never move it. It is a constant, not a config setting: the ceiling is a status decision, not a capacity setting. A request whose body crosses it answers, in full:
HTTP/1.1 422 Unprocessable Entity
content-type: application/json
{"error":"body_too_large"}
That body is byte-exact, like the step-up refusal’s: body_too_large is
the token a client branches on to shrink the payload before retrying.
Two boundary facts complete the contract:
- The status is deliberately not the
413 Payload Too Largeaxum answers by default. On this API413means over storage quota and only that — the aggregate per-wallet cap a compose can cross — so the pair is distinguishable by status alone:413= free space,422 body_too_large= send less. - The token belongs to the ceiling alone. Other
422s keep their own bodies — a message-id collision on the chain relay answers422with its own prose, never this token — so matching on the exact string is safe and matching on the bare status is not.
Sizing the summary caches
The API keeps four bounded in-memory caches — the shared plaintext
summary cache, the per-session sealed-summary cache with its
whole-session ceiling, and the reading-secret stash — sized by the
[cache] section
of account_api.toml. Nothing there is required: the defaults fit a
small self-contained deployment, and that page carries starting values
for a farm. What to actually set is decided from the service’s own
telemetry rather than taken on trust: it exports hits, misses,
evictions, live entries and the configured capacity per cache (the
sithbit.api.cache.* and sithbit.api.session_secrets.* series), and
Tuning the account-API caches
reads the four states those distinguish into one action each, naming
the key it changes. Size per replica, never from the fleet total — each
replica holds its own caches.
Do-not-disturb schedules
The do-not-disturb schedule lives here too (see
Do not disturb for why refusing mail
beats an autoresponder). The owner manages it authenticated —
GET/PUT /v1/account/dnd read and replace the exclusion set wholesale
— and one anonymous route answers senders:
GET /v1/dnd/{wallet}always returns{"excluded_now": bool}— whether the wallet is away right now, evaluated in its own timezone. A wallet with no account simply isn’t away:excluded_nowisfalse.- The schedule itself (an
exclusionsarray alongsideexcluded_now) appears only when the owner opted in via the account’sexpose_dnd_scheduleflag —PATCH /v1/account {"expose_dnd_schedule": true}, also reported on everyGET/PATCH /v1/accountresponse. Defaultfalse: hidden. - An
accepting_atfield — the instant the wallet starts accepting mail again, computed in the account’s own timezone and put on the wire as RFC 3339 UTC so the sender’s page can localize it to their clock — rides the reply only behind a triple gate: the wallet is excluded right now, and the owner opted in (the sameexpose_dnd_scheduleflag as the schedule), and the schedule ever reopens. A recurring schedule covering all seven days around the clock never does — the field is then omitted, like every other case that fails the gate.
That default is a deliberate privacy-tightening behavior change: the
route used to return the full exclusion list to any anonymous caller.
Existing deployments now answer only the yes/no until each owner opts
in — the self-service schedule
page degrades
gracefully either way, telling a refused sender “temporarily away” and
showing the windows — and the accepting_at instant, localized to the
sender’s own clock — only for opted-in recipients.
The on-chain proxy (/v1/chain)
With a [chain] section configured, the API additionally serves the
authenticated /v1/chain surface — an on-chain read proxy ( SOL balance,
mailbox, published encryption key, aliases, per-sender stamp balances)
plus a relay for transactions the client built and signed itself. This is
the browser-reachable surface the Thunderbird extension rides: mail-grpc
holds a hot signing key and speaks raw gRPC, so browsers never talk to it
directly. The wallet-scoped reads answer for the JWT’s wallet; the relay never
signs anything. One generic read rounds out the proxy:
GET /v1/chain/account/{address} returns any raw account —
{"owner": "<base58>", "data": "<base64>"}, the node’s bytes untouched —
so an API-mode web shell can decode accounts client-side with the same
wasm decoders the wallet-direct pages use (the bounty-claim resolver
reads the domain account this way, so a domained claim pays the domain
authority instead of falling back to the filler pair). An absent account
is a 404; the data is public on-chain, but the route sits behind the same
JWT as its read siblings. Without [chain], those routes answer 503.
The mail routes (/v1/mail)
The API also serves the /v1/mail surface — the user-facing mail
routes (folders and folder CRUD, message pages with parsed summaries,
full rendering, raw/part downloads, flags, move/delete, and a capped
scan search with a resume cursor) over the same store the
IMAP/POP servers serve. No IMAP bridge is involved:
webmail and the
Outlook add-in read the rows and blobs directly through these routes,
authenticated by the same JWT as the account surface. The read routes
need no configuration beyond [store], with one optional setting.
Message listing (GET /v1/mail/messages) and search
(GET /v1/mail/search) ride an indexed, newest-first keyset primitive
on every store backend — the store answers each page from an index
range rather than scanning the whole mailbox — so the client-visible
cursor contract (before_uid in, next_before_uid/resume_before_uid
out, truncated) is unchanged. Two boundary facts worth knowing: an
unknown mailbox 404s, while an existing-but-empty mailbox returns
200 with an empty page; and a search that scans the full candidate cap
reports truncated=true with a resume cursor whose follow-up page is
empty — a harmless one-extra-empty-continuation that a correct resume
walk simply stops on.
By default a message fetch (GET /v1/mail/messages/{uid}) is a pure
read: it never touches flags, so read state is whatever the client
sets with an explicit PATCH — the recommended default, matching the
IMAP model where the client owns \Seen. Operators serving clients
that expect a fetch to imply “read” can opt in with the [mail] auto_mark_seen setting: when true, a successful fetch adds \Seen
to that message after rendering (an opt-in read-receipt; the response
body is unchanged). It defaults false, and — like every other setting —
is shown commented at its default in the example config:
[mail]
# auto_mark_seen = false # true: GET /v1/mail/messages/{uid} adds \Seen after fetching
(Message fetch is served by an indexed by-uid lookup plus a per-blob summary cache — a performance detail with no client-visible change.)
Sealed bodies and keyed sessions
On a deployment that seals mail
at rest, each delivered
body is stored under a per-message key wrapped to the account’s
reading key — so the API can only
serve a body it can open. A client supplies the matching secret once,
as an optional reading_secret field (32 bytes of base58 X25519) on the
POST /v1/auth/token exchange; the server holds it in memory for that
token’s lifetime and never writes it down. See
what your operator holds for the
privacy model this sits inside.
A session that supplied one is a keyed session, and it opens sealed bodies everywhere reading happens — the single-message fetch, the message listing’s parsed summaries, and search. A session that did not still lists and searches normally; it simply cannot see inside a sealed body. Nothing here is configurable: it follows from whether the client sent a secret.
The sealed field on a listing entry
Every entry of GET /v1/mail/messages carries a sealed boolean whose
meaning is narrow: “this response did not open the body” — not
“encrypted at rest”. A sealed row that a keyed session opened is an
ordinary row. The four cases are exhaustive:
| Stored body | This session | Listing entry |
|---|---|---|
| plaintext | any | sealed: false + the parsed summary |
| DEK-sealed | holds a reading key that unwraps it | sealed: false + the parsed summary |
| DEK-sealed | supplied no reading secret | sealed: true + an all-default summary |
| DEK-sealed | wrong key, no wrapped key for this reader, or a store error | sealed: true + an all-default summary |
The all-default summary is empty by construction — no sender, no subject,
no snippet — so a client must render the sealed marker rather than the
blanks. The row’s own metadata (uid, size, flags, internaldate) comes off
the store row, not the body, and is always real. A client that predates the
field sees no field at all, which reads as false.
Search over sealed rows
GET /v1/mail/search keeps its exact response shape; what changed is which
rows can match. A keyed session decrypts each sealed candidate inside the
same capped candidate window described above, then matches it on the
headers — and on the body when body=true — at parity with a plaintext
row. A row this session cannot open never matches: it is skipped rather
than matched as ciphertext, so an unkeyed search returns precisely what it
always did. Every hit therefore carries sealed: false, because reaching a
verdict means the response opened the body.
Cost is bounded by that same window: at worst one blob fetch, one wrapped-key lookup and one decrypt per candidate row. Unkeyed sessions pay nothing extra — the sealed path short-circuits before any store round-trip.
Summaries a keyed session decrypted are cached per session, keyed on a
digest of that session’s jti id — never of the bearer token itself, which
the cache never sees — and never reach the shared plaintext summary cache.
They die with the session: token expiry,
logout, or eviction of that
session’s reading secret. What a reader actually sees when a row stays
locked is on the webmail
page.
Ending a session (POST /v1/auth/logout)
POST /v1/auth/logout ends the session the presented token names: the API
drops that token’s reading secret and every summary decrypted under it,
together and in memory, before it answers. There is no request body and no
response body.
- Bearer required, like every other authenticated route: no
Authorization: Bearerheader — or an invalid one — is the usual 401, and it drops nothing, so an unauthenticated caller can never end somebody else’s session. - 204 No Content on success, and idempotent: a session that stashed nothing, and a token that already logged out, both answer 204 as well. A client can call it on every sign-out without checking first.
- Per token, not per wallet. One wallet holding two live tokens logs out
of one and keeps the other — two logins made inside the same second
included. Every issuance mints its own random
jtisession id into the JWT, so the second-resolutioniat/expno longer decide the matter: a same-second pair is two sessions, with separate stashes, and ending one leaves the other whole.
What it deliberately does not do is revoke the token. The JWT is a
stateless bearer credential: it stays valid to its exp
(jwt.ttl_hours, 24 by default) on every
route, exactly as it does for any other request, because there is no
denylist — the jti is the key a session’s server-side state is filed
under, not a revocation list a presented token is checked against. What
logout guarantees is narrower, and is the half that matters for sealed
mail — after it the server holds no key material for that session, so
nothing sealed can be opened with that token again. The same still-valid
token then reads precisely what an unkeyed session reads: a message fetch
answers 403 {"error": "sealed"} and the listing comes back sealed: true
with an all-default summary, while the plaintext routes carry on working.
The client must still drop its own copy of the token — logout is the
server-side half of signing out, never the whole of it. What that looks
like in a browser client is on the
webmail page.
Compose and outbound quotas
The one write route that leaves the store is compose
(POST /v1/mail/send): the API builds the RFC822 message and spools it
through the same core sithbitd’s SMTP sink uses — local recipients get
mailbox rows and background chain jobs, foreign domains get relay jobs
the spooler’s relay worker drains (the two binaries share the store, so
no extra wiring), and the composer gets an already-read Sent copy.
Routing is configured by the [mail] section: local_domains names the
domains whose recipients live in this store (mirror sithbitd’s
local_domains; aliases and the stamp precheck resolve over the
[chain] gateway — without one, only literal wallet addresses resolve
locally), and [mail.dkim] (same one-or-many shape as
[spooler.dkim]) signs composed mail. Both default off: with no local
domains everything relays — mail addressed to this deployment’s own
domain then loops back through its MX, which is correct, just slower —
and with no keys relayed mail goes unsigned.
Compose also enforces the outbound abuse controls: a suspended
account is refused HTTP 403 on every send, and external (relayed)
recipients past the account’s rolling hour/day allowance are refused
HTTP 429 — local recipients are never counted (on-chain stamps price
those). The policy is the API’s [quota] section, a deliberate twin of
sithbitd’s [smtp.quota] / [submission.quota] — keep the two in
step so a sender meets one policy on both submission surfaces. See the
Configuration reference
for the settings and the age ramp, and
Monitoring for the
admin quota/suspend endpoints and the per-surface refusal codes.
The DMARC aggregate-report reader
The /v1/admin surface (a wallet on the admin_wallets allowlist,
like every admin route — see Monitoring) also carries
the DMARC aggregate-report reader: GET /v1/admin/dmarc-reports
lists every report sithbitd’s
[spooler.dmarc_rua_ingest]
delivery hook has stored in the shared blob store —
{"reports": [{"id": …, "modified_at": …}]}, modified_at RFC 3339 or
null when the backend reports no timestamp, unpaginated (like its
admin siblings), an empty store an empty list — and
GET /v1/admin/dmarc-reports/{id} returns the stored report JSON
verbatim (the parsed RFC 9990 aggregate report, as the ingest hook serialized
it). A malformed id is 400 — ids are validated against the key
whitelist ([A-Za-z0-9._-], at most 200 bytes) before the store is
touched, so a crafted path can never read outside the dmarc_rua/ blob
prefix — an unknown well-formed id is 404, and both routes share the
standard admin 401/403. Nothing prunes the stored reports yet. See
DNS setup for pointing your
domain’s rua= address here in the first place, and the
conformance appendix
for what ingestion deliberately does not do with the data.
Move, delete, and IDLE semantics
Two semantics worth knowing when these routes mutate mail:
- Move preserves the source’s chain linkage; delete settles it. A move is a single linkage-preserving relocation: the surviving copy carries its on-chain message id, IPFS pin (cid), and chain state to the destination, and the source uid is gone. Nothing is torn down — no chain-delete job, no orphaned-blob delete — because the linkage travels with the moved copy rather than being settled. This deliberately diverges from IMAP MOVE, which settles the source; keeping the linkage means the moved mail stays available and on-chain-linked at its new home. Delete is the teardown path: it expunges the copy and settles the chain — chain-delete jobs for pinned/sent copies, byte deletion for orphaned blobs.
- IMAP IDLE sees API changes with a delay. The API and
sithbitdare separate processes, so a webmail mutation reaches an idling IMAP client viasithbitd’s change poller ([imap] watch_poll_seconds), not instantly. Flag-only changes on a SQLite store don’t bump the change sequence at all — they surface with the next real mailbox event.
Static hosting for browser clients
The browser clients SithBit ships are static bundles that talk to this
API, and serving them from the API itself is what keeps page and API
on one origin: they call /v1/… directly, with no CORS configuration
anywhere. Each directory you want served is one [[static]] entry — a
route prefix and a root directory — and because it is a list, one API
instance can host the
Outlook add-in, the
get-started and self-service pages and a
webmail shell side by side:
[[static]]
route = "/addin"
root = "webclients/outlook/staging"
[[static]]
route = "/onboarding"
root = "webclients/onboarding/staging"
[[static]]
route = "/mail"
root = "webclients/webmail/staging"
With no entries at all — the default — the API serves no files and is a
pure JSON API, and a bare [[static]] entry with neither key takes both
defaults, route /addin and root wwwroot.
The five rules that list obeys — unique prefixes; the leading slash you
have to write yourself; nesting, which is free in any order except under
a harness nest; the four harness names every mount 404s beneath itself;
and a missing root, which answers 404 rather than failing at startup —
are written out once in
the configuration
reference, with the
migration note for the earlier single [static] table beside them: that
spelling is now refused at load rather than ignored. What is worth having
in front of you here is what a rejected list says on the way out. Every
one of those refusals aborts the process while the router is assembled —
before the listener binds, so a bad entry is a startup failure rather
than a half-served origin — and each quotes your own value back at you,
so the fix usually starts with grepping the file for the quoted prefix.
(A missing root is the one shape above that is not a refusal: that
mount is built and answers 404.)
A prefix claimed twice:
[[static]] route = "/addin" is already mounted by an earlier entry: each entry needs a prefix of its own. Drop one of the two entries, or give one of them a different route.
A prefix written without its leading slash, which is never added for you:
[[static]] route = "addin" cannot be mounted: a mount prefix has to start with "/". Write it as route = "/addin" — the slash is never added for you, so the prefix that serves is always the one in the file.
A route standing on one of the harness nests — test, tests,
__tests__ and spec, which every mount 404s beneath itself — and a
route standing below one. Both are refused in either configuration
order, and each message names the deeper of the two entries:
[[static]] route = "/addin/test" cannot be mounted: route = "/addin" fences it off as a harness nest — every mount 404s "test", "tests", "__tests__" and "spec" beneath itself, so nothing configured there could ever be served. Drop one of the two entries, or give one of them a route that is not a harness directory of the other.
[[static]] route = "/addin/test/sub" cannot be mounted: it sits inside "/addin/test", which route = "/addin" fences off as a harness nest — every mount 404s "test", "tests", "__tests__" and "spec" beneath itself, and a mount below one of those names would serve straight through that fence. Drop one of the two entries, or give one of them a route that is not inside a harness directory of the other.
The second of those is a deliberate break with earlier releases, which
built such a list and served it; see the change
history. The one refusal not quoted above, a route
claiming the origin root, reads the same way — the API’s own routes live
there. Only
the harness names themselves are fenced: /addin/testing merely starts
with one and mounts fine, while the API’s own wwwroot/test/ is exactly
what the fence is for — a suite that ships so it can be run from the
tree, never so it can be fetched from the origin.
Serving the add-in over https
Office requires the add-in over https: either front
the API with a TLS-terminating reverse proxy (the production
recommendation) or enable the built-in [tls] listener. A dev
certificate for sideloading is one command —
openssl req -x509 -newkey ed25519 -keyout key.pem -out cert.pem \
-days 365 -nodes -subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost"
— then trust cert.pem in the OS store (Office rejects untrusted
certificates even on localhost).
Key sources: TLS and JWT
The [tls] certificate and key (certs / key) and the
JWT signing key
(jwt.key_file) are each a key
source: a local
file by default — the zero-config path, and the only form the JWT key
auto-generates into when missing — or a cloud secret-manager secret:
Azure Key Vault ({ kind = "akv", vault_uri = "…", secret_name = "…" },
fetched with the same managed identity as the Azure store backend), AWS
Secrets Manager (kind = "asm"), or Google Secret Manager
(kind = "gsm").
Running it
When you need it: only if you’re exposing self-service account management — for example, the Thunderbird extension, the Outlook add-in, or a webmail UI — rather than administering accounts purely through the CLI.
Quickstart:
cargo run -p account-api
Config file account_api.toml, or point ACCOUNT_API_CONFIG at an
alternate path. It shares sithbitd’s [store] — point both at the same
database so account state and mail state stay consistent.
See the Configuration reference for the full key/default table.