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

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

RouteAuthPurpose
POST /v1/auth/noncenoneissue a login challenge (mints no account row; rate-limited per requested pubkey)
POST /v1/auth/tokennoneverify the signed challenge → JWT (optionally carries the reading secret)
POST /v1/auth/logoutJWTend the session: drop its reading secret and the summaries decrypted under it
POST /v1/auth/step-upJWTissue a step-up challenge for the token’s own wallet
GET /v1/whoamiJWTthe caller’s wallet

Account settings

RouteAuthPurpose
GET/PATCH /v1/accountJWTaccount settings (timezone, DND exposure opt-in, current auth epoch)
PUT /v1/account/passwordJWT + step-upset the POP/IMAP/SMTP mail secret
DELETE /v1/account/passwordJWT + step-updelete the stored mail secret
POST /v1/account/auth-epochJWT + step-uprotate the wallet-signature mail credentials
GET /v1/account/pin-providerJWTwhich custom IPFS pin provider is configured (kind only, never the credentials)
PUT/DELETE /v1/account/pin-providerJWT + step-upset or clear the custom IPFS pin provider — see Per-recipient pin providers
GET/PUT /v1/account/dndJWTown away-schedule exclusions (replace-set)
GET /v1/dnd/{wallet}nonepublic 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.

RouteAuthPurpose
GET /v1/chain/balanceJWTnative SOL balance (lamports)
GET /v1/chain/mailboxJWTon-chain mailbox (mail count)
GET /v1/chain/keyJWTpublished delegated encryption key
GET /v1/chain/cert?email=noneresolve an email/alias/wallet → recipient’s published key (HKP/WKD-style keyserver)
GET /v1/chain/aliasesJWTaliases held by the wallet
GET /v1/chain/listingsJWTevery open marketplace listing (aliases and domains for sale) — global, so the wallet is authenticated but unused
GET /v1/chain/sales?wallet=&kind=&name=JWTmarketplace sale history, all filters optional — global, so the wallet is authenticated but unused
GET /v1/chain/participants?tags=&limit=JWTglobal participant pool, narrowed to beacons carrying every listed tag bit
GET /v1/chain/frombox?from=JWTstamp balance one sender holds with us
GET /v1/chain/account/{address}JWTone raw account (owner + base64 data), the escape hatch API-mode web shells decode client-side
GET /v1/chain/blockhashJWTrecent blockhash for client-built transactions
POST /v1/chain/submitJWTrelay a client-signed transaction
GET /v1/chain/tx/{signature}JWTcommitment 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.

RouteAuthPurpose
GET /v1/mail/mailboxesJWTfolder tree with per-folder counts
POST /v1/mail/mailboxesJWTcreate a folder (and missing parents)
POST /v1/mail/mailboxes/renameJWTrename a folder subtree
DELETE /v1/mail/mailboxes?mailbox=JWTdelete a folder + chain teardown
GET /v1/mail/messages?mailbox=&limit=&before_uid=JWTnewest-first summary page (keyset cursor)
GET /v1/mail/messages/{uid}?mailbox=JWTfull JSON rendering (headers, text, html, attachments)
GET /v1/mail/messages/{uid}/raw?mailbox=JWTthe stored .eml bytes (message/rfc822)
GET /v1/mail/messages/{uid}/parts/{part}?mailbox=JWTone decoded part as a download
PATCH /v1/mail/messages/flagsJWTmerge flag adds/removes onto stored flags
POST /v1/mail/messages/moveJWTcopy + expunge + chain teardown (IMAP MOVE semantics)
POST /v1/mail/messages/deleteJWTexpunge + chain teardown
GET /v1/mail/search?mailbox=&q=&body=&limit=&before_uid=JWTcapped scan with a resume cursor
POST /v1/mail/sendJWTcompose 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.

RoutePurpose
GET /v1/admin/accounts?after=&limit=paged wallet enumeration
GET /v1/admin/accounts/{wallet}/mailboxesmailbox tree
GET /v1/admin/accounts/{wallet}/messages?mailbox=copies with chain states
GET /v1/admin/queuesjob-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/requeuere-drive on the source queue (echo the listed token)
POST /v1/admin/dead-jobs/discarddelete permanently (echo the listed token)
GET /v1/admin/accounts/{wallet}/quotarolling outbound usage, suspend flag, and the allowances in force
PUT /v1/admin/accounts/{wallet}/suspendset/clear the outbound-suspend flag
GET /v1/admin/dmarc-reportsingested 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/password takes an optional password field. 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/account returns a boolean mail_password_set telling 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/account also returns auth_epoch, a u64: 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 — at 0, an account that has never rotated, exactly as firmly as anywhere else. The older epoch-less signature 0 once 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. 428 when the request brings no freshly signed challenge, 404 when 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 a 428, 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/password keeps it, and it keeps logging in — clearing that one is the separate DELETE /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 its exp. 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_auth off 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. 401 re-login, 428 re-sign a challenge, 404 only when the token’s wallet has no account row, 500 on 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 no 409/412 and no “nothing to delete” error.
  • A route of its own, not a mode of PUT /v1/account/password. An empty PUT body 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-epoch retires the wallet-signature credential, DELETE /v1/account/password the 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_auth off 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:

  • PUT and DELETE /v1/account/password
  • PUT and DELETE /v1/account/pin-provider
  • POST /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

  1. 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}.
  2. Sign the nonce string’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-text is that signer.
  3. Resend the mutation with that signature in the x-sithbit-step-up header:
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.

StatusWhat it meansWhat the client does
401the session itself is overlog in again — a whole new wallet-challenge login
428the session is fine, the proof is missing, stale or spentfetch a challenge, sign it, resend with the token already held
400the header is not a base58 ed25519 signaturefix the encoding and retry; the challenge is untouched, so an honest retry works
429the wallet’s mutation budget is spenthonour 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: against SithBit 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-After is always delta-seconds, and never 0. 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 at 1 — a sub-second remainder truncates to zero, and a Retry-After: 0 tells 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-mail 403 {"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 401 without 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:

RefusalRaised byRetry-After
429 too many account changes; retry in N secondsthe 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 secondsPOST /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 proofno — the remedy is a signature, not a wait
429 the outbound quota (see Compose and outbound quotas)POST /v1/mail/send, external recipients onlyno — 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 Large axum answers by default. On this API 413 means 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 answers 422 with 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_now is false.
  • The schedule itself (an exclusions array alongside excluded_now) appears only when the owner opted in via the account’s expose_dnd_schedule flag — PATCH /v1/account {"expose_dnd_schedule": true}, also reported on every GET/PATCH /v1/account response. Default false: hidden.
  • An accepting_at field — 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 same expose_dnd_schedule flag 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 bodyThis sessionListing entry
plaintextanysealed: false + the parsed summary
DEK-sealedholds a reading key that unwraps itsealed: false + the parsed summary
DEK-sealedsupplied no reading secretsealed: true + an all-default summary
DEK-sealedwrong key, no wrapped key for this reader, or a store errorsealed: 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: Bearer header — 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 jti session id into the JWT, so the second-resolution iat/exp no 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 sithbitd are separate processes, so a webmail mutation reaches an idling IMAP client via sithbitd’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.