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/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 — 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/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.
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_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.
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
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.
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.