The webmail app
A plain-browser webmail client: the folder rail, a newest-first message
list with per-folder search, a sandboxed reader, and a compose pane —
plus the same wallet login and settings panes the
Thunderbird and
Outlook clients carry, because all three hosts run the same shared core
(webclients/shared/). Everything that must be signed is signed
in the page by a WebAssembly module compiled from this workspace’s
own crates; the server only relays already-signed transactions and
never holds your key.
Navigation is hash-only — #/mail (the three-pane mail view) and
#/settings (the shared dashboard panes). There is no build framework
and no CDN: the bundle is static files served by
account-api itself.
The server is optional, though: clearing the API URL in the connection settings switches the app into trustless mode — wallet-only unlock, on-chain mail read and sent with nothing but a Solana RPC node and an IPFS gateway/pin daemon.
Operators: building this bundle, mounting it under account-api, and the config the compose pane needs are covered in Serving the browser clients. A reader who was handed a URL needs none of it.
Install as an app (PWA)
The webmail bundle is an installable progressive web app. Once it is served over its origin, the browser offers its native install prompt on the desktop (Chrome/Edge’s address-bar install button) and Add to Home Screen on mobile, giving you a standalone SithBit Mail window with its own icon and no browser chrome — tinted the brand teal.
A service worker precaches the static shell (HTML, CSS, JS, and the
wasm module), so the app loads offline — the login, unlock, and
dashboard frames paint with no network. Your mail itself stays live:
the service worker never caches the account API (/v1/*), so every
message, folder count, and send always hits the server. Open the app
with the server down and you get the shell; you reach your mail again
the moment it is back.
First run and onboarding
A brand-new visitor — no wallet stored in this browser yet — lands directly in the shared five-step onboarding wizard: create or import a wallet (the create branch shows your secret key once, with the save it — it is the only copy gate), claim an optional handle, set your default postage, review, and mint your mailbox on-chain. Until that finishes the app hides its mail and settings views and shows only the wizard. Every transaction is built and signed in the page by the wasm module; the server only relays it.
The app decides which of four views to paint from the session it probes on load:
- Onboarding wizard — no wallet stored yet, or an unlocked wallet that owns no mailbox (a returning user finishing setup).
- Unlock — a wallet is stored but locked this run; the app asks for its passphrase (the key is encrypted with that passphrase before it lands in the browser’s localStorage, and is asked for again each time you open the app).
- Dashboard — a fully set-up account: unlocked and mailbox-registered
— the normal
#/mail,#/settingsand#/marketviews below. - (a brief loading view while it probes.)
Because the webmail bundle is served same-origin from account-api,
it needs no endpoint configuration at all: the API base URL is simply
the page’s own origin. The onboarding create relays through that same
API, so its on-chain reach follows the API’s [chain] config exactly as
the compose path does above.
Using it
The #/mail view is three panes. The folder rail opens with a pinned
section in a fixed order — INBOX, then Sent, then Drafts, each hoisted
with whatever nests under it — and a rule divides it from the rest of
your folders. A special-use folder draws a small icon for what it is
(Sent, Drafts, Archive, Trash, and Junk or Spam); INBOX and folders of
your own draw none. Either section renders as a tree with unseen
counts: names nest on the / separator (a folder named
Projects/Alpha shows as Alpha under Projects), a parent row’s
triangle folds its subtree shut and open, and a nested folder whose
parent does not exist shows flat under its literal full
name — the rail never invents a parent row the server did not list.
A folder holding unread mail renders its name bold — the same cue as
an unread message row, keyed to each folder’s own count, never bubbled
up to a parent.
Long folder lists fold. When the section below the rule holds more than five top-level folders, the rail draws the first five — each with its own subtree — and a More link appears beside New folder; clicking it shows the rest and the link reads Less while they are showing. Only that section is ever cut: the More link never hides the pinned three, and never hides the folder you are currently reading either, even when it sorts past the cut — though a subtree you folded yourself stays folded in both sections, so your own fold can still hide the folder you are reading. The link is there only while a row is genuinely hidden, so an account one folder over the line with that folder selected draws everything and offers no link at all. In the shot above, More stands for Spam and Trash.
The middle column stacks per-folder search over the message list
(keyset-paged, newest first); the reader marks messages read on open,
toggles text/HTML/raw views (HTML render in a fully sandboxed iframe
that blocks scripts and remote loads), downloads
attachments, flags, moves, and deletes. A ✓ Verified trust mark
appears beside the From line when the sender holds an on-chain
verified-sender attestation
from its domain — here it checks the sender the (ingress-authenticated)
From header names, while the trustless viewer’s
badge binds the program-verified on-chain envelope signer. No mark
simply means “not attested”; it is never a warning. Compose floats bottom-right:
To/Cc take comma-separated addresses — aliases, user@domain, or bare
wallet addresses — and the reader’s Reply action opens it prefilled
(sender as To, Re: subject, threading header). A successful send
files an already-read copy in Sent and refreshes the folder counts;
recipient problems (unknown alias, no stamps on your frombox) surface
on the pane with your draft intact.
The rail also manages your folders. New folder under the list opens a small dialog with a Nest under picker — any listed folder, or the top level — and each row’s ⋮ menu (shown when you hover the row, or reach the button with the keyboard) offers Rename, Delete, and New subfolder. Creating a folder navigates to it: the rail reloads, unfolds any parent you nested it under, and selects the new folder — so one created past the More cut is drawn rather than hidden behind it. That selection closes whatever message the reader had open, exactly as picking any folder does. Rename edits the folder’s full name, so changing the path part re-parents it, and its subfolders move along with it (the dialog says so when it has any). Delete asks first, then removes that one folder and its messages and keeps its subfolders: a child whose parent is gone shows flat under its literal full name, exactly the orphan rendering described above. A placeholder row the server lists as unselectable (a parent an earlier delete left behind) offers only New subfolder — there is no real mailbox there to rename or delete.
The #/settings view is the shared dashboard: mail password —
typed, or derived from a wallet signature with the pane’s Derive
mail password button (available while the wallet is unlocked; see
The mail password) —
timezone, do-not-disturb, aliases,
balances, delegated
encryption-key management,
domain trading (list a domain you hold
for sale on the open marketplace, or buy a listed one by name and authority),
settling reply bounties (claim one on a
message you received and replied to, or refund one you placed that expired
unclaimed), pinning leases (escrow a
refundable deposit asking operators to keep a message’s pinned body past the
default retention — create
a lease by the message’s CID and id, check a CID for your wallet’s lease, or
close it
anytime to reclaim the deposit),
claiming and configuring your mailbox
(the handle, sending domain, default postage, and on-chain-only opt-out), and
closing the mailbox
— a two-step, 7-day timelocked flow, so the request only starts a
clock (the mailbox stays open and receiving, nothing is refunded), a
cancel is available throughout, and the rents return only when you come
back after the wait and finish the close. Where they return depends on who
paid: for a mailbox you created yourself both rents come back to you, while
for a sponsored mailbox
the mailbox rent goes back to the sponsor who funded it and you keep the
pending-close rent. The pane says which happened. All of it behaves exactly as
documented for the Thunderbird extension. The
trustless viewer’s Lease this message button
(beside Reply, shown once a body has rendered) prefills the
pinning-leases create form with the open message’s CID and id and
routes the shell straight here — the URL hash becomes #/settings and
the settings strip opens its Services tab, so the browser’s back
button returns to the mail view. You still review
the deposit and submit, and entering a CID by hand works as before.
Three of those controls decide how your mail clients get into the account — Set password, removing a stored password, and rotating the wallet-derived one — and each of them asks your wallet to sign a one-time confirmation as it goes through. The pane says so where it asks (the mail password field’s own line reads “Saving it asks your wallet to sign a one-time confirmation.”), and the signature is per change: applying two of them is two confirmations. With an in-app wallet the app signs and you see nothing; with a Phantom or Ledger wallet it is the usual approval prompt. Being signed in is no longer enough for these three, which is the point — a session someone else picked up cannot change your mail credentials without your wallet. The account-api reference has the wire-level contract.
The settings view: five tabs
The #/settings view is the shared dashboard the other clients mount too. Its five tabs group related controls:
- Wallet — balances and the encryption key.
- Mailbox — mailbox settings, do not disturb, and mailbox closure.
- Names — aliases and domains.
- Services — reply bounties and pinning leases.
- Sign-in — the mail password, client certificate, and connection details described below.
Hovering a tab for half a second shows a one-line summary. The top bar’s Mail / Settings / Marketplace links switch between the main views.
Sealed rows and your reading key
On a deployment that keeps your stored mail
sealed at rest, the app
can only show you a message it can decrypt. A row it could not open is drawn
as locked rather than blank: (sealed) where the sender goes,
(sealed — sign in with your reading key) where the subject goes, and no
snippet. Its date is real — dates come off the mailbox row, not the body —
so the list still sorts and pages exactly as usual, and opening a locked row
answers with a readable sign in again with your wallet’s reading key
message rather than a broken reader. Search skips them too: a message this
session cannot open never appears in results, rather than matching its
ciphertext.
Sign in with your reading key and the lock simply is not there — those rows list, search, and open like any other, with nothing marking them out. The app works out which key your mailbox is read with at unlock — it looks up what, if anything, your mailbox has published on-chain, then derives that secret itself — and hands it to the server only on the login exchange; there is no setting to turn on and nothing extra to type. Treat it as a secret, not as a password: a mail password only proves who you are, while this key opens mail — anyone holding it reads every message sealed to that wallet, the old ones as well as the ones still to arrive. That is why it lives in the page for the length of a session, is never written to the browser’s vault, and rides nothing but the login.
Which accounts have that key today. Four cases:
- A browser-generated account that has published no encryption key — the default for a wallet the in-page wizard created — yes. Its stored mail is wrapped to the wallet’s own encryption twin, and unlocking the wallet is what produces that secret, so every sealed row opens — the mail already in the account included, because the twin is the key it was always wrapped to. Nothing to publish, nothing to save, no button to press. (Until this release those rows stayed locked: the page held the wallet, but nothing handed the app the wallet’s twin secret.)
- A connect-wallet account (Phantom, Ledger, or any wallet extension taken through the web wizard) — yes. The wizard generated a delegated reading key and published it, and your browser holds its secret, so every sealed row opens.
- An account that published the recoverable, wallet-derived key
(the recoverable reading key,
published from the command line with
sithbit mailbox set-key --derivesince no pane offers it) — yes. Unlocking reproduces that key from the wallet itself, and the app signs in with it rather than with the twin because it can see that this is the key your mailbox published — so every sealed row opens, on any device you hold the wallet on, with nothing to save or paste. (Until this release these accounts read nothing sealed here: the app signed in with the twin while your operator wrapped to the derived key.) - A browser-generated account that published a fresh random delegated key — the Encryption key pane’s Generate & publish a delegated key button — yes for the mail sealed to that key, while the tab stays open. Publishing signs this session in again with the new key on the spot, so mail that arrives from then on opens without your locking and unlocking first; and because a session reads with one key, the mail already in the account — sealed to the wallet twin, before the publish — is what lists as locked for the rest of that session. That key is random, so nothing about your wallet reproduces it: the app keeps the copy the pane just published only for as long as the tab stays open, and it is never written to the browser’s vault, the one-time export the pane shows you being the only lasting copy. So a reload signs in with the wallet twin again — the older mail reads, and rows sealed to the published key list as locked. Keep that export; handing it back to the app at sign-in is filed follow-up work.
Publishing or closing a key takes effect at once. Either way the app signs this session in again with whatever reads your mail from that moment — the key you just published, or your wallet’s own key again once a delegated key is closed — so there is no lock-and-unlock step to remember, and no stretch of the session where your operator holds a key nothing is sealed to. If that second sign-in cannot reach the server the pane tells you in as many words (“…but this session still reads with the previous key. Lock and unlock to finish switching.”): the on-chain half already happened and is not undone, it is only this session that is still on the old key.
So the Encryption key pane is about key separation — keeping the key that reads your mail distinct from the key that signs your transactions — not about unlocking anything in the browser. Publish a random key when you want that separation, save the secret it shows you, and expect the trade above; leave the mailbox key-free, or publish the wallet-derived key, and this app reads everything. None of this arises on a development stack with no chain gateway (nothing is sealed at rest there), nor for an account with a stored mail password — see what your operator holds and the account-api reference for the wire-level contract.
Locking and signing out
Lock, beside your address at the top of the app, is how you leave: it drops the active wallet’s unlocked key and this browser’s session token, and the app falls back to the passphrase gate. The wallet stays imported for next time, and your reading key goes with the session — it only ever lived in the page.
It signs you out of your mail server in the same breath. The reading key you
signed in with sits in the server’s memory for that token’s life, and Lock
ends that session there and then —
POST /v1/auth/logout,
which drops the reading key and every summary decrypted under it, so nothing
sealed can be opened with that token again. The Lock button in the shared
wallet manager that the
Thunderbird,
Outlook and Chrome clients carry does the same
thing per wallet: locking one of two unlocked wallets there signs that one out
of the server and leaves the other alone.
It locks either way, whatever the server answered — so locking still works with the server down or the machine offline, and a sign-out that could not be delivered passes without a word rather than an alarm over a lock that did work. What your server keeps even after a clean sign-out is the other half of this, and is worth knowing: the session token is a stateless bearer credential that nothing revokes. It stops being accepted when it expires; locking removes this browser’s only copy of it, and the key that made it useful is gone from the server’s memory — but the token itself stays valid to the letter until it runs out.
Removing the stored mail password
A stored mail password is optional, and it is not a one-way door: #/settings can hand it back. The Remove the stored mail password block appears only when there is actually one stored — an account on wallet-signature login never sees it — and it opens by saying what you are trading: removing it leaves this account on wallet-signature mail login, so your mail client authenticates with the wallet-derived password instead, and you can set a stored password again whenever you like.
The gesture is the same deliberate two-step as rotation below it. Remove the stored mail password… arms the change rather than making it, and arming replaces the button with a warning and two answers: Remove it now, or Keep my stored password. The warning is the part to read twice: every mail app set up with the stored password will stop connecting until you give it a new one, and the server keeps only a hash of that password, so it can never be shown to you again. There is no recovery afterwards — a client still holding the old value has to be given a new password, either a fresh stored one or the wallet-derived one. The warning closes by telling you what happens next: your wallet signs a one-time confirmation as you continue — silently with an in-app wallet, as an approval prompt on a Phantom or Ledger one.
Removing takes effect at once. The block disappears — there is nothing left to remove — and a notice takes its place telling you where your mail clients now stand: they authenticate with the wallet-derived mail password instead, so derive that value and paste it into each one, or set a new stored password at any time. Derive mail password, in the section just below, is where that value comes from.
What a removal does not do is worth as much as what it does, and it is the mirror of the three the rotation warning heads off:
- It does not rotate the wallet-derived password. Your account’s auth epoch is untouched, so every copy of the wallet-derived password keeps authenticating exactly as before. Retiring that credential is rotation’s job; the two levers are independent, and neither implies the other.
- It does not sign you out. Your session token is untouched and stays valid to its expiry, exactly as after Lock. Only mail apps configured with the removed password need attention; this page, and any other client holding a live session, carry on.
- It does not revoke a client-certificate login.
SASL EXTERNAL proves
who you are from the certificate presented in the TLS handshake, not from any
password, so removing a stored one leaves that door exactly as it was. The
only lever on it is the operator’s
client_cert_authsetting.
It does exactly one thing: it deletes the stored secret. The account is back
where a fresh one starts, and Set password puts a new one in place whenever
you want it. Note that setting the password to an empty value is not a way
to clear one — that only declares the wallet-signature state and leaves any
existing secret in place; removal is this control, and nothing else. Underneath
it is one call,
DELETE /v1/account/password,
which takes no argument that could point it at anybody else’s account and is
idempotent — a double-click is a second no-op, not an error. The same block
rides the shared Settings pane in the Thunderbird,
Outlook and Chrome clients.
Rotating the wallet mail password
The wallet-derived mail password has no expiry of its own — it is a signature over a fixed challenge, so anyone who copied it once could use it forever. #/settings is where you take it back. Under Rotate the wallet mail password the pane shows the account’s current auth epoch — a counter mixed into the challenge your wallet signs — beside a Rotate the wallet mail password… button that arms the change rather than making it. Arming replaces the button with the warning above and two answers: Rotate it now, or Keep my current password. Rotating adds one to the epoch (the pane names the new value), which changes the bytes a valid password must sign over — so every copy of the old password stops authenticating at that instant, on every SMTP, IMAP and POP listener at once.
The gesture is two-step because the cost lands immediately: every mail app
already set up with the wallet-derived password will stop connecting until you
paste the new value into it. The warning’s last line is the other half of the
gesture: your wallet signs a one-time confirmation as you continue. Arming
the control does not spend it — the signature is asked for when you press
Rotate it now, once, for this rotation only, and a Phantom or Ledger wallet
shows its approval prompt at that moment. Declining it leaves the epoch exactly
where it was. Derive the replacement right there — Derive
mail password always signs for the epoch the pane just read — or offline with
sithbit mailbox credentials --epoch <N>,
where <N> is the epoch this pane shows.
What a rotation does not do matters as much as what it does, and the pane says all three on the warning rather than leaving you to find out:
- It does not clear a stored mail password. Rotation is scoped to the wallet-derived credential. If the account also set a stored password, that password is untouched and still logs in — this is not a “revoke every way in” button. Clearing the stored one is its own control, a section up: Removing the stored mail password.
- It does not sign you out. Your session token is untouched and stays valid to its expiry, exactly as it does after Lock. Only mail apps need re-provisioning; this page, and any other client holding a live session, carry on.
- It does not revoke a client-certificate login.
SASL EXTERNAL proves
who you are from the certificate presented in the TLS handshake, not from a
signature over the epoch, so a bump leaves that door exactly as it was. The
only lever on it is the operator’s — turning
client_cert_authoff for the listener.
The control appears only once the app has actually read your account, so it can
never offer to bump an epoch it could not see; if you are not signed in it says
so instead. Underneath it is one call,
POST /v1/account/auth-epoch,
which always acts on your own wallet and takes no argument that could point it
at anybody else’s. The same control rides the shared Settings pane in the
Thunderbird, Outlook and Chrome
clients, and the operator-hosted
enrollment page.
Balances: quotes before you buy
The Balances pane covers both directions of postage, and they are not the same thing. Sender stamps looks inbound — what a given sender holds with you, and the price you charge them. Buy stamps to send looks outbound — postage you prepay toward someone else.
On the outbound side, Quote reads the live per-stamp price for the pair in the form: the price that recipient has set for you if you already have a frombox with them, otherwise their default rate for a new sender. The same figure becomes the price ceiling your purchase is pinned to, so a repricing between quoting and buying makes the purchase refuse rather than overcharge. Buying without quoting first is still guarded — the ceiling is read again at the moment you buy either way.
Once a quote shows a frombox with stamps still in it, a Reclaim unspent button appears beside the buy buttons and returns that postage to your wallet, leaving the frombox open at the recipient’s price. It is offered only when the sender address in the form is your own wallet, since that is the only frombox the withdrawal can address — see getting unspent postage back.
The compose loop is exercised headlessly by the env-gated live suite
webclients/webmail/test/e2e-webmail.test.js — the bundle served from
a real account-api, a real send to a throwaway wallet, and the message
read back from the recipient’s INBOX (the runbook lives in
webclients/README.md).