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.

Wallet-challenge login (issuing a JWT), plus mail passwords, timezone, and do-not-disturb schedule management.

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 — there is no delete path yet, so an account that already set a stored password keeps it until a future clear endpoint lands.
  • 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.

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.

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.

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 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.)

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 /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 7489 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.

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.

With a [static] section configured, the API also serves a static directory (default route /addin) on its own origin — this is how the Outlook add-in’s built bundle (webclients/outlook/staging) is hosted, so the add-in’s pages call /v1/… same-origin with no CORS configuration. 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).

The [tls] certificate and key (cert_file / key_file) 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").

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.