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

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, #/settings and #/market views 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 --derive since 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_auth setting.

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