Welcome
Your inbox has never known peace. Now the fight pays you.
Email promised quiet correspondence and delivered an arms race — filters guessing at spam forever while your address, your archive, and your attention belong to someone else. SithBit doesn’t negotiate a truce. It is a decentralized mail protocol on Solana that changes the balance of power: postage paid by the sender and earned by you — roughly 90% of it lands in your wallet — addresses tied to your wallet instead of a rented domain, and mail encrypted end-to-end and stored off-chain on IPFS. No token: it runs on the SOL you already hold. Standard SMTP, IMAP, and POP3 underneath, so the inbox you already use just works.
The quiet inbox was always a lie
Email never had peace to offer — only filters guessing at spam while the volume climbed. Strip the illusion away and one real thing remains: how badly a sender wants your attention. SithBit makes that desire measurable. Every message from a stranger costs real SOL — a payment is the one thing a spammer, a phishing crew, or the AI writing their mail cannot fake.
Name the price of reaching you
Through a price you set yourself, your mailbox gains real strength. Strangers meet a default that is deliberately a wall; you lower it for the people you want to hear from, and senders you trust write for free. Anyone unwilling to pay what your attention is worth simply never lands. No filter to tune, no guesswork — just your terms.
Get paid for your own inbox
Through that strength comes power over email’s oldest bargain: postage from strangers lands directly in your wallet, compensating the attention you already spend defending it. Opt into the campaign pool and advertisers pay you postage to land in your inbox and a bounty when you reply — on topics you chose.
End spam at the source
Through better economics, a victory fifty years of filtering never delivered: filters treat the symptom, but the incentive to spam was never touched. When reaching a hundred million inboxes costs a hundred million stamps, the math that created spam finally points the other way.
An address nobody can take from you
Winning the spam fight breaks only half of email’s chains. The rest — an address that lives on someone else’s domain, an archive readable on someone else’s servers — break here. Your address is tied to your wallet; move it anywhere and keep your history and identity intact. Every message is sealed straight to your wallet and stored on IPFS, so no provider ever holds your mail in the clear — and no provider holds you.
A protocol, not a product
No gatekeeper, no single company — identity and settlement ride a network nobody owns, and anyone with a domain can run a mail server and earn 10% of the postage it carries, on the same footing as every other operator. It’s that force, not anyone’s promise, that keeps your mail free.
No conversion required
There’s nothing to convert to. Standard SMTP, IMAP, and POP3 to the letter — your existing mail client just works. No new app to learn, no migration, no ceremony.
Curious how sender-paid postage ended up solving a fifty-year-old email problem? Start with the Prelude for the story of how SithBit came to be, or jump straight to the Introduction for how the economics actually work.
Prelude: A Solution in Search of a Problem
This project began, like a surprising number of things, in lockdown.
When the world shut its doors in 2020, it handed a lot of people something they hadn’t had in years: time, and nowhere to spend it. Some baked bread. Some learned an instrument. A great many, stuck indoors and watching the markets, discovered crypto — and arrived just in time for the moment programmable blockchains grew up. Chains that could run real smart contracts were coming online, fast and cheap enough to be interesting, and they were dazzling: a genuinely new kind of computer, one no single party owned or could switch off.
They were also, for the most part, a brilliant solution in search of a problem. The technology was extraordinary. What it was for was far less clear.
Every technology waits for its killer app
That’s not a knock — it’s just how these things go. A new platform arrives years before anyone knows what it’s really for, and then one application comes along that makes the whole thing suddenly, undeniably worth having. The industry has a name for it: the killer app.
The personal computer had the spreadsheet. VisiCalc, and then Lotus 1-2-3, were so useful that people bought the hardware just to run the software — a beige box justified by a grid of numbers. The internet had one too, and it wasn’t the web browser. Years before Netscape, before anyone said “surfing,” the thing that pulled people online was email. Instant, free, global, personal. Correspondence at the speed of light. It was the internet’s first killer app, and for a long stretch it was the internet to most of the people using it.
Then someone realized you could advertise through it.
The flaw was in the economics, not the code
Email’s fatal weakness was never a bug. It was a price: sending a message costs the sender essentially nothing. That single fact — wonderful for correspondence — turned inevitably, mechanically, into spam. If reaching one more inbox is free, then reaching a hundred million of them is nearly free too, and the math only ever points one direction.
What followed was one of the great arms races of the computing era. To hold the tide back, engineers taught machines to read — to weigh words and patterns and guess, message by message, what was junk. Some of the earliest large-scale machine learning ever put into production was pointed squarely at your inbox. The decades of research and the fortunes poured into telling ham from spam seeded techniques for classifying human language at scale — an unmistakable ancestor of the models behind today’s AI revolution. Spam, of all things, helped teach the machines to understand us.
And still, after all of it, the problem was never actually solved. Filters treat the symptom. The incentive to send spam has never once been touched, and the volume climbs every year regardless. Worse, the same free-to-send flaw now carries phishing and invoice fraud measured in billions a year — and the machines the spam wars trained have learned to write the bait, so the filters that judge content are losing to content they can no longer tell apart.
The other problem: we gave it all away
Somewhere along the way a second flaw came into focus, quieter but deeper. The open internet had quietly re-centralized. A handful of companies came to own our identities, our archives, our address books — and our mail. Your address lives on someone else’s domain; your messages sit, readable, on someone else’s servers; and all of it can be revoked, mined, or discontinued at their discretion. People began calling the fix Web 3.0: a web you own rather than rent, built on exactly the ownerless computers the pandemic-era crypto boom had just made real.
The revolution came for money, for art, for identity, for storage. It never quite came for email — the web’s very first killer app, still running on infrastructure and an economic model designed in the 1970s.
Email, again
That is the gap SithBit was built to close, and it turns out the blockchain supplies the two things email always lacked. It can put a real, sender-paid, recipient-set price on a message — dismantling the economics of spam at the root instead of forever guessing at its symptoms. And it can carry identity and settlement with no company in the middle to own your address or read your mail — the Web 3.0 promise, finally delivered to the place it started.
A solution in search of a problem, meet a problem the world gave up on solving. The oldest killer app on the internet was, it turns out, waiting for the newest platform all along.
Welcome to email with postage.
Introduction
Spam isn’t a filtering problem. It’s an economics problem, and email has never fixed it. Sending mail costs a spammer essentially nothing, so even a 1-in-12,500,000 response rate still nets about $7,000 USD/day at scale. Multiply that incentive across the internet and you get the numbers today: an estimated 362 billion emails sent daily, with 45-60% of it spam. No amount of machine-learning spam detection can win that fight permanently — filters treat the symptom, and the number of spam emails keeps climbing precisely because the incentive to send it was never touched.
The same free-to-send flaw now powers something worse than junk. The FBI’s 2025 Internet Crime Report counts business email compromise — the “our bank details have changed” message — at roughly $3 billion in reported losses in a single year, and phishing as the most-reported crime of all. And the filters are losing faster than they used to: security vendors now report that most phishing mail is written by AI, with click rates that match a human expert’s. A filter judges the content of a message, and content is exactly what a machine can now make indistinguishable from the real thing.
A price is different. Postage doesn’t care how well the message is written. That’s the whole idea behind SithBit: it charges the sender, not the recipient, and lets you set the price — content-blind, so there is nothing for a better forgery to get past. And it does so without asking you to abandon email itself: the reference servers speak standard SMTP, IMAP, and POP3 to the letter, so your existing mail client just works. See Standards and RFC coverage for exactly which RFCs each server implements.
Sender-pays postage
In the earliest days of U.S. postage, letters were paid for by the recipient, and the system was so inefficient it collapsed under its own volume. Email never got past that stage — you still bear the cost of your inbox, either directly through a subscription or indirectly through the ads and data-mining that fund “free” accounts. Stamps fixed postal mail over a century ago by putting the cost on the sender instead. SithBit does the same for email, and takes it a step further: instead of one central post office setting one universal price, every mailbox owner sets their own price, per sender if they want to.
That single change is what makes spam uneconomical rather than merely harder to get away with:
- You price out spam, not guess at it. Every message to your mailbox costs the sender real SOL. A new mailbox starts at 1 SOL per message from anyone you haven’t priced yet — deliberately a wall, not a price list — and you lower it, per sender or for everyone, to whatever your attention is actually worth. Spam has to be profitable after your price, not just after a filter’s guess.
- Strangers pay; friends don’t. Each correspondent gets their own prepaid postage balance for mail to you (a “frombox” — see Fromboxes). Allow-listing someone just means funding theirs yourself: the protocol waives its fee on that purchase, and the value round-trips back to you as their mail arrives, so recognizing someone you trust is free beyond ordinary transaction fees.
- You only ever pay for mail you mean to send. As a sender, there’s no subscription and no ad-supported inbox trade-off — postage is the price of the message you chose to send, nothing more.
Get paid to be emailed
Postage from strangers doesn’t go to a provider. It goes to you.
- You keep roughly 90 % of it. When a message settles, the postage lands in your wallet; the operator whose domain carried it takes a 10 % share by protocol default (see Economics for the exact split). You’re compensated for the inbox space you were already defending, instead of paying a provider or letting them monetize your data.
- Advertisers pay you too — only if you ask them to. Publish a beacon naming topics you’ll hear about, and campaigns pay your postage to land in your inbox plus a bounty when you reply. You keep the postage whether or not you answer, and closing the beacon takes you out of the pool and returns its deposit.
- You can see what your inbox made.
sithbit earningsis the read-only snapshot of what your wallet holds and has taken in.
Paid is delivered
The sender’s side of the bargain is just as real. The big mailbox providers now reject bulk mail outright unless it clears an authentication and complaint-rate bar, and even fully compliant senders watch a share of legitimate mail land in spam. Delivery has become a reputation lottery, and a whole industry sells tickets to it.
A funded frombox is not a lottery. If the sender has postage for you, the message is delivered and recorded on-chain — there is no reputation score in the path to lose. Two things make that safe rather than a spammer’s dream:
- Reputation lowers the price for the honest, not the wall for everyone. Under reputation-scaled first-contact pricing, a wallet that has paid and behaved pays less to reach someone new; a burner wallet pays your full default. An organization can go further and attest a verified sender — prove once, by DNS, who stands behind its wallet, and pay the floor price on first contact ever after.
- Campaigns reach only people who opted in. An advertiser can quote the whole campaign before a lamport moves, and every recipient is a real, funded wallet — a bot farm costs real SOL to build.
No token
If you’ve been anywhere near crypto, you already know the pattern: a project launches its own token, and your upside depends on a team you don’t control — one that can pre-mine a chunk for themselves, unlock more supply later, or simply walk away with the liquidity. SithBit doesn’t ask you to take that bet. There is no SithBit token. Postage, stamps, and every payout described above move in native SOL — the same SOL you already hold to pay transaction fees on Solana for anything else. No mint anyone controls, no allocation for insiders to dump, nothing to “rug”: if you hold SOL, you already hold everything the protocol needs from you.
Note — only on Solana: pricing every message individually only works if the settlement layer can keep up. To support even a small fraction of global email traffic, an on-chain mail system needs to clear millions of transactions per second — throughput only the Solana blockchain provides today.
Your address is yours, and your mail is sealed
Fighting spam with filters costs you twice over. Your provider has to open and read every message to classify it, so you’re trusting a third party with your mail’s contents — and increasingly they do more than classify: regulators have fined the largest provider for advertising inside the inbox without consent.1 That provider usually also owns the domain your address lives on, so your identity on the internet is rented, not owned — and an automated “security hold” can take it away for days or months with no one to appeal to.
SithBit removes both dependencies:
- Your address is your wallet. It’s not tied to a domain someone else controls, so nobody can revoke it out from under you. It’s not permanently tied to any one domain either — you can move it to a different domain at any time, keeping your wallet, mail history, and identity intact.
- Your mail is sealed to your key. Message bodies are encrypted by the sender straight to your wallet and stored on the Interplanetary File System ( IPFS), so no central authority ever holds your mail in the clear — see IPFS storage: benefits to users. With Lockbox the seal is applied on your own device before a message leaves it, so even your operator only ever sees ciphertext. Taken together, two SithBit addresses can exchange mail using only the chain for settlement and IPFS for storage — bypassing the internet’s SMTP relay infrastructure entirely.
- We tell you exactly what’s public. The envelope — who mailed whom, when, and for how much — lives on a public ledger and is permanent. That is a real trade-off, and What’s public and private spells it out rather than hiding it.
Anyone with a domain can run one
SithBit isn’t a single proprietary email service with one company behind
it — it’s a protocol. Running the mail servers (sithbitd, the combined
SMTP/IMAP/POP daemon — see Running a mail server) takes
no special permission: authorize your own domain on-chain, point its MX
record at your server, and you’re a first-class mail operator on the same
footing as anyone else on the network.
Hosting isn’t charity, either — it’s the third profitable role in the system, alongside senders and recipients:
- You earn a cut of every message you relay. For each mail settled on a mailbox under your domain, your operator wallet collects a share of that message’s postage — 10% by protocol default (see Economics for the exact split) — so the more mail your domain carries, the more it earns, turning what used to be a pure hosting cost into a revenue line that can offset it.
- No permission needed — a DNS record and 0.01 SOL. Authorizing a domain costs 0.01 SOL paid on-chain once, verified against your domain’s DNS TXT record. No central provider decides who’s allowed to run a mail server.
- Your outbound to other SithBit users never touches an IP blocklist. Self-hosting conventional mail has become effectively gated by IP reputation: a perfectly authenticated message from a rented server range is still refused on the address it came from. Mail between SithBit mailboxes settles on-chain and is stored on IPFS, with no IP reputation anywhere in the path; the domain’s on-chain authorization replaces the gatekeeper.
- Names are assets, not rentals. A domain authorization and an alias never expire and carry no renewal fee; both can be transferred, listed, or auctioned on the name marketplace, and the seller keeps 90% of the proceeds.
Find your path
Everything above serves one of four kinds of reader. Start where you fit:
- You want an inbox that pays you — Getting started claims a mailbox in five steps; Economics traces every lamport; What’s public and private and Lockbox cover what stays yours.
- You want to reach people who chose to be reachable — Fromboxes explains stamps and the price you are quoted; Campaigns is paid outreach to an opted-in audience; Verified-sender attestation earns your organization the floor price on first contact.
- You hold a domain or a name, and want it to earn — Running a mail server and Domains for operators; Aliases and Trading names for name owners.
- You’re building on it — the
sithbitCLI is the whole protocol as typed commands; Program & PDA reference documents the three on-chain programs and every instruction; Standards and RFC coverage says what a conforming server speaks.
-
The same report describes tracking pixels and the regulator’s proposal that tracking needs a consent of its own. SithBit’s answers — a reader that never fetches the pixel, an on-chain delivery record in place of an open receipt, a reply bounty in place of an engagement score, and a beacon as separate, revocable consent to advertising — are taken point by point in Tracking pixels, consent, and the paid inbox. ↩
The Components at a Glance
From the outside, SithBit looks like ordinary email: you open a mail app, read what arrived, and write to people by address. Under the hood, four groups of pieces cooperate to make that work without a central provider:
- On-chain programs — the shared rulebook, living on Solana where anyone can read it.
- Servers — the post-office machinery that anyone with a domain can run.
- Graphical clients — the apps and extensions most people actually use.
- The command line and the console — terminal tools for developers and operators.
This page introduces each group in plain terms, then puts them together on one map. Nothing here is required reading to use SithBit — it’s the orientation tour, with links into the deeper chapters when you want more.
The on-chain programs — the shared rulebook
A blockchain “program” is a small piece of software that lives on the blockchain itself, where everyone can see it and no one can quietly change it. Think of it as a vending machine for rules: put in a correctly-formed request, and it does exactly what its published rules say — the same for everybody, every time.
SithBit is built on three such programs, running on Solana:
- The mail program is the heart of the protocol. It keeps the record of every mailbox and its prices, holds each sender’s prepaid postage balance (a frombox), and moves the SOL when a message is delivered — sender pays, recipient collects. It also hosts the postoffice, the one shared account that records protocol-wide settings.
- The alias program maps human-readable names
(aliases) to wallet addresses, so mail
can go to
pat@example.cominstead of a long string of characters. - The domain program keeps the registry of domains whose owners have proven, via DNS, that they control the name — and runs the marketplace where aliases and domains are bought and sold.
Two things make this arrangement matter. First, everyone consults the same three programs — every server and every app, no matter who runs it, reads and writes the same shared record, which is what makes SithBit a protocol rather than a company. Second, your messages are not on the blockchain: the chain holds only small delivery records and balances, while the message body itself is encrypted by the sender and stored on IPFS, a distributed storage network. What’s visible to whom is laid out in What’s public and private.
The servers — the machinery an operator runs
The on-chain programs can’t answer a mail app’s call on their own — somebody has to speak the languages your mail apps already know. That somebody is a mail operator: anyone who owns a domain, authorizes it on-chain, and runs the SithBit services. The full set is described in Running a mail server; the short version:
-
sithbitd, the mail daemon, is the big one. It accepts and delivers mail (SMTP), lets your apps read it ( IMAP and POP), and runs the behind-the-scenes workers that encrypt each message, store it on IPFS, and settle its postage on-chain. account-apihandles sign-in and account settings — you prove you own your wallet, it hands your apps a session.mail-grpcis the operator’s gateway to the three on-chain programs: postage checks, delivery records, name lookups.domain-sithbitverifies domain ownership against DNS so a domain can be authorized on-chain.sithbit-ipfsdandsithbit-gatewayare the IPFS storage plumbing — a shared storage node for a fleet of servers, and a read-only window for fetching stored mail over plain HTTP.
The crucial point for a newcomer: operators are interchangeable. Because prices, balances, and delivery records live in the on-chain programs rather than inside any operator’s database, no operator owns your identity or your mail. Operators compete to carry traffic — and earn a share of the postage on every message they relay.
The graphical clients — where most people live
Most people never touch a terminal or a server, and never need to. SithBit ships a family of GUI clients:
- The webmail app — a full mail client in your browser, installable as an app on desktop and phone.
- The Chrome extension, the Outlook add-in, and the Thunderbird extension — the same account tools, embedded in the mail software you may already use.
All of them run one shared core, so a screen behaves identically wherever you meet it — and everything that must be signed with your wallet key is signed on your device. The server only ever relays already-signed requests; it never holds your key. That shared core also includes the guided first-run wizard that creates or imports a wallet and claims your mailbox, and the browser name marketplace for buying and selling aliases and domains.
And because the servers speak standard mail protocols to the letter, any ordinary mail app works too: point your favorite client at a SithBit server over IMAP or POP and read your mail with no SithBit software installed at all. The GUI clients earn their place with the parts standard apps can’t do — wallet sign-in, pricing your senders, and trustless reading, where your browser checks the chain and IPFS directly instead of taking the server’s word for it.
The command line and the console — power tools
Two terminal tools round out the set, for the people who want them — typical users never need either:
- The
sithbitCLI is the developer’s tool: the full protocol surface as typed commands. It talks directly to the on-chain programs — no SithBit server required — so everything from claiming a mailbox to pricing a sender to sending and reading mail can be done from a terminal. If you’re building on SithBit, this is your surface: the whole protocol is three small programs with documented instruction semantics, reachable from the CLI, its C library, the gRPC gateway, or WebAssembly in a browser — paid, spam-proof messaging between any two wallets, with standard email on the other end. - The
sithbit-consoleTUI is the operator’s dashboard: a keyboard-driven console for inspecting accounts and mailboxes, watching the mail queues, and unsticking failed jobs. It talks only to the account API’s admin surface, never to the operator’s database directly.
How the pieces fit together
Put the four groups on one map and the shape of the protocol appears. Follow a single message through it:
- You write mail in a client — a GUI client or any standard mail app —
and it hands the message to your operator’s
sithbitdthe same way email always has. - The servers do the protocol work:
sithbitdchecks throughmail-grpcthat the sender has postage for you, encrypts the body so only you can read it, stores it on IPFS, and records the delivery on-chain — the moment your postage is collected. - The chain and IPFS hold the truth: the mail program’s record says a message exists and was paid for; IPFS holds the sealed body. Any operator’s servers — or none at all — can show it to you from there.
- You read it in whatever window you prefer: webmail, your usual mail app over IMAP or POP, the CLI straight from the chain, or trustless webmail verifying every step itself.
The division of labor, in one breath: the programs are the rulebook, the servers are the machinery, the clients are the windows — and because the rulebook is shared and public, the machinery is replaceable and the windows are many. Ready to go deeper? Start with Addresses for the core concepts, or jump straight to Getting started.
Standards and RFC coverage
SithBit reinvents email’s economics — sender-paid postage settled on-chain, addresses you own outright — but it deliberately reinvents almost nothing about the wire. Your existing mail client already speaks SMTP, IMAP, and POP3, and SithBit’s servers speak them back, to the letter of the specifications that have carried email for four decades. That is the whole point: you get a postage economy that finally works — spam, phishing, and deliverability priced instead of guessed at — without throwing away Thunderbird, Outlook, Apple Mail, or the mobile client already on your phone. Keep your client; change only who pays.
Standards-completeness matters more for email than for almost any other protocol. Email’s interoperability is adversarial — a message crosses servers written by strangers, in languages you’ll never see, and a single misread reply code or dropped capability turns into a silently lost message or an open relay. So the reference servers don’t implement “enough of” each protocol to pass a smoke test; they implement the published grammar, advertise exactly the capabilities they honor, and reject out-of-sequence commands structurally rather than hoping clients behave. Where a specification is only partially implemented, this page says so plainly in the Notes column — no overclaiming.
The tables below enumerate every RFC each SithBit
server and subsystem implements, grouped by the component that owns it. Each links to the canonical
text at the RFC Editor. If you are
weighing a from-scratch server or an existing MTA
against simply running sithbitd, this is the coverage
you’d be matching — and if you are building a product on the protocol rather
than a server, it is the guarantee that whatever you build talks to every
mail client already in the world.
SMTP — sending and relaying mail
The SMTP surface is the sans-io smtp_session state machine (the wire
grammar and its extensions) driven by the smtp_server binary (STARTTLS,
SASL, delivery, and the SithBit sender-authentication policies). Enhanced
status codes are structural: the class digit is derived from the reply code so
a mismatch is unrepresentable.
| RFC | Title / feature | Notes |
|---|---|---|
| RFC 5321 | Simple Mail Transfer Protocol | Core command sequencing and reply codes; null sender and postmaster envelope forms. |
| RFC 3463 / RFC 2034 | Enhanced mail system status codes | ENHANCEDSTATUSCODES; class digit derived from the reply code. Every emitted code is listed in the RFC 5248 IANA registry. |
| RFC 4954 | SMTP authentication (AUTH) | Submission mode requires AUTH over TLS. |
| RFC 3207 | Secure SMTP over TLS (STARTTLS) | Pre-TLS buffer discard per §4.2/§6. |
| RFC 6152 | 8-bit MIME transport | 8BITMIME. |
| RFC 3030 | Chunking and binary MIME | CHUNKING/BDAT, BINARYMIME. |
| RFC 1870 | Message size declaration | SIZE. |
| RFC 2920 | Command pipelining | PIPELINING; inherent to the sans-io design. |
| RFC 3461 | Delivery status notification parameters | DSN: RET/ENVID/NOTIFY/ORCPT carried on the envelope. |
| RFC 3464 | Delivery status notification report format | Failure DSNs generated by the relay on exhausting the retry schedule. |
| RFC 6522 | The multipart/report media type | Container for both DSN and ARF reports. |
| RFC 7505 | Null MX (no-service resource record) | A null MX is the domain’s standing “no mail, ever”: the relay refuses such recipients outright instead of dialing. |
| RFC 7504 | 521/556 “server does not accept mail” reply codes | Null-MX refusals bounce with 556 and enhanced status 5.1.10 (§2.2); the client send machine classifies both codes as permanent. Never emitted by the server session — a SithBit node that accepts no mail simply runs no listener. |
| RFC 3848 | ESMTP transmission types | with keywords in the Received: trace header. |
| RFC 8601 | Authentication-Results header | Records SPF/DKIM/DMARC verdicts on accepted mail. |
| RFC 7208 | Sender Policy Framework (SPF) | Inbound-relay sender authentication (via the adopted mail-auth). A published hardfail rejects at MAIL FROM with 554 and enhanced status 5.7.23, the code RFC 7372 §3.2 registers for exactly this outcome; anything short of Fail lands in Authentication-Results instead. |
| RFC 6376 | DomainKeys Identified Mail (DKIM) | Verified inbound; signed once on outbound spool entry. The RFC 8301 algorithm floor holds on both sides: signing is rsa-sha256 only by construction, and a verified rsa-sha1 signature is downgraded to failure before it reaches reporting or DMARC input. RFC 8463 ed25519-sha256 signatures verify, and outbound entries can opt into dual-signing. See the conformance appendix for the full posture. |
| RFC 9989 | Domain-based Message Authentication (DMARC) | Full evaluation and disposition: alignment folded by the §4.10 DNS tree walk, p=reject/p=quarantine, the subdomain policies sp=/np=, and test mode t=y. Enforcement is all-or-nothing — 9989 retires the pct= sampling tag, and a published pct= is inert. The §5.3.1 From-extraction termination cases (zero or multiple differing author domains) produce no verdict — see the conformance appendix. Obsoletes RFC 7489. Selectable via sender_auth = "dmarc". |
| RFC 9990 | DMARC aggregate reporting | §3.1 aggregate (rua) reports — gzip XML in the urn:ietf:params:xml:ns:dmarc-2.0 namespace, one per policy domain per interval, with the §4 external-destination check enforced on every target. Ingesting other operators’ reports is an optional operator feature; both dmarc-1.0 and dmarc-2.0 report bodies are accepted. |
| RFC 9991 | DMARC failure reporting | §2 failure/forensic (ruf) reports — one per DMARC failure, headers-only by default per the §7.1 exposure guidance, with the §5 external-destination check enforced on every target. |
| RFC 5965 | Abuse Reporting Format (ARF) | message/feedback-report body of each forensic report. |
| RFC 6591 | Authentication-failure reporting via ARF | The auth-failure report emitted per DMARC failure. Sent only where solicited, per the RFC 6650 applicability statement: ruf= targets are addresses the policy domain itself published, and out-of-domain targets must additionally pass the external-destination check. |
| RFC 6692 | Source ports in ARF reports | Source-Port in each forensic report — the SMTP peer’s TCP source port, captured at accept and emitted alongside Source-IP; an unknown port omits the field rather than reporting 0. |
| RFC 7435 | Opportunistic security | Best-effort TLS posture for MX-to-MX relay. |
IMAP — reading and managing mail
The IMAP surface is the sans-io imap_session semantics core over the typed
imap-types AST, driven by imap_server over the imap-next flow layer. The
baseline is IMAP4rev1; the rev2 extensions SithBit implements are advertised
individually rather than by claiming rev2 as a whole.
| RFC | Title / feature | Notes |
|---|---|---|
| RFC 3501 | IMAP4rev1 | States, mailbox semantics, and the full command set. |
| RFC 2177 | IDLE | Push notification of mailbox changes. |
| RFC 6851 | MOVE | Atomic message move. |
| RFC 3691 | UNSELECT | Close a mailbox without expunging. |
| RFC 5161 | ENABLE | Capability negotiation. |
| RFC 2342 | NAMESPACE | A single fixed personal namespace. |
| RFC 4315 | UIDPLUS | UID EXPUNGE, APPENDUID/COPYUID response codes. |
| RFC 7888 | Non-synchronizing literals (LITERAL-) | Framing lives in the imap-next driver; the capability is advertised here. |
| RFC 7889 | APPENDLIMIT | APPENDLIMIT=<n> in §2’s form (a): one ceiling for every mailbox, carried in the capability name itself, advertised both before and after authentication. The value is the enforced one — imap.max_message_size (25 MiB by default), the same setting the driver caps literals with. Partial: the per-mailbox form and its STATUS (APPENDLIMIT) item are not implemented, and §4’s [TOOBIG] response code is met only in prose, and only on one of the two literal forms: an oversize synchronizing literal draws a tagged BAD whose text names TOOBIG and the ceiling it exceeded, but unbracketed — human-readable, not the machine-readable resp-text-code §4 asks for — while an oversize non-synchronizing (LITERAL-) literal draws no TOOBIG, no ceiling and no tagged response at all. Both are limits of the adopted imap-next flow layer — see the conformance appendix. |
| RFC 5530 | IMAP response codes | Extended NO/BAD response codes for precise failure signalling. |
| RFC 4959 | SASL initial client response | One-round-trip AUTHENTICATE; SASL-IR is advertised wherever the AUTH= mechanisms are. |
| RFC 6154 | SPECIAL-USE mailbox attributes | Tier 1: a name heuristic marks the well-known top-level names — Sent, Trash, Drafts, Junk (also Spam), Archive — with their \Sent-style attributes in LIST responses, case-insensitively. The LIST (SPECIAL-USE) selection filter and CREATE-SPECIAL-USE are not supported. |
| RFC 7162 | CONDSTORE / QRESYNC | CONDSTORE implemented; QRESYNC queued as its own follow-up wave (its SELECT parameters and VANISHED are refused). Mod-sequences are tracked per message on every store backend: ENABLE CONDSTORE, SELECT/EXAMINE (CONDSTORE), STATUS (HIGHESTMODSEQ), SEARCH MODSEQ, the MODSEQ fetch item and CHANGEDSINCE modifier, and STORE UNCHANGEDSINCE with the MODIFIED response code. The §3.1.4.1 follow-up untagged FETCH after a non-PEEK fetch’s implicit \Seen persist is sent too — see the conformance appendix for the echo’s shape and the one known wire deviation. |
| RFC 2595 / RFC 8314 | TLS for IMAP | LOGINDISABLED until the connection is protected; implicit TLS is the primary deployment. |
| RFC 9208 | QUOTA | GETQUOTA/GETQUOTAROOT over one per-wallet quota root (the conventional ""), advertised as QUOTA QUOTA=RES-STORAGE. The root exists exactly while max_wallet_bytes sets a cap — an unbounded account (the default) reports no roots. SETQUOTA parses but is always refused, and QUOTASET is never advertised: quota limits are operator configuration. Deliberate deviation: STORAGE usage is the enforced, per-blob-deduplicated stored-bytes meter — the very number [OVERQUOTA] refusals are measured against — not the per-copy message-size sum a client computes from its own folder listings; units are 1024-octet blocks, usage rounded up and the limit rounded down. See the conformance appendix for the reconciliation caveat. |
| RFC 9051 | IMAP4rev2 | Not advertised. rev1 is the baseline; the rev2 extensions above are advertised individually. Of the extensions rev2 folds into its base (Appendix E), NAMESPACE, UNSELECT, UIDPLUS, ENABLE, IDLE, SASL-IR, MOVE, LITERAL-, the RFC 5530 response codes and the SPECIAL-USE attribute list are implemented; ESEARCH, SEARCHRES, LIST-EXTENDED, LIST-STATUS, the FETCH side of BINARY, STATUS SIZE/STATUS DELETED, 64-bit sizes and the CLOSED response code are deferred behind the parser fork. CONDSTORE/QRESYNC (RFC 7162, above) are not part of rev2 — it borrows only their CLOSED response code — so they are extensions on top of either baseline. Rev2 keeps STARTTLS and LOGINDISABLED mandatory (§6.1.1); implicit TLS is the RFC 8314 recommendation, not a rev2 requirement. |
POP3 — simple mailbox download
The POP3 surface is the sans-io pop3_proto typestate machine driven by
pop_server. Commands invalid for the current session state are
unrepresentable rather than merely rejected at runtime.
| RFC | Title / feature | Notes |
|---|---|---|
| RFC 1939 | Post Office Protocol version 3 | The AUTHORIZATION → TRANSACTION → UPDATE state machine, plus APOP (§7). |
| RFC 1957 | Observations on POP3 implementations (updates RFC 1939) | Its one server-side recommendation — real clients depend on the optional UIDL command, so provide it — is implemented in both forms: the whole-maildrop listing and the per-message query, advertised via the UIDL capability. |
| RFC 2449 | POP3 extension mechanism | CAPA, capability limits, extended response codes. |
| RFC 2595 | TLS for POP3 | STLS with pre-TLS buffer discard. |
| RFC 5034 | POP3 SASL authentication | The AUTH command. |
| RFC 3206 | POP3 SYS/AUTH response codes | AUTH-RESP-CODE. |
| RFC 6856 | POP3 support for UTF-8 | UTF8, and LANG in both AUTHORIZATION and TRANSACTION states. |
Authentication (SASL)
SASL mechanisms are shared by all three protocol servers through
server_common. Wallet-signature SASL PLAIN is verified against the connecting
address with no stored secret; CRAM-MD5/APOP serve clients limited to
challenge-response.
| RFC | Title / feature | Notes |
|---|---|---|
| RFC 4422 | SASL framework | Mechanism-neutral hooks shared across SMTP/IMAP/POP. |
| RFC 4616 | SASL PLAIN | The primary wallet-signature mechanism. |
| RFC 2195 | CRAM-MD5 | Challenge/response for clients without PLAIN-over-TLS. |
| RFC 4422 §5.1 | SASL EXTERNAL | Identity proven at the TLS layer by a client certificate. |
The LOGIN mechanism (widely deployed, never standardized as an RFC) is also supported for legacy clients. DIGEST-MD5 and NTLM are deliberately dropped.
Message format and abuse-report handling
Message parsing and generation are adopted rather than hand-rolled — every
consumer names one set of versions through mail_message, which itself owns
only the RFC 5321 envelope layer (source routes, the null reverse-path, the
domainless postmaster recipient) that the format crates don’t model.
| RFC | Title / feature | Notes |
|---|---|---|
| RFC 5322 | Internet Message Format | Header and message parsing/generation. Group syntax in From:/Sender: parses per RFC 6854; every generated message carries a singleton mailbox. |
| RFC 2045–2049 | MIME (parts 1–5) | Structure, media types, encodings. |
| RFC 2047 | MIME encoded-words | Non-ASCII header field values. |
| RFC 2231 | MIME parameter value extensions | Continuations and charset/language in parameters. |
| RFC 6531 | Internationalized email addresses (SMTPUTF8) | Validated in addr-spec parsing (email_address). |
Anti-abuse and transport hardening
Connection-level abuse policy, plus the TLS posture shared by all three
servers (through the server_common acceptors) and the outbound relay.
| RFC | Title / feature | Notes |
|---|---|---|
| RFC 5782 | DNS blocklists (DNSBL) | Address-reversal query conventions for the connection blocklist. |
| RFC 7817 | Updated TLS server-identity check for email protocols | On the stack’s one email TLS client — the outbound relay under strict verification — the verified name is the connected MX host or smarthost, never the recipient domain, matched against DNS-ID subjectAltNames with no CN fallback. |
| RFC 8996 | TLS 1.0 and 1.1 deprecated | Neither version can be negotiated anywhere: every acceptor and connector rides rustls, which has never shipped TLS ≤ 1.1. |
| RFC 8997 | TLS 1.2 as the minimum version for email | The ≥ 1.2 floor is asserted rather than inherited: test fences hold it at the shared acceptors and the outbound connector, and the spooler’s HTTPS report fetchers pin the same rustls backend in code. |
Beyond these wire standards, the on-chain layer is where SithBit’s own
contribution lives: the MailInstruction ABI, PDA derivations, and sealed-box
encryption described in the Program & PDA reference
and How sealed-box encryption works. Any server
that speaks the RFCs above and those on-chain conventions is a first-class
participant — see
Protocol conformance for custom mail servers.
Addresses
An “address” is the public key portion of a Solana cryptographic key pair.1 The public key is typically represented as a case-sensitive string about 45 characters in length .2
For example: 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q03.
Every SithBit client can create a key pair (also known as a “wallet”) for you: the getting-started wizard in your browser creates or imports one on its first step, and each of the GUI clients offers the same on first run.3
Once you create a keypair, the public key can serve dual purposes: it can be the source or target of cryptocurrency coin transfers, but can also act as an RFC822-compliant internet email address.
By default, a public key is not associated with an email domain until you create a mailbox for it — the getting-started wizard claims one in a single signed transaction, and every GUI client embeds the same wizard — or update its existing mailbox from the client’s Mailbox pane.4 Once you associate the public key with a domain, it becomes the case-sensitive “local” portion of the RFC822-compliant email address (i.e., the part of the address before the ‘@’ symbol, which separates the domain portion of the address).
For example: 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q03@sithbit.com
A domain is only required when email is routed through SMTP, but without a domain, emails can only be sent directly through the program, where both the “from” and “to” addresses are public keys without a domain portion of the email.
Trusting a “from” address
The “to” side of an email is always an on-chain wallet address, but the
“from” side doesn’t have to be — it’s an arbitrary off-chain
RFC822-compliant
string (e.g. jane_doe@sithbit.com), which raises the obvious question:
what stops anyone from sending mail claiming to be any “from” address they
like?
- Only a recipient’s own domain authority can deliver into their
mailbox. Every
SendMailinstruction requires the transaction signer to be the active, registered authority for the recipient’s (“to”) domain — the same wallet named in that domain’s on-chainMailDomainaccount. The comparison is part of the instruction itself, not an operator policy anyone can relax: a signer that doesn’t match is rejected (IllegalAuthority) and the whole transaction fails. This is not a check on the “from” domain — it’s what stops a stranger from injecting mail directly into someone else’s mailbox at all, regardless of what “from” address they claim. - Domain authority is DNS-gated, not self-asserted. A domain’s
authority is set either by the postoffice’s delegate directly, or (self-service)
by the
domain-sithbitservice after it verifies a_solana.authority.<domain>DNS TXT record matches the claimed key — see Create a domain. Controlling a domain on-chain requires controlling that domain’s DNS, the same trust root traditional email anti-spoofing and anti-phishing defenses (SPF5/DKIM6) relies on. - A domain-less from address must match the signer’s own wallet. If
the recipient address has no
@domainat all (a direct, unrouted on-chain send), the program instead requires the signer to be the exact address named in the “from” field — so a bare public-key “from” can only ever be sent by its own keypair. There is no relaying at all in this case. - One MX relay signs as one set of domains. The gRPC gateway that
submits
SendMailon behalf of an MX server signs every transaction with a single configured keypair, so a given mail server deployment can only deliver into mailboxes on the domain(s) it actually holds the authority key for — it can’t inject mail into a domain’s mailboxes that it doesn’t operate. - The “from” domain itself is not independently re-checked on-chain.
Once a signer clears the checks above, the “from” string it submits is
otherwise free-form (format-validated for length only). The chain
trusts that the recipient’s own domain operator already verified the
claim — which is exactly what
SPF/DKIM/DMARC7 do, off-chain, before that
trusted operator ever submits
SendMail. An operator who has misconfigured that off-chain authentication — or turned it off entirely — is the actual point of failure for “from”-domain spoofing, not the on-chain program. - Postage adds economic friction on top. Every send burns a stamp
from a frombox keyed on
(from string, recipient wallet), priced by the recipient — see Economics. This doesn’t block spoofing by itself, but it means even a fabricated “from” string costs the actual sender lamports, at a price the recipient controls. - Inbound SMTP mail is checked with SPF/DKIM/DMARC before it ever reaches the chain pipeline — see Configuration for the operator settings that govern how strictly it is applied. This is the layer that actually authenticates a “from” domain’s claim; the chain only enforces who may deliver to a given recipient.
- Casing can’t be used to dodge any of the above. Domain names are
lowercased both when a
MailDomainaccount is created and every time one is looked up. Each operation that touches a domain — the delivery authority check, plus creating, transferring, deactivating and closing a domain — lowercases the name independently, so there is no path that skips the normalization and no caller who can opt out of it.SithBit.Comandsithbit.comare therefore the same account, and there’s no shadow-domain trick via casing. A frombox’s “from” key is normalized the same way — the domain half of a domain-qualified address is lowercased — so pricing set forjane@sithbit.comalso applies toJane@SithBit.Com. Bare wallet addresses are deliberately left case-sensitive, since a base58 pubkey’s case is part of its identity, not a stylistic variation.
What’s not enforced on-chain is the authenticity of the “from” domain itself, and the local part under any authorized recipient domain: a domain’s authority can write any “from” string it likes for mail delivered to its own mailboxes, the same trust a real domain’s mail server holds for traditional email — the protocol’s guarantee is that only a recipient’s own trusted operator can deliver to them, not that every “from” claim has been independently re-verified.
Since public key addresses usually appear as long random strings that are hard to remember, it is also possible to create one or more friendly aliases for an address via the alias program — the getting-started wizard claims your first handle as part of onboarding.8 Aliases are globally-unique identifiers for public key addresses. Once created, they represent public keys across all domains.
Instead of using 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q03@sithbit.com, you might create the alias myalias, and use myalias@sithbit.com as your public email address.
Unlike public key addresses, aliases are case-insensitive. So myalias@sithbit.com, MYALIAS@SITHBIT.COM, and MyAlias@Sithbit.com are all aliases for the same public key address.
-
A keypair (or “wallet”) in the Solana blockchain is specifically a public key and its associated private key derived from the ED 25519 elliptic curve. ↩
-
The 32 bytes of the key are base58 encoded to produce the string. ↩
-
Power users can also create one from the terminal with the SithBit CLI — see Creating a wallet. And since ED25519 key pairs are created by a publicly well-known algorithm used by multiple blockchains, there are actually many tools that can create them — including the Solana CLI’s own
solana-keygen, if you already have it installed. ↩ -
From the terminal: Create a mailbox and Update a mailbox. ↩
-
For the formal specification, see RFC 7208 (Sender Policy Framework); dmarc.org’s overview explains how SPF, DKIM, and DMARC work together in practice. ↩
-
For the formal specification, see RFC 6376 (DomainKeys Identified Mail). ↩
-
For the formal specification, see RFC 9989 (Domain-based Message Authentication, Reporting, and Conformance), which obsoletes the earlier RFC 7489; its reporting halves are specified separately, in RFC 9990 (aggregate,
rua) and RFC 9991 (failure,ruf). dmarc.org also has practical deployment guidance. ↩ -
Registering additional aliases (and transferring them) is CLI territory today — see Create an alias. ↩
Mailboxes
What is a mailbox
A mailbox is an account on Solana that stores email settings for an address. An address must have an associated mailbox created before participating in the email system, and an address can only have one mailbox.
Every GUI client shows a mailbox’s settings in its Mailbox pane — see the webmail, Chrome, Thunderbird, or Outlook client pages.1
Mailbox settings
A mailbox holds the following settings:
- Mail count. The total number of emails sent to the mailbox since it was created.
- Default Postage. The default price of a stamp in lamports to send an email to the mailbox. When you create a new frombox, this value is used initially for the stamp price for the frombox unless otherwise specified. New mailboxes start at 1 SOL: deliberately a wall that keeps spam, phishing, and every other free-to-send abuse out, which you then lower — per sender, or for everyone — to what your attention is actually worth. Strangers pay it and you keep roughly 90%; senders you fund yourself write for free.
- Domain. The program address that represents the internet domain associated with the mailbox. You can change the domain for your account at any time, but in order for email to be properly routed to your mailbox, the owner of the domain must have registered the domain with the email program. In addition, the domain must host MX servers that support the email program protocol.
- No IPFS. Whether the owner has opted out of public IPFS body storage — see IPFS storage: benefits to users. When enabled, delivered bodies are kept in the operator’s own store and the on-chain message carries a local-only marker instead of a fetchable CID. Defaults to disabled.2
- Funder. The wallet that paid the mailbox’s rent: yourself on a normal create, or the sponsoring domain authority on a sponsored create. When the mailbox is closed (every GUI client has a Close-mailbox action3), its rent refunds to the funder — and only ever the rent, because a mailbox never accumulates earnings (stamp value settles from the sender’s frombox through the message account to you on delete).
Creating a mailbox also claims an alias
Creating a mailbox does one more thing besides writing these settings: it automatically registers your wallet’s own address string as a self-alias pointing back at you. That claim is a namespace reservation: aliases are one global, case-folded namespace, and the self-alias takes your address’s spelling out of it so nobody else can ever hold a name that reads as your wallet. It is not what routes your mail — every SithBit resolver treats a string that is a wallet address as that wallet before it consults the alias registry at all, so an alias registered under an address’s spelling never redirects mail sent to the address. See Aliases for the resolution rule from the alias side, including why most users never need to register an alias by hand.
Sponsored mailboxes: a domain provisions for its users
Normally the wallet that signs a mailbox create is the wallet the mailbox belongs to. A domain’s on-chain authority may additionally provision a mailbox for a different owner — an employer setting up receiving mailboxes for its staff’s wallets under the corporate domain before those wallets have ever transacted. Three guards keep this from becoming a spam or squatting vector:
- Only the named domain’s registered on-chain authority may pay for another owner’s mailbox; anyone else is refused.
- The new mailbox’s default postage is forced to the 1-SOL spam floor, whatever the sponsor asked for — a sponsor cannot open cheap send channels into a stranger’s mailbox. The owner lowers prices for known senders afterwards, exactly as with a self-created mailbox.
- No self-alias is bundled (the sponsor must not squat the owner’s address string); the owner claims their own alias when they first act.
The sponsor is recorded on-chain as the mailbox’s funder, and closing the mailbox refunds its rent to the sponsor rather than the owner. In every other way the mailbox belongs wholly to the owner from the moment it exists: the sponsor keeps no control over it.4
Opting out of IPFS storage
A mailbox’s no-IPFS setting (above) is off by default, meaning delivered bodies are pinned to public IPFS and the on-chain message carries a fetchable CID. Turning it on is a deliberate trade: your operator keeps the sealed body in its own store instead, and decentralized clients like the trustless viewer can no longer read it — availability then depends entirely on that one operator. See IPFS storage: benefits to users for the full trade-off, and Create a mailbox or Update a mailbox for how to set it.
Mail is sealed to your wallet by default
Mail bodies are encrypted before they are pinned to IPFS, so that only the mailbox owner can read them, and this needs no setup at all by default. A wallet address is an Ed25519 public key, and its X25519 twin is a valid encryption key: mail servers seal every message straight to the recipient’s wallet address as a libsodium sealed box — no published key, no key account, no rent. See Appendix: How sealed-box encryption works for a full walkthrough of the protocol, with diagrams. Decrypting is just a matter of having the wallet keypair file.
Hardware and browser wallets are the exception: they only sign, and never reveal the secret needed to derive the wallet’s decryption key. Such users instead generate a delegated X25519 keypair client-side and publish its public key on-chain as an optional mailbox setting; mail servers then seal to the published key instead of deriving one from the wallet address, and the delegated key file (not the wallet keypair) opens the mail. Every GUI client’s Encryption-key pane publishes, rotates, and clears the delegated key — see the Chrome or Outlook client pages.5
-
From the terminal: Looking up a mailbox. ↩
-
Set it at create or update time from the terminal: Create a mailbox or Update a mailbox. ↩
-
From the terminal: Close a mailbox. ↩
-
Sponsors are typically domain operators scripting provisioning — see Create a mailbox — sponsored creation for the command. ↩
-
From the terminal: Mailbox keys. ↩
Fromboxes
What is a frombox
A frombox is an on-chain account for one (sender “from” address, recipient “to” wallet) pair. It holds a prepaid balance of “stamps” and the postage price of a single stamp — sending one email always costs exactly one stamp, so this is the per-email price for that particular sender writing to that particular recipient. Where a mailbox is 1:1 with a recipient, a frombox is 1:1 with a specific sender-recipient relationship — the same mailbox owner can have a different frombox (and a different stamp price) for every sender who writes to them.
Every GUI client’s Balances pane shows your fromboxes’ prices and stamp balances — see the webmail, Chrome, or Outlook client pages.1
Default postage and pricing
New, never-before-seen senders don’t have a frombox at all: the recipient’s mailbox default postage (also a per-stamp price) applies instead, and that default is normally set high specifically to price out spam from unknown senders. Once a recipient knows and trusts a sender, they create a frombox for that sender and set a lower, negotiated stamp price.
The default is not entirely one-size-fits-all, though: a sender wallet with a real on-chain track record — cumulative postage spent reaching other recipients, or a verified-sender attestation from its domain — pays a scaled share of the default at first contact, down to a tuned floor. Spam economics are unchanged (a burner wallet has no record and pays full price), and a price the recipient sets is never scaled — see Reputation-scaled first-contact pricing.
The anti-spam lever
A frombox’s per-stamp price — the required_postage a sender pays for each
email — is the recipient’s core lever against spam and phishing alike: a
price is content-blind, so there is nothing for a better-written forgery to
get past. A freshly created frombox inherits the recipient’s mailbox’s high default price (1 SOL on a new mailbox — a wall, not a
going rate), and the recipient can drop it for a sender they trust, or
raise it — even above the mailbox’s own default — for one who has become a
nuisance. Friends are free: when the recipient funds a correspondent’s
frombox themselves, the per-stamp protocol fee is waived
and the value round-trips back as that correspondent’s mail arrives.
Only the recipient can change this price: the update must be signed by the recipient (“to”) keypair, and the on-chain program checks that signer against the mailbox owner, so a sender can never discount their own postage.
That same ownership underpins an owner exception: normally opening a brand-new, empty frombox with zero stamps is refused (see the prepayment rule below), but the recipient may set a price for a sender’s frombox before that frombox — or any purchase from the sender — exists at all. Because the signer setting the price is the mailbox owner, the on-chain guard allows this one stampless create; a third party can never open an empty frombox this way. The frombox starts at the recipient’s chosen price with zero stamps, so the sender still can’t reach them until it holds at least one stamp, funded by either side.
Reprice a sender from your client’s Balances pane — see the webmail or Outlook client pages.2
Prepaying with stamps
Stamps are the currency of delivery: sending one email always burns exactly one stamp, so a frombox with no stamps left can’t receive mail from that sender until it is topped up. Buying stamps prepays postage into a frombox, creating the frombox on the spot if it does not exist yet: a newly created frombox always inherits the recipient’s mailbox’s current default postage as its per-stamp price — the deliberately high value that prices out spam. Buying stamps never sets a custom price on its own; giving a trusted sender a cheaper rate is a separate step, done either afterwards or up front, as described in the anti-spam lever above.
This first-purchase behavior is bounded by the prepayment rule: a third party — a sender funding their own frombox to someone else, or anyone other than the recipient — must buy at least one stamp on that first purchase. An on-chain guard refuses a zero-stamp create from a non-owner payer, so a spammer cannot litter the chain with empty fromboxes for free — for anyone but the recipient, opening one always costs at least one stamp of postage. The only way to open a stampless frombox is for the recipient to do it while setting the price, as described above.
Buy stamps from your client’s postage form — the webmail Balances pane, the compose card’s inline prepay when a send is refused for stamps, or the self-service funding page a refusal reply links you to.3
The price you are quoted is the price you pay
A recipient can reprice a sender at any time, so the rate you were shown is not guaranteed to still be the rate when your purchase lands. Every purchase therefore carries a price ceiling: the per-stamp figure the client quoted you. If the price has moved above it in the meantime, the program refuses the purchase instead of charging you the new rate, and you can re-quote and decide again.
The ceiling is read fresh at the moment you buy, not when the quote was displayed — so it holds even if you left the page open, and it applies whether or not you asked for a quote first. It covers only the per-stamp postage; the protocol fee, the settlement surcharge and the one-off account rent are set by the protocol rather than the recipient, and cannot move under you the same way.
Getting unspent postage back
Prepaid postage is a deposit, not a payment: until a stamp is actually spent on a delivery, the lamports behind it are still sitting in the frombox. Two people can take them back out, and which one you are decides how.
The recipient can close the frombox outright, which reclaims the whole balance — the rent they put up plus whatever stamp value is left. That is their remedy against a sender who stockpiled cheap stamps before a price rise.
The sender can withdraw their own unspent postage without disturbing the account, provided the frombox is keyed on their wallet address rather than an email address. The stamp count drops to zero, the lamports return to the sender’s wallet, and the frombox stays open at the price the recipient set. A frombox keyed on an email string hashes text no wallet key can reproduce, so it has no sender-side withdrawal and stays the recipient’s to close.4
Withdraw from the webmail Balances pane: quote the recipient you prepaid, and a Reclaim unspent button appears beside the buy buttons once the frombox is shown to hold stamps. The button is offered only when you are sending as your own wallet address, because that is the only frombox the withdrawal can address — send as an alias or an email address and it is simply not there, rather than there and failing.
Because the recipient’s close still sweeps any residual left behind, the sender’s withdrawal is a way to move first, not an exclusive claim — see frombox custody for the reasoning behind that ordering.
-
From the terminal: Looking up a frombox. The CLI additionally annotates prices with a best-effort USD value (fail-soft — a rate-API outage just drops the annotation), mirroring
sithbit earnings. ↩ -
From the terminal: Update a frombox. ↩
-
From the terminal: Add stamps. ↩
-
From the terminal: Reclaiming unspent stamps. ↩
Aliases
An alias is a short, human-readable name — like john_doe — that resolves to
a wallet address, the same way a person’s name in your phone’s contacts
resolves to a phone number. Aliases are globally unique across the whole
system (not per-domain), case-insensitive (John_Doe and john_doe are the
same alias), and are lowercased and stripped of any domain suffix at creation.
An alias is yours outright: it never expires and carries no renewal fee,
and it is a transferable on-chain asset you can sell — through an escrowed
offer, a fixed-price listing, or an auction — keeping 90% of the proceeds
(see Trading names). Short names are explicitly
scarce: there will only ever be 36 one-character aliases, and the one- to
four-character tiers carry scarcity-priced claim fees.
See Addresses for how aliases fit into the address system
as a whole.
Note: creating a mailbox automatically registers your wallet’s own address string as an alias for you. This is a namespace reservation: the alias namespace is global and case-folded, and the self-alias keeps anyone else from holding a name that reads as your wallet. It is not what protects your mail — resolution is literal-first: a string that already is a wallet address names that wallet, full stop, and the alias registry is only consulted for text that is not an address. So an alias registered under an address’s lowercased spelling can never redirect mail sent to the address, whether or not the owner claimed the self-alias. Most users never need to create an alias by hand — the getting-started wizard claims an optional friendlier handle as part of onboarding, and every client’s Aliases pane lists the names pointing at your wallet.1
One namespace, and why
There is exactly one alias namespace, and it is domain-blind: any @domain
suffix is parsed off and discarded before resolution, so alice,
alice@acme.com, and alice@anything.example all resolve to whoever holds the
global alice. Assigning your address to a
domain binds which MX relays your mail; it confers no
claim on any name. Only you can repoint a name you hold.
That is a deliberate guarantee, not a simplification. Because lockbox sealing follows resolution in the sender’s browser, anyone who could redefine which wallet an address names would thereby choose which key a sender seals to — reaching inside an end-to-end-encrypted body. Keeping one holder-controlled namespace is what forecloses that.
Issuing addresses to an organization’s staff therefore works by handing
over real ownership rather than by carving out a suffix: reserve the names in
bulk with alias create,
then transfer each to
its holder — a two-party consent hand-off, after which the employee holds the
name outright and the organization cannot silently take it back.
Listing all aliases (the alias indexer)
Alias names are not recoverable from chain state: an alias account stores
only the holder’s address, and the name itself exists on-chain only as a
blake3 hash inside the PDA seed. “Which aliases point at wallet X?” is
therefore answered by the gRPC gateway (mail-grpc), which maintains an
off-chain index of the alias program’s transaction history — every
Create/Transfer/Close instruction carries the plaintext name — and serves
it through the ListAliases RPC.
- Names are returned in their canonical (lowercased) form, sorted.
- The index lives in a local SQLite file beside the gateway — the operator chooses its location, and an operator who doesn’t want the feature can turn the indexer off entirely — and is fed by polling the chain every few seconds.
- On first start the gateway backfills the program’s full history;
ListAliasesanswersUNAVAILABLEuntil that backfill completes, and restarts resume from a stored cursor instead of re-scanning. - The index reads at finalized commitment, so a freshly created alias appears after finalization plus one poll interval.
Transferring an alias
Moving an alias to a new wallet is a deliberate two-party consent ceremony — the current holder initiates the offer, and it only changes hands once the named recipient separately accepts — never a unilateral push. See Trading names: aliases & domains for the escrowed-transfer-for-a-fee marketplace mechanics.
Selling an alias
A listing is a different disposal mechanism from a transfer: it puts the alias up at a fixed price that whoever pays first takes, rather than a transfer’s negotiated two-party consent naming one specific recipient. An auction is a third option: instead of one fixed price or one named recipient, buyers bid the price up over a window and the high bidder wins at the deadline. See Trading names: aliases & domains for the full marketplace mechanics — listings, auctions, and escrowed transfers alike.
-
From the terminal: Create an alias registers additional aliases, Get an alias looks one up, and Transfer an alias moves one to a new address. ↩
Domains
What is a domain
A domain is a claimed, protocol-authorized mail suffix — like
sithbit.com — that a mailbox references so
that MX servers know how to route mail to it over SMTP. Domains are
registered and administered on-chain: someone must claim a domain and have
the postoffice’s
delegate authorize it before mailboxes
on that domain can send or receive mail through the normal MX/SMTP path. The
whole domain registry — the domain accounts, their lifecycle, and the
marketplace — lives in its own dedicated on-chain
domain program.
A domain is also the operator’s revenue line: its authority collects 10% of the postage on every message settled to a mailbox under it (protocol default — see Economics), plus a share of stamp fees and reply bounties. Authorizing one costs 0.01 SOL once, proven by a DNS record rather than granted by a gatekeeper, and the authorization never expires. Mail between SithBit mailboxes settles on-chain and is stored on IPFS, so your outbound to other SithBit users never touches an IP blocklist — the reputation gate that keeps most self-hosted mail out of conventional inboxes.
The marketplace web page shows the domains listed for sale, and each client’s Domains pane shows the ones your wallet holds.1
Active and inactive domains
Every registered domain is either active or inactive. An active domain is fully usable: mailboxes may register against it, and mail may be sent to or deleted from mailboxes that reference it. An inactive domain — one whose deactivation has been finalized — blocks both: it refuses new or updated mailbox registrations, and it blocks sending and deleting mail for the mailboxes that already reference it. A mailbox’s domain must be a registered, active domain (or no domain at all), so deactivating a domain locks it down from new registrations too, not just mail flow.
Who can create a domain
Claiming a domain involves up to three distinct roles, and signing authority over each is separate from the others:
- Authority — the mail server’s
signing key for the domain.
SendMailinto a mailbox is only accepted when the transaction signer is the recipient domain’s recorded authority, and that authority collects the operator share of DeleteMail settlement — 10% of postage by protocol default (see Economics). Transferring, deactivating, and closing a domain are delegate operations, not authority ones — holding a domain’s authority key lets you operate mail for it, not dispose of it. - Delegate — must always sign a domain’s creation; only the postoffice’s standing delegate can authorize a new domain. A signer that isn’t the delegate is refused outright.
- Payer — funds the new account’s rent; defaults to the delegate when no separate payer is given.
Domain identity is scarce and protocol-wide in a way a mailbox or alias
isn’t: claiming sithbit.com grants standing to receive mail for an
entire suffix, so the postoffice — not any individual key holder — decides
who gets to claim it, which is why the delegate’s signature is mandatory
even though the authority and payer roles are flexible. Because domain
accounts are keyed by the domain name alone rather than by who created
them, one authority key can still hold any number of domains: a
mail-server deployment serving several domains registers each of them
with the same authority key.
Asking the delegate holder to sign by hand isn’t the only route to authorization, though — see DNS setup for a self-service alternative that proves domain ownership instead.
Deactivating a domain isn’t instant
Deactivating a domain blocks sending and deleting mail for every mailbox that references it, and it also blocks new mailbox registrations against that domain — a network-wide halt for everyone whose address lives on it. Because that’s such a disruptive action, the protocol never lets it happen with a single signature. Instead it runs on a deactivation timelock: a waiting period between the moment deactivation is requested and the moment it actually takes effect. The domain stays fully active — mail keeps flowing normally — for the entire window.
The point of the wait is to give everyone with a stake in the domain a chance to notice and object before the cutoff lands. The delegate is a single hot admin key (see the threat model), so a compromised or coerced delegate that could deactivate a domain instantly could take its relayed mail down without warning. Splitting the action into a request that only starts a clock, followed by a separate step that finalizes it, turns that into a notice-and-veto window: the domain’s authority, or anyone else monitoring delegate activity, sees that deactivation is pending and has time to intervene — including cancelling the request outright — before it ever affects mail flow. Reversing course in the safe direction, reactivating a domain, is never subject to this delay; only the destructive direction is.
While a domain is in this state it is described as having a pending deactivation: the request has been made and the clock is running, but the domain itself is still active in every respect until the waiting period elapses and the deactivation is carried through. A pending deactivation can still be called off before that happens, which simply cancels the clock and leaves the domain untouched, as if nothing had been requested. Because deactivation reaches every mailbox on the domain at once, requesting, finalizing, and cancelling it are all delegate-only actions, the same standing authority that must approve a domain’s creation in the first place.
Requesting, finalizing, cancelling, and reversing a deactivation are delegate operations run from the terminal.2
Transferring a domain
A domain’s authority is the
key that mail-serving control belongs to: it’s the only signer SendMail
into a
mailbox on that domain will accept, and it’s the key that earns the
operator’s share of settlement when mail is deleted (see
Economics). Transferring a domain reassigns that one
role to a different wallet — nothing else about the domain moves. The
domain’s name, its active/inactive status, and every
mailbox that already references it are all untouched by a transfer: mail
keeps flowing under the new authority exactly as it did under the old one,
without interruption and without any mailbox needing to re-register.
Because a domain’s authority is a role over a shared, protocol-scarce resource rather than personal property, the current authority holder cannot simply hand it off unilaterally — the same standing that must approve a domain’s creation is required to reassign it. See Who can create a domain for why that authority is concentrated in the postoffice’s delegate rather than left to individual key holders: transferring a domain is a delegate-only operation for exactly the same reason creating one is.
Reassigning a domain’s authority is likewise a delegate operation run from the terminal.3
What buying a domain does — and does not — buy
A domain can also change hands on the open marketplace rather than by delegate-signed transfer. For how that sale is priced, timed, and settled — listings, the binding window, the 90/10 split — see Trading names: aliases & domains; this section covers only what a purchase conveys, not how the sale itself works.
A marketplace domain sale conveys protocol authority, nothing more. Concretely, the buyer’s wallet becomes:
- The domain’s mail-injection key.
SendMailinto a mailbox on the domain is only accepted when the transaction signer is the domain’s recorded authority — buying puts that signing right, the on-chain half of running the domain’s inbound relay, in the buyer’s hands. - The domain’s settlement collector. The buyer earns the operator share of every DeleteMail settlement (see Economics) for mailboxes on the domain from that moment on.
It does not convey anything off-chain:
- Not DNS. The DNS name is owned at the registrar, entirely outside the
protocol; a sale never moves it. Whoever holds the registration keeps the
zone — the MX records, the
_solana.authority.<domain>TXT record, all of it. - Not hosting. No MX server,
sithbitddeployment, or IPFS infrastructure changes hands. - Not DKIM. Signing keys live in the operator’s DNS zone and filesystem, never on-chain.
A buyer who wants to operate the domain — actually receive its mail, not just collect its settlement share — must separately obtain the DNS name (or the cooperation of whoever holds it) and stand up their own MX. Do that diligence before paying: the on-chain state is easy to check,1 but only the DNS zone shows who controls the name itself. For what can go wrong when on-chain authority and DNS ownership sit in different hands — lockouts, proof replay, and authority drift — see The marketplace sells protocol authority; DNS remains separately owned in the threat model.
Buy a listed domain from the marketplace web page’s For-sale tab, and list your own from its listing form or your client’s Domains pane.4
-
From the terminal: Looking up a domain inspects any domain’s registration and status. ↩ ↩2
-
See Deactivating a domain for the commands. ↩
-
See Transferring a domain for the command. ↩
-
From the terminal: List a domain for sale covers
sithbit domain sell/buy. ↩
Authorize a domain by proof
Every SithBit domain is really a DNS domain — the same name that shows up in a browser’s address bar, governed by the same domain-name system the rest of the internet already answers to. Normally, claiming that identity on chain means asking the postoffice’s delegate to authorize it by hand: a human administrator has to trust that you actually control the domain and sign a transaction saying so. See Who can create a domain for that delegate-signed path.
There’s a second option: prove it yourself, and skip the administrator entirely. If you already control a domain’s DNS records, you can generate a cryptographic proof that shows exactly that — and the chain checks the proof itself, on the spot, rather than trusting a human’s say-so. No delegate signature is needed, and the proof doesn’t even have to be submitted by you personally: whoever ends up holding it can send the transaction (and pay for it), because the proof itself carries the authority, not whoever happens to click submit.
This works because SithBit treats DNS as the ultimate root of trust for who owns a name. On-chain possession of a domain is never final or self-sufficient on its own — it always answers to whoever genuinely controls the domain in the real DNS system. That cuts both ways: if a domain’s on-chain record ever falls out of sync with reality — after a change of hands at the registrar, say, or a stale claim left over from before — the domain’s rightful DNS owner can prove control again and reclaim the on-chain authority outright. The chain defers to DNS, not the other way around.
For the cryptography under the hood — how the proof is built, staged, and verified on chain, and the exact commands to run — see Authorize a domain by proof.
Verified-sender attestation
Every message a wallet sends is signed, but a signature only proves which
wallet sent it — not who stands behind the wallet. For an organization
that sends mail at any scale (receipts, notifications, a newsletter), that
gap is a credibility problem: nothing on chain connects its sending wallet
to the name its recipients actually recognize — and the same gap is what
invoice fraud and lookalike-sender phishing live in, where a message that
reads like the real company asks for a payment to go somewhere new. A verified-sender
attestation closes the gap. It is an on-chain record in which a DNS
domain vouches for a wallet address as a legitimate sender —
“acme.com stands behind this wallet” — and anyone, MX operator or mail
client alike, can look the binding up and verify it.
The vouching works the same way authorizing a domain by proof does: whoever controls the domain’s DNS generates a cryptographic proof of that control, and the chain checks the proof itself, on the spot — no administrator signs anything, and no delegate is in the loop. The organization proves domain control once, pays a one-time fee, and the attestation stands until revoked. The cost sits entirely on the sender’s side, matching the protocol’s positioning everywhere else: senders pay to be credible, recipients never pay anything. For a legitimate organization the payoff is delivery on its own terms — prove you’re you once, and pay the floor price on first contact under reputation-scaled pricing instead of a stranger’s full default, with no mailbox provider’s reputation score standing between you and the inbox.
An attestation is deliberately less than domain authorization, and independent of it. It confers no serving rights: an attested wallet gains no ability to route, inject, or operate mail for the domain, and the domain itself gains no standing as a mail suffix. It is a freestanding record — a domain that has never been registered as a SithBit mail domain at all can still attest its sending wallets, because attesting requires only proven DNS control, not an on-chain domain account. And it is not exclusive: a domain may attest any number of wallets, one record per (domain, wallet) pair, each independently revocable.
The fee is a flat, one-time charge to the postoffice — 0.01 SOL by default, tunable by the delegate up to a hard on-chain cap of 0.1 SOL (see the tunable-constants table). A proof that fails to verify charges nothing.
Revocation belongs to the attested wallet itself: it can close its own attestation at any time and reclaim the record’s rent. No other key — not even the domain’s — can reach the record, because its on-chain address derives from the attested wallet.
Since v0.39.0 an attestation also buys a concrete economic advantage: reputation-scaled first-contact pricing prices an attested sender’s first contact with any stranger at the discount floor immediately — the cheapest rate any reputation earns — instead of the mailbox’s full default postage. Proven domain control substitutes for a long postage spending record; a recipient-set price is never affected.
The binding is otherwise consumed from the terminal and by servers: the
read-only sithbit domain attestation lookup and the gateway’s
GetSenderAttestation call answer “is this wallet attested for this
domain, and since when”. A verified-sender badge in the mail clients is
planned but has not shipped yet.
For the commands — attesting, looking a record up, revoking, and tuning the fee — see Attest a verified sender.
This section covers the mechanics of the mail operations themselves — sending, getting, pinning, and deleting a message, plus the reply-bounty incentive that rides on top of a send — as they happen against the chain and IPFS. In everyday use your mail client performs these for you; this page is for understanding exactly what happens underneath.1 See Addresses for how wallet addresses double as email addresses, and Economics for the money side.
“Pinning”, in plain terms: IPFS keeps a message body only while some computer on the network has agreed to hold a copy — agreeing to hold one is called pinning it, and releasing it is unpinning. Who holds that copy, and how to add one you control, is explained for non-technical readers in What does pinning mean?
The split model: body off-chain, envelope on-chain
A SithBit message is deliberately split in two so that nothing sensitive and nothing large ever touches the chain:
- The body lives on IPFS. The message body (headers, subject, and text) is sealed to the recipient’s encryption key and pinned to IPFS as an opaque blob. It is content-addressed: the blob’s hash is its address, its content identifier (CID).
- The envelope lives on-chain. Only a small envelope goes on-chain as a
SendMailinstruction — the sender, the recipient, a timestamp, and that CID. The chain never sees the plaintext, and never stores more than a hash and a pointer, so on-chain cost stays flat no matter how large the mail is.
Because the CID is a hash of the sealed bytes, the two halves are tamper-evident against each other: given the on-chain CID, anyone fetching the body from any IPFS source can hash what they got and confirm it is exactly the body the sender committed to. Nothing else can hash to that CID.
The lifecycle of a message
The end-to-end path, from a sealed compose to the settling delete:
- Send (figure steps 1a–1c) — seals the body, pins it to
IPFS, and writes the envelope (with its CID) on-chain via
SendMail. Postage — the frombox price for that sender, or the mailbox default — gates whether the send is allowed at all, and the postage the sender pays funds the new message account. - Get (figure step 2) — reads the on-chain envelope, fetches the sealed body from IPFS by its CID, and decrypts it with the recipient’s wallet (or a delegated key).
- Pin (figure step 3, optional) — re-pins a verified body to a pinning provider the recipient controls, so a message stays retrievable even if the operator that originally pinned it stops.
- Delete (figure step 4) — settles the message: it pays the postage out (to the recipient, with a share to the domain operator), refunds the prepaid fees, and reaps the on-chain message account so its rent is returned.
Deletion is the settlement trigger, not just a cleanup step — a message account holds the sender’s postage until it is deleted. See Deleting mail and Economics for the exact split.
Note: in normal operation, MX/SMTP servers and your mail client handle sending and receiving mail for you — see Running a mail server. The operations documented here talk to the chain and IPFS directly, which is useful for testing, scripting, or understanding exactly what a mail server does on your behalf.
Sending mail
The send operation records a delivery on-chain: it burns one stamp from the sender’s frombox and writes a new message account for the recipient pointing at the body’s IPFS CID. It does not move any bytes around — the encrypted body must already exist on IPFS. This is the low-level primitive an MX server runs on the sender’s behalf after it has sealed and pinned an incoming message; end users normally send through an ordinary mail client, not this command.
Want a faster answer? Attach a reply bounty and put SOL behind your question — the recipient collects it the moment they reply, no separate escrow to set up. See Reply bounties for the full mechanics.
See Sending mail for the full command reference, its preconditions, and what reaches the chain.2
Reading mail
The get operation is the receive side of send: it reads a mailbox’s message accounts from the chain, prints their on-chain listing, and — when a body is fetched — pulls the encrypted bytes from an IPFS gateway and (optionally) decrypts them with your key. It is the primitive an IMAP/ POP server runs to materialize a mailbox; end users normally read mail through an ordinary client rather than this command. Reading is read-only: a get never signs a transaction and never settles postage — that is delete’s job.
See Getting mail for the full command reference, the on-chain listing format, and decrypting a fetched body separately.
Pinning to IPFS
A message body never lives on-chain — only its content identifier (CID) does. The bytes live on IPFS, pinned by whichever mail server received the message. That is convenient, but it means the availability of your own mail depends on an operator continuing to pin it. If that operator stops — or you simply want to hold your own copy — you can pin the body to a provider you control. That is what the pin operation does: it fetches each message body, proves it against the on-chain CID, and re-pins the verified bytes somewhere of your choosing, so the mail stays retrievable no matter what the operator does.
See Pinning mail for provider choices, message selection, keeping mail pinned continuously, and the verify guarantee.
Deleting mail
Deleting a message is how a received email is settled and its on-chain account reclaimed. A mail message lives on-chain as its own account — a small envelope (sender, recipient, timestamp, and the IPFS CID of the sealed body) funded with the postage the sender paid to deliver it. That account sits open until someone deletes it. Deletion is the single event that pays the postage out, refunds the prepaid transaction fees, and closes (reaps) the message account so its rent is returned rather than left locked on-chain forever.
See Deleting mail for who may delete, the exact settlement order, and the delete-vs-refund distinction.
Reply bounties
A reply bounty turns postage’s pay-for-attention into pay-for-an-answer: the sender escrows extra lamports on the message itself, and the recipient collects them by replying before a deadline. No reply by the deadline and the sender takes the money back. The bounty rides the message account — there is no separate escrow account to create, fund, or clean up.
Attach a bounty from the compose card in any GUI client, and claim or refund one from its Bounties pane — see Replying, and attaching a bounty.3
-
The
sithbitCLI drives each of these operations directly from the terminal; each section below links its full command reference. ↩ -
End users normally never run these commands — an ordinary mail client and its MX server perform the same operations on your behalf. ↩
-
From the terminal: Reply bounties covers attaching, linking a reply, and claiming or refunding. ↩
Do not disturb
A SithBit account can carry an away schedule — do-not-disturb windows
managed through any of the GUI clients (or the
account service directly) and evaluated in the
account’s own timezone. While a window is active, the recipient’s
sithbitd does something deliberately
different from the classic vacation autoresponder: it refuses the mail up
front, at RCPT time, with a transient 450 4.2.1 — instead of
accepting the message and firing an “I’m away” reply back at the sender.
450 4.2.1 Recipient alice is away (do not disturb); try again later
That one design choice does most of the work of this page, so it is worth spelling out.
Why refuse instead of autoreply
- The sender’s mail server does the waiting. A
450is SMTP for “not now”: the sender’s MTA queues the message and retries on its own schedule, transparently, for days. When the away window ends, the queued mail simply arrives — the sender resends nothing and installs nothing. An autoresponder cannot offer this; it accepts the mail and leaves the sender to guess whether anyone will read it. - Nothing piles up while you are away. Accept-and-autoreply means coming home to a stack of stale unread mail — and, on SithBit, to a stack of postage decisions with it, since every delivered message carries prepaid postage that settles when you delete it. It also means the pile is there, one login away, for the whole vacation. Refusing at the door keeps the mailbox genuinely quiet: no backlog to triage on return, and no temptation to “just check” business mail from the beach.
- The sender learns at send time. An autoreply may arrive, may be eaten by a spam filter, or may never have been configured — the classic bounce-or-silence lottery. A refusal reaches the sender through their own mail server’s queue notice the moment they send, while the message is still fresh in their mind.
- A refusal costs the sender nothing. The refusal happens before the message is accepted, so no stamp is burned and nothing needs refunding. (The postage gate still runs first — the away check only fires for a sender who could otherwise deliver.)
Two honest caveats. The schedule lives in the operator’s account store and is
enforced by the recipient’s sithbitd — this is server-side behavior, not an
on-chain guarantee. And DND fails open: if the store hiccups mid-lookup,
the server accepts the mail rather than refusing something deliverable —
do-not-disturb is a courtesy, not a security boundary.
What the refused sender sees
Out of the box, the refusal is the single line above and nothing more. When the operator sets a self-service base URL, the same line also carries a link to a schedule page the sender can open in a browser:
450 4.2.1 Recipient alice is away (do not disturb); try again later; schedule at https://mail.example.com/dnd.html?to=alice%40sithbit.net
The page answers the question the refusal raises — when should I expect delivery? — as far as the recipient allows:
- Accepting now. If the away window has already ended, the page says so: the sender’s MTA is retrying on its own, so the mail is on its way (or a resend delivers immediately).
- Away, schedule private (the default). The page confirms the recipient is temporarily not accepting mail but cannot say when the window ends — the sender’s server keeps retrying regardless.
- Away, schedule shared. If the recipient opted in to sharing, the page lists the away windows — and, when the schedule ever reopens, adds “accepting mail again at …” with the exact resume instant shown in the sender’s own local time (the account service computes it in the recipient’s timezone and ships it as UTC; the page localizes it to the viewer’s clock). The windows themselves still read in the recipient’s local time. A recurring schedule covering the whole week around the clock never reopens, so no instant exists — the page then shows the windows alone.
Sharing is the recipient’s choice, off by default: the account’s
expose_dnd_schedule flag (a toggle in the clients’ DND settings, or
PATCH /v1/account on the account API) controls
whether the anonymous schedule check returns the windows themselves — plus,
while away, the resume instant — or only the yes/no “away right now” answer.
Your calendar is yours; the protocol only ever needs the boolean.
Stamp refusals: the funding page
The same setting links a second page from the two postage refusals — the gate every unknown sender meets before DND is even consulted:
450 4.7.0— the sender’s frombox exists but holds no stamps. Transient: the sender’s MTA queues and retries, so once the frombox is funded the queued mail delivers on its own — no resend needed.550 5.7.0— no frombox exists for this sender/recipient pair at all. Permanent: the message bounced for good, so after funding the sender must send it again.
With the base URL set, both append ; fund it at {base}/fund.html?to=<recipient>&from=<sender> — a funding page where the
sender fixes the shortage themselves, with no account and no operator in the
loop:
- The page resolves the recipient and quotes the price straight off the chain — the recipient’s per-stamp postage, the fixed settlement surcharge, and the live per-stamp protocol fee (waived on-chain when a recipient funds their own frombox). Nothing is trusted server-side; the quote is read from the same accounts the program enforces.
- The sender connects a Phantom or Ledger wallet, picks a stamp count (one stamp = one email), and buys. A first purchase creates the frombox in the same transaction — the same create-or-top-up path every client uses.
- The page confirms the purchase and tells the sender what happens next:
after a
450, waiting is enough; after a550, resend.
This closes the loop postage opens: pricing out strangers only works as spam defence if a legitimate stranger has a way to pay, and the refusal itself now hands them one.
For operators: one setting, two pages
Everything above is off by default — leaving the setting unset keeps
every refusal byte-identical to the linkless text. A single SMTP setting
turns both links on: the public base URL the two pages are served from
(for example https://mail.example.com). There is nothing else to
configure — the paths and query strings are built for you.
See the configuration
reference
for the details, and Self-service pages for refused
senders for how the
two pages are hosted (they ship in the onboarding web bundle, normally served
by the account API’s static-file root). The standalone smtp-server dev
binary carries only the funding link — the DND gate is sithbitd’s.
Related
- Getting mail — the receive side the away schedule protects.
- Add stamps — the CLI flavor of what the funding page does, and the exact per-stamp price breakdown.
- Economics — why postage prices out spam, and where the protocol fee goes.
- account-api: the account service — the DND
schedule routes and the
expose_dnd_scheduleopt-in. - Configuration reference — the operator setting.
The Marketplace
SithBit is not just a way to send mail — it is a small open economy, and three kinds of value change hands on it. Every trade settles in SOL, on-chain, with no platform sitting in the middle taking a cut of the principal:
- Alias names — short, memorable, globally-unique handles that resolve to a wallet. A good name has value, so names can be listed, auctioned, or sold to a specific buyer.
- Domains — the on-chain authority to carry a DNS mail domain’s inbound mail. Selling a domain sells its future settlement income along with the name.
- Attention — through campaigns, you can opt in to be reached by advertisers who pay for the privilege. Your inbox becomes an income stream instead of a spam target.
The first two are the name marketplace: an asset changes owner. The third is the participant pool: nothing changes owner — you rent out your attention, one message at a time, and keep earning. They share a home here because they are the ways SithBit lets you turn what you own — a name, a domain, or your own inbox — into SOL. On a name sale the seller keeps roughly 90% of the proceeds, with a small settlement cut to the postoffice; on campaign mail the recipient keeps the postage and roughly 90% of any bounty.
Why it works this way
Traditional mail gives value in one direction only: a provider monetizes your data and attention, and you pay for the privilege of being marketed to. SithBit inverts that. Because every message already carries sender-paid postage and a name is a real on-chain asset, the same machinery that prices out spam also lets honest value flow back to the people who create it — the name holder, the domain operator, and the recipient.
Nothing here is mandatory. You never have to list a name or join the pool; these are levers you reach for when you want to, and the base experience of owning an address and receiving mail stays exactly as cheap as before. What the marketplace adds is upside.
The two surfaces
Everything below is reachable two ways, backed by the same on-chain accounts:
- In the browser — the marketplace pane (standalone, or mounted inside the webmail/Outlook/Thunderbird clients) lets you browse names for sale, buy with a click, list your own, and browse the participant pool by topic.
- From the terminal — the
sithbitCLI drives the same operations:alias sell/buy,domain sell/buy, and the wholesithbit campaigntree for the participant pool.
Where to go next
- Trading names — list, auction, or sell an alias or a domain, and buy names other people are offering.
- Beacons — the small public sign that opts you in: what it says, what it costs to reach you, and how to take it down.
- Campaigns — opt in to earn from your inbox, or run a bountied campaign to reach an opted-in audience.
- Economics — the same flows traced lamport by lamport: who pays, who collects, and where SOL parks along the way.
Trading names: aliases & domains
An alias is a short human name that resolves to a wallet; a domain is the on-chain authority to carry a DNS mail domain. Both are real, transferable on-chain assets that never expire and carry no renewal fee — which means both have a resale market, and the seller keeps 90% of every sale. This page is the map of that market; the mechanics of each move live in the per-name how-tos it links to.
Three ways to sell
A name can leave your hands three ways, depending on whether you have a buyer in mind and how you want price discovery to work:
| You want to… | Use | Applies to |
|---|---|---|
| Offer a name to whoever pays first, at a set price | The marketplace’s listing form1 | aliases + domains |
| Let buyers bid the price up over a window | Auction (alias sell --auction) | aliases |
| Hand a name to one specific buyer for an agreed fee | Escrowed transfer / domain transfer | aliases + domains |
A listing and an auction name nobody in particular — the market decides who wins. An escrowed transfer is a private, two-party deal: you name the buyer and the price, and only they can take it. All three settle in SOL and take the standard 90/10 split between the seller and the postoffice on the sale proceeds.
What a domain sale actually sells
Buying an alias buys a name. Buying a domain buys a revenue stream: the authority that carries a DNS domain’s inbound mail also collects that domain’s share of every settlement its mail produces, so a domain listing is really an offer to sell that future income. The Economics chapter traces exactly what a domain authority earns.
Buying a name
Browsing and buying is the mirror image: the marketplace pane lists every name currently offered, and a click buys one (signed with your own browser or hardware wallet).2 Purchases are public — the buyer, the price, and the transaction are all on-chain (see What’s public and private).
Beyond names: renting your attention
Selling a name is a one-time transfer of an asset you own. If you would rather keep earning from something you don’t give up — your own inbox — that is what campaigns are for: opt into the participant pool and advertisers pay you, message by message, to reach you.
-
From the terminal: List for sale (
alias sell --price) / domain sell. ↩ -
From the terminal,
alias buy/domain buytake a name that is currently listed. ↩
Beacons
A beacon is a small public sign you put up next to your mailbox that says: you may mail me about these topics, at my usual price. It is how you opt in to campaigns — paid outreach from advertisers — and it is the only thing that makes you findable by one. No beacon, no campaign mail: a wallet that never published one is simply not in the pool, and nothing changes about the mail it already gets.
Think of it as a small classified ad you place about yourself, rather than a profile somebody else builds about you.
What a beacon says
Three things, and deliberately nothing more:
- The topics you’ll hear about. You pick labels from a fixed, shared list — interests, skills, and broad bands such as an age range or a region. There is no free-text box, so there is nothing to over-share in: the labels are coarse on purpose, and everyone’s labels come from the same list.
- What it costs to reach you. Your price isn’t a separate setting on the beacon at all — it is whatever your mailbox already charges a stranger, its default postage. Opting in says “if you’ll pay my going rate, you may reach me”, so there is never a second price to keep in step.
- Optionally, a pointer to a longer profile. If the labels are too coarse to describe you, you can write a fuller profile, encrypt it, store it off to the side, and put only the address of that encrypted file on your beacon. Anyone can see that you have one; nobody can read it unless you hand over the key — and the way they ask for it is to mail you, paying your postage like anyone else. You share it only if you want to.
Because a beacon advertises a mailbox’s price, you need a mailbox before you can publish one.
One beacon per wallet, and publishing replaces it
A wallet has at most one beacon, so publishing again doesn’t add a second sign — it replaces the whole one you had. Whatever you publish becomes the complete new contents: topics you leave unpicked are dropped, and leaving the profile pointer blank clears a pointer you had published before.
The practical habit: when you change a beacon, state everything you want it to say, not just the part you’re changing.
How advertisers find you
An advertiser searching the pool names the topics they care about and gets back the wallets carrying every one of them — naming more topics narrows the result, it never widens it. What they see is your wallet address, your labels, and whether you advertise an encrypted profile.
Finding you is free; reaching you is not. Sending you a campaign message is a separate, paid step: the advertiser pays your postage so the message lands, and can attach a reply bounty you claim if you answer in time. Campaigns walks through that whole flow and who pays for what.
What’s public, and what isn’t
A beacon is a public notice — that is its job, and the reason it is designed to carry so little. Anyone can read your wallet address, the topics you picked, the fact that you published an encrypted profile, and when you last changed any of it. Like everything else on the chain, that record is permanent: it outlives the beacon itself, so closing one later does not un-publish the history.
What stays private is the content of the encrypted profile. Only people you hand the key to can read it — and a key you hand out can be passed on, so keep anything out of that profile whose onward sharing would hurt.
Two habits follow, and they’re the whole privacy story here:
- Pick only labels you’re comfortable being publicly known by, forever.
- Treat the encrypted profile as “shared with whoever I gave the key to”, not as a secret.
For the wider picture, see What’s public and private and its field reference.
Turning it off: disable vs. close
There are two ways to stop being reachable, and they are not the same thing:
- Disable is a convenience of the browser pane, not something the chain itself knows about. It republishes your beacon with no topics; since a search has to match at least one topic, a topic-less beacon drops out of every search. The beacon and its rent deposit stay put. The pane remembers the topics you switched off in that browser only, so Re-enable puts back exactly the set you had — on another browser, or after clearing your browser data, there’s nothing to restore and the pane says so; pick your topics again and publish an update instead.
- Close is the real exit. It removes the beacon entirely, you disappear from every search, and the deposit that kept it on the chain is refunded to your wallet. Opting out is one click, costs you nothing, and leaves nothing behind — and you can always publish a fresh beacon later. Postage you already earned from campaign mail stays yours whether or not you ever replied.
Neither one touches your mailbox, your mail, or your postage price. Ordinary mail keeps arriving exactly as before; you have only stopped advertising that you’re open to campaigns.
Where beacons show up
- In the browser, the marketplace pane’s Participants tab browses the pool by topic, and its My beacon section publishes, updates, disables, or closes your own.
- From the terminal, the
sithbit campaigncommands do the same three things, and are also the way to encrypt and store a longer profile rather than pasting a ready-made pointer.
Where to go next
- Campaigns — what a beacon opts you into: who pays, what you earn, and how a reply bounty works.
- What’s public and private — the same public/private split across the whole system.
- The participant-pool design note — why beacons carry coarse labels and a sealed profile rather than a rich public one.
Campaigns
A campaign is paid outreach that runs the usual advertising bargain in reverse. Instead of blasting messages at people who never asked and hoping a few don’t mind, an advertiser reaches only wallets that opted in to hear about a topic — and pays each of them for the reach, plus a bounty for a reply. Nobody is contacted uninvited, nobody is contacted for free, and the money flows to the person whose attention is being spent.
This is the same sender-pays idea that prices out spam, pointed at a new use: turning your inbox from something you defend into something you earn from.
Why campaigns exist
Ordinary email advertising is adversarial. Senders want reach; recipients want quiet; the middle fills with spam filters, list brokers, and tracking. SithBit already made unsolicited mail cost the sender real SOL. Campaigns take the next step: they let a recipient advertise their own availability — “I’ll happily hear about gaming and pay-per-response research” — so an advertiser can find willing people directly and compensate them, with no list broker and no guessing.
Everyone is better off in the honest case: the advertiser reaches an audience that wants the message, and the recipient is paid for attention they chose to give.
For advertisers: reach an audience that opted in
If you run outreach — a marketer, a survey researcher, a business with an offer — a campaign gives you something a mailing list never could: a targeted audience that has agreed to be reached, and a built-in incentive for them to respond.
- Target by topic, not by scraped data. Participants tag themselves with
coarse interests, skills, and broad demographic bands. You filter to the
wallets carrying every tag you care about (a logical AND) — e.g.
interest.gamingandregion.apac— and the match is exact: what people declared about themselves, never a lookalike model inferred from tracked behaviour. - Pay only for real reach. You fund each message yourself: the recipient’s postage (so it actually lands) and a reply bounty (paid only if they answer in time). No impressions you can’t verify, no bot traffic — every recipient is a real wallet that chose to be reachable.
- Price it before you commit. A campaign quote reads each matched
recipient’s live chain state — a standing frombox price, prepaid stamps, or
the reputation-scaled first-contact price — and itemizes message rent,
postage, bounty, and fees per class, so you see the whole campaign’s total
before a single lamport moves. The same quote marks the matched wallets the
send will skip — and never bills you for them: no mailbox, postage over
your
--max-postagecap, or a mailbox carrying the IPFS opt-out. Your real reach is on screen before you decide; the send reference states the exclusion rule exactly. - Every recipient is a real, funded wallet. Compare the alternatives a Web3 launch usually gets: an ad platform’s impressions, or a community channel’s shilling, neither of which can prove a human on the other end. Here a bot farm has to hold real SOL in a real mailbox to appear in your search, and it costs it postage to stay there.
- Get responses, not just deliveries. The reply bounty is the hook: a recipient who answers your survey or offer claims it, so your call-to-action carries its own reward.
The whole advertiser flow — search, quote, send — is chain-direct and
scriptable from the sithbit campaign CLI. A
campaign send is direct-signed only: your funded wallet signs every
delivery locally, so no mail server ever holds the keys to a paid campaign.
For participants: earn from your inbox
If you just want to receive mail, you never have to think about any of this. But if you’re willing to hear from advertisers about things you actually care about, opting in turns your inbox into an income stream — and you stay in control the whole time.
- You choose what you’ll hear about. You publish a small on-chain beacon listing coarse tags — interests, skills, broad bands like an age range or region. Advertisers can only find you through the topics you chose.
- You set the price. Your participation price is simply your mailbox’s default postage — the same price that already prices out strangers. Opting in doesn’t add a new fee to manage; it says “if you’ll pay my going rate, you may reach me.”
- You get paid twice. When a campaign message arrives you keep the postage — you were paid just to be reached. Reply before the window closes and you also claim the bounty (split 90% to you, 10% to your domain authority — or to the postoffice when no active domain resolves for your mailbox). Ignore it and you simply keep the postage — you are never paid less for not replying.
- Your privacy is built into the design. Only coarse, self-chosen tags go on chain — never free text, never precise personal values. A richer profile, if you attach one, is encrypted; an advertiser who wants it has to mail you and ask, and you hand over the key only if you want to. See What’s public and private.
- You can leave any time. Closing your beacon is the opt-out — you drop out of every search immediately, and the account’s rent is refunded to you.
How it works
- Opt in. A participant publishes a beacon listing the tags they’ll accept mail on. Their mailbox must already exist — the advertised price is its default postage.
- Discover. An advertiser runs a trustless search for beacons carrying every tag they want, and gets back each match’s sendable wallet.
- Quote. The advertiser prices the matched set, itemized per recipient, and sees which matches the send will skip, before committing funds.
- Send. The advertiser sends one bountied message to each remaining match — the loop continues past any single failure so one bad address can’t strand a paid batch.
- Earn. The participant keeps the postage on delivery, and — if they reply before the window closes — claims the reply bounty. Anything unclaimed is refundable to the advertiser after it expires.
For the exact commands, flags, and quote output, see the
sithbit campaign CLI reference. For the money
traced lamport by lamport, see Economics → Campaigns.
For the design rationale behind the coarse-tag beacon and the mail-gated detail
profile, see the
participant-pool marketplace design note.
Where campaigns show up
- In the browser, the marketplace pane’s Participants tab lets anyone browse the opt-in pool by topic.
- From the terminal, the
sithbit campaigntree drives both sides — a participant’screate/update/closeand an advertiser’ssearch/quote/send.
What’s public and private
SithBit puts the envelope on a public ledger — the Solana blockchain, where it is public and permanent — and keeps the letter sealed. That split shapes its privacy model in a way ordinary email does not, and this page tells you exactly which side of it every piece of your mail lands on. Your message body and subject are encrypted so only the recipient can read them; almost everything around the body — who mailed whom, when, and for how much — is world-readable and stays that way forever.
This page is the plain-language map of that split. For the exact field-by-field inventory of every account and what it exposes, see What’s public and private: the field reference. For the adversarial framing, see the trust assumptions and threat model.
The one-line summary
| Public | Private | |
|---|---|---|
| On-chain | your wallet address (= your email address), account settings, who-mailed-whom + when, stamp and marketplace prices, SOL balances and transfers | nothing — the chain is a public ledger |
| Off-chain | the sealed body’s ciphertext on IPFS (fetchable by anyone with the CID); the SMTP envelope your relay sees | the plaintext body and subject (sealed to you); your wallet secret; your mail password |
The rest of this page unpacks each row.
On-chain: public and permanent
Everything an on-chain account holds is stored in the clear in a world-readable Solana account — anyone can read it, and history outlives deletion. What that means for you:
- Your wallet address is your public identity. It doubles as your email address (see Addresses), so it is inherently public — anyone you mail, and anyone watching the chain, sees it.
- Your account settings are readable. Your mailbox’s default
stamp price, your published encryption key, your
no_ipfspreference, your alias-to-wallet mapping, and any domain you authorize (its cleartext DNS name and authority) are all on-chain and public. - Every send records metadata. Sending mail writes a message account keyed
to the recipient wallet, holding the sender wallet, a blake3 hash
of the
fromaddress (never the string) and a timestamp — and the rest of the account with them, down to the body’s storage locator, listed field by field in theEmailrow. So a chain observer learns which wallet mailed which wallet, and when — the address book, not the message. This is deliberate and covered in depth under message metadata is hashed, not hidden. It is permanent: this history survives even after you delete the mail. - Money is visible. SOL balances, stamp purchases, and every transfer are public, as on any Solana account.
- Marketplace purchases are public. Listing an alias or domain for sale, the asking price, the winning bid, and the buyer’s wallet are all on-chain — an observer can see who bought which name and for how much.
- Opting in to campaigns is public. Publishing a
beacon puts your wallet in the
campaign
pool on-chain, together with the topic tags you picked — and the rest of the
beacon account with them, down to the timestamps, listed field by field in
the
ParticipantBeaconrow. The profile’s contents stay sealed; the fact that you have one does not.
Off-chain: the sealed body and your operator
The message body never touches the chain. It is sealed with crypto_box_seal to your wallet (or a delegated key — see Mailbox Keys) and stored off-chain. That keeps the plaintext private, but “off-chain” is not automatically “hidden”:
- The plaintext body and subject are private. They are encrypted before they
are ever stored, and only your wallet key (or delegated key) opens them. The
readable
From:/To:/Subject:headers live only inside this sealed body. - But the ciphertext on IPFS is public. By default the sealed body is pinned
to IPFS, where anyone holding its content ID can
fetch it. They get ciphertext, not plaintext — but a public copy of your
ciphertext exists. That is a harvest-now, decrypt-later exposure: an
adversary can archive the ciphertext today and decrypt it years from now if
the sealing crypto is ever broken. If that trade-off matters to you, the
no_ipfsopt-out keeps the ciphertext off public infrastructure (at the cost of decentralized availability). - Your operator sees more than the chain does. Whoever runs your mail server handles your mail at delivery and at read time, holds your IMAP / POP credentials, and — for mail relayed through them — sees the SMTP envelope and headers in the clear. A relaying domain operator is a trusted party; see the domain authority is fully trusted for relayed mail. How much of your mail they can read afterwards, from their own store, depends on how your account authenticates — the next section unpacks it.
What your operator holds
The server that delivers your mail keeps its own copy so IMAP, POP, and webmail can serve it back to you. For accounts without a stored mail password — wallet-signature login only — that copy can be envelope-encrypted at rest1: each delivered body is sealed once under a fresh per-message key (AES-256-GCM), and that key is wrapped to your account’s reading key with the same sealed-box construction that seals mail to your wallet. The reading secret is derived in your client, travels only at login — appended to the wallet-signature password over IMAP/POP, or as a field on the account API’s token exchange — is held in memory for the session, and is scrubbed when the session ends. A session that didn’t supply it gets a clear “log in again with your reading key” refusal; the server never hands back raw ciphertext pretending it is a message. A session that did supply it reads normally throughout — the summaries in your message list and your search hits are decrypted for it too, not only the message you open — while an unkeyed session still gets its list and its search, with the bodies it cannot open shown as locked rather than blank or silently missing.
This at-rest sealing is
automatic on chain-connected deployments —
there is no operator switch to forget. Any sithbitd or account API
with a chain gateway configured seals password-less accounts’ mail at
spool time; only a chain-less development stack (which has no way to
resolve reading keys) stores plaintext. The one thing that opts an
account out is setting a stored mail password. Your reading secret is
accepted only where reading happens — IMAP, POP, and the account
API’s login. The SMTP submission service refuses a wallet-signature
login that carries it: sending mail never requires your decryption
secret, so a client configured to ship it there fails loudly instead of
leaking it.
Be clear-eyed about what it does and does not buy you. What it protects against:
- A stolen disk, a leaked backup, a store snapshot or dump — none of them contain your plaintext, only the envelope-sealed bytes.
- After-the-fact browsing — an administrator paging through the store (or its backups) later cannot read sealed bodies without your reading key.
What it does not protect against:
- A live, malicious operator. The running server necessarily sees your reading secret at login and the decrypted plaintext at read time — a hostile operator can capture either in-process. At-rest sealing protects data at rest, not from the operator while you use their server. If that is your threat, the answer is no server at all — see Trustless webmail.
- Accounts with a stored mail password. They keep a readable copy by design: challenge-response logins (CRAM-MD5, APOP) never transmit a reading secret, so the server must be able to serve those sessions from a copy it can read itself.
- Metadata. Who mailed whom, when, message sizes, folder activity, and the SMTP envelope of relayed mail all stay visible to the operator — sealing covers bodies, not traffic.
- The public copies. The body pinned to IPFS and referenced on-chain was ciphertext all along — sealed to your wallet, a separate model this section changes nothing about; see IPFS storage: benefits to users.
The same at-rest-versus-operator distinction applies to any per-recipient pin-provider credentials you register: sealed on disk, readable by the running server.
What you control
no_ipfs— keep your sealed bodies out of public IPFS, stored only by your operator. Removes the public-ciphertext exposure; re-centralizes availability onto one operator. See Opting out of IPFS storage.- A delegated encryption key — publish an X25519 key so senders seal to it instead of your signing wallet; see Mailbox Keys. The key itself is public (it is a public key); the point is key separation, not hiding.
- Skipping the stored mail password — wallet-signature login (with a reading key) is what keeps your server-side copies sealed at rest on chain-connected deployments; see What your operator holds. A stored mail password trades that away for CRAM-MD5/APOP compatibility: it is recoverable in plaintext by your operator, and every message delivered to you while you hold one is stored readable. Some operators disable stored passwords entirely for that reason, in which case setting one is refused and the choice is already made for you.
- Sharing your DND schedule (
expose_dnd_schedule) — by default, anyone probing the anonymous do-not-disturb check learns only whether you are away right now, never your calendar; the away windows themselves appear only if you opt in. See What the refused sender sees and the account API. - Alias vs raw wallet — an alias is a friendly public label for a wallet; it does not add privacy (the mapping is on-chain), it adds memorability.
The honest limitations
SithBit hides message contents well and message metadata poorly — by design, because it settles on a public chain. Say this before anyone else does: your social graph is public.
- The social graph is public and permanent. Who mails whom, how often, and when is readable on-chain forever, even though the messages themselves are sealed. Traffic analysis is possible; the plaintext is not.
- Public ciphertext is a long-horizon risk. Default IPFS storage means your
encrypted bodies are publicly archivable — safe against today’s cryptography,
a bet against tomorrow’s.
no_ipfsis the lever if you don’t want to make that bet. - Your purchases are visible. Marketplace buys tie your wallet to the names you acquire, publicly.
- Opting in to campaigns is itself public. Publishing a beacon puts your wallet and the topic tags you picked in the on-chain campaign pool, along with every other field of the beacon account — the profile you attach stays sealed, your participation does not.
None of these are bugs — they are the cost of a trustless, operator-independent system. Knowing them lets you decide what to route through SithBit and what to keep elsewhere.
Further reading
- What’s public and private: the field reference — the exact per-account inventory.
- Trust assumptions and threat model — the adversarial view.
- How sealed-box encryption works and IPFS storage: benefits to users — why publishing ciphertext is safe, and how to opt out.
- The split model: body off-chain, envelope on-chain.
-
“At rest” contrasts with in transit: TLS protects mail moving on the wire, at-rest sealing protects the copy sitting in the operator’s storage. Neither covers the moment the running server handles plaintext — that is the live-operator caveat below. ↩
Getting started
A brand-new user meets SithBit through one of the four web clients — the
webmail app, the
Thunderbird extension, the
Outlook add-in, or the
Chrome extension — and all four walk them through the
same five-step onboarding wizard, because they run the same shared core:
create or import a wallet, then claim a mailbox (and, if you like, a handle)
on-chain in one pass. Nothing here needs the CLI. (If you are comfortable
at a terminal, the CLI’s
sithbit setup wizard does the same
guided pass at the shell, and
sithbit earnings gives a configured
recipient a read-only snapshot of what their wallet holds and has taken in.)
Web onboarding: the browser wizard
The wizard is what a brand-new user sees first: until it finishes, the client hides its normal wallet manager and mail UI and shows only these five steps.
-
Welcome — your wallet. Your wallet address is your email address, so the first thing the wizard needs is a wallet. Three paths:
- Create a new wallet. A fresh Solana keypair is generated entirely in your browser by a WebAssembly module compiled from this workspace’s own crates — the secret never touches a server. The wizard shows that secret key exactly once with a plain warning: save it now, because it is the only copy — lose it and your mailbox is gone forever. You must tick “I have saved my secret key” before you can go on. Copy it somewhere safe (a password manager) before continuing.
- Import an existing keypair. Paste a Solana keypair file — the same JSON
array of 64 numbers
sithbit wallet createwrites — to reuse a wallet you already own. - Connect Phantom or Ledger. If you already run a browser wallet, connect it instead of putting a key in the browser. Your wallet’s signing key never enters the browser — it stays in the extension or on the hardware device, and you approve the mailbox create there. This button only appears when a wallet extension is actually present. Because a hardware wallet cannot itself open sealed mail, this path also creates a separate reading key — see below.
For the create/import paths you then choose a passphrase, typed twice. The wallet is encrypted under that passphrase before it is stored in the client (PBKDF2 + AES-GCM in the browser’s local storage); the decrypted key lives only in memory for the session, so the client asks for the passphrase again next time. The passphrase is not a recovery phrase — it protects the stored copy, but only the secret key from the create step can restore the wallet elsewhere.
The second field is a confirmation, and Save stays disabled until the two agree. This is not ceremony: a mistyped passphrase seals the wallet successfully and fails only later, at unlock, by which point the correct passphrase is whatever you typed once, unseen. An eyeball toggle beside each field reveals what you typed, so you can check it before committing.
The connect-wallet reading key (honest trade-off). SithBit mail is sealed to the recipient, and a Phantom/Ledger wallet can sign but cannot decrypt — its key never leaves the device. So when you connect an external wallet, the wizard generates a separate delegated reading key in your browser and publishes its public half on-chain (senders seal to it). It is revealed once to save, exactly like a fresh keypair, and protected by a passphrase at rest. The trade-offs, stated plainly:
- Your funds-holding wallet key stays off the browser — only this low-value, mailbox-scoped reading key is browser-held. That is the point of the path.
- If you lose the reading key, you lose the ability to read already
received mail — but not your wallet, your funds, or your mailbox. You
can rotate to a new reading key (
mailbox set-key) and keep receiving. - A recoverable, seed-derived reading key (no separate secret to save) is a planned improvement; for now the reading key is a distinct secret you save, just like a keypair.
-
Claim your handle. Optionally register a human-readable alias —
yourhandle— so senders can mailyourhandle@sithbit.cominstead of your raw base58 address. Leave it blank and people simply address mail to your wallet directly; you can always claim a handle later. You may also set the sending domain here (blank uses the protocol default,sithbit.com). -
Set your price. Choose the default postage — the per-message price, in SOL, that an unknown sender must prepay to reach you. This single number is both your spam defence and your earnings: strangers pay it and you keep roughly 90% of what they pay, while you lower it per sender — or waive it — for people you want to hear from (see Economics for how postage and prepaid stamps work). The default matches the CLI’s — one SOL — and it is deliberately a wall rather than a price list: it keeps strangers out until you decide otherwise. If you want to be reachable by people you don’t know yet, or findable by campaigns that pay you to be reached, set a lower default here. An optional checkbox keeps your message bodies off public IPFS (local storage only) from the start.
-
Review. The wizard shows the handle, domain, and default postage it is about to commit, so you can step back and adjust before anything lands on-chain. If the wallet you chose already owns a mailbox, it says so here rather than trying to create a second one. The wizard also checks the wallet’s on-chain balance at this point: because a mailbox create pays a little SOL (the account rents, plus the alias claim fee when you claimed a handle — length-tiered, so a premium 1–4 character handle raises the figure to match its scarcity price), a wallet that can’t cover it gets a plain-language warning here — naming the wallet, what it holds, and how much more to transfer — instead of a raw chain error at the last step. The warning does not block you: you can fund the wallet out of band and go on.
-
Finish. One click mints your mailbox on-chain. If you claimed a handle in step 2, the mailbox create and the alias create ride the same transaction — one signature, one confirmation. On the create/import paths the transaction is built and signed client-side in the wasm module and only the finished, signed transaction is relayed; the server never sees your key. On the connect-wallet path the same bundle — mailbox create, the optional alias, and publishing your delegated reading key — is a single approval in Phantom or Ledger; the wallet signs and submits it. When it confirms, the client swaps out of the wizard and into its normal mail-and-settings UI. If the wallet turns out to be unfunded, the create is refused with the same plain-language funding message rather than the node’s raw “debit an account” error.
Because your wallet is your account, there is no “sign up” step and no server-side password — the five steps above are the whole account. A returning user never sees the wizard: a client that already has a stored wallet asks only for the passphrase to unlock it (and reconnects the extension, on the connect-wallet path), and a wallet that already owns a mailbox goes straight to the normal UI. The per-client specifics of that routing — and where each client reads its server and RPC endpoints — are on the webmail, Thunderbird, Outlook, and Chrome pages.
Onboarding a second wallet from inside a client. The wizard is not only a first-run flow. Once you are signed in, each mail client’s dashboard carries an “Add another wallet” button that re-opens the wizard for a fresh wallet — so an existing user can stand up a second mailbox (a new keypair, or another connected wallet) without forgetting the one they already use. Finishing the new wallet returns you to the normal UI, now able to switch between them.
A standalone get-started page
The same wizard is also served on its own, as a standalone get-started
page you can point a brand-new user at directly — a single shareable URL,
with no mail client installed. It runs the identical five steps and, like the
standalone marketplace page, is served by
account_api from a [[static]] mount whose root is the built
webclients/onboarding/staging bundle (same origin as the API, so no CORS).
When the mailbox lands, the page confirms the
wallet is live and points the user at any SithBit client to sign in. It offers
the same three wallet paths as the in-client wizard — create a fresh keypair,
import one you already hold, or connect a Phantom/Ledger wallet (with the
browser-held reading key described above) — so a brand-new user can complete
onboarding end-to-end from this one page, with no mail client installed.
Self-service pages for refused senders
The same bundle carries two more standalone pages, aimed not at new users
but at senders whose mail a SithBit server just refused — the pages the
refusal replies link when the operator sets
self_service_base_url:
fund.html— the funding page. Reached from a postage refusal (450 4.7.0out of stamps /550 5.7.0no frombox). It resolves the recipient and quotes the stamp price trustlessly off the chain — postage plus the settlement surcharge plus the live protocol fee — then lets the sender connect Phantom or Ledger and buy stamps on the spot, creating the frombox if needed. The purchase is pinned to the price the page quoted, so a repricing between the quote and the click makes the buy refuse rather than charge the new rate.dnd.html— the schedule page. Reached from a do-not-disturb refusal (450 4.2.1). It answers whether the recipient is accepting mail right now, and shows the away windows only for recipients who opted in to sharing them.
What each page tells the sender — and why refusing beats an autoreply in the first place — is on Do not disturb.
Both read their endpoints from two <meta> tags in the page head —
sithbit-rpc-url (the Solana RPC node; both pages) and sithbit-api-url
(the account API; the schedule page) — which an operator edits in the
staged copy. Left empty, the pages fall back to the webmail app’s
localStorage config keys and then the loopback dev defaults; the API
default is the page’s own origin, so the normal deployment — the bundle
served by account_api from a [[static]] mount, like the get-started page
above — needs no setting at all.
GUI clients
Not everyone lives at a terminal. Alongside the sithbit CLI, SithBit ships
four graphical clients — the webmail app, the
Chrome extension, the
Outlook add-in, and the
Thunderbird extension — as the non-CLI ways to use your account. Each
one wraps the same account-management surface in a different host, so which one
you reach for is a matter of where you already read your mail, not what you can
do.
They are not four separate apps that happen to look alike: all four run the
same shared core (webclients/shared/), so a pane behaves identically
wherever you meet it. Everything that must be signed is signed client-side
by a WebAssembly module compiled from this workspace’s own crates — the server
only relays already-signed transactions and never holds your key.
Because they share that core, they also share the same first-run experience: every one walks a brand-new user through the identical five-step browser onboarding wizard — create or import a wallet, then claim a mailbox (and, optionally, a handle) on-chain in one pass. Once you are set up, the same wizard is reachable again from each client’s dashboard to onboard a second wallet.
Pick the client that fits your setup:
- webmail app — a standalone browser app, nothing to install.
- Chrome extension — an installable browser-toolbar popup for account onboarding and wallet management.
- Outlook add-in — for Microsoft Outlook.
- Thunderbird extension — for the Thunderbird desktop mail client.
The three extension pages each open with an Installing from the store section (Chrome, Outlook, Thunderbird) alongside the build-and-sideload path — note the store listings are placeholders until the extensions are published.
Reading and sending mail itself is otherwise unchanged across all four: it flows over ordinary IMAP/POP/SMTP, exactly as it does for the CLI. The one exception is the biggest reason to pick a plugin over a generic mail app: the Thunderbird extension and Outlook add-in also carry Lockbox, automatic end-to-end encryption that seals a message on your device before it ever reaches a server — including your own mail operator’s. Lockbox works over any existing email account and needs no funded wallet and no on-chain mailbox, so it is also the lowest-commitment way to try SithBit: install the plugin, seal your mail, and claim a mailbox later if you want an address that pays you. Everything else these clients do is the account-management and onboarding surface, not a replacement for your mail app.
Operators: everything on these pages is written for the person using a client. The server side — what each one needs of account-api, and how the webmail and marketplace bundles are built and mounted — is Serving the browser clients in the technical reference.
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. Since v0.86.0 the panes sit under a five-tab strip so the dashboard fits without scrolling: Wallet (Balances, Encryption key), Mailbox (Mailbox, Do not disturb, Close mailbox), Names (Aliases, Domains), Services (Reply bounties, Pinning leases) and Sign-in (Settings — the mail password — plus the certificate and connection sections described below). Hovering a tab for half a second shows a one-line note of what it holds. The topbar’s Mail / Settings / Marketplace links pick the view.
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).
Trustless webmail
The webmail app normally rides its account API for everything: login, folders, compose, settings. Trustless mode is the same app with the mail server removed from the loop entirely — leave the API URL blank in the connection settings pane and, from the next load, the wallet alone unlocks the session (no login challenge, no JWT) and every on-chain read and write goes straight to a Solana JSON-RPC node. PDA derivation, account decoding, sealing, and signing all happen in the page, in the same WebAssembly module the normal mode uses; no server ever sees your key, and now no server has to exist at all.
Note the default is not blank: the app is normally served same-origin by account-api, and an absent setting means “this origin”. Trustless mode is the explicitly cleared field — the deliberate statement that there is no account API.
What it needs instead
Three endpoints, all in the connection settings pane (dev defaults in parentheses — the local development stack):
- Solana RPC URL (
http://127.0.0.1:8899, a surfpool dev validator) — every chain read and the transaction submit path. - IPFS gateway URL (
http://127.0.0.1:8183, sithbit-gateway) —GET /ipfs/{cid}for reading sealed bodies. - IPFS pin service URL (
http://127.0.0.1:8182, sithbit-ipfsd, plus its optional bearer token) — where sending pins outbound bodies. Only sending needs it; reading works with the first two alone.
One sithbit-ipfsd
can serve both IPFS roles: its pin surface is POST /pins/… and it
answers GET /ipfs/{cid} for anything it holds, so pointing the gateway
and pin URLs at the same daemon is a complete single-node setup.
What works, and what doesn’t
Everything on-chain works: wallet unlock, the trustless inbox (your mailbox’s message count enumerated newest-first, straight off the message accounts), the trustless reader (fetch the sealed body from the gateway, unseal with the wallet in the page, render), balances, delegated encryption-key management, and the trustless compose described below.
Everything that lives on a server doesn’t, and the app hides those
surfaces rather than degrade them: the server mail view (folders,
search, the Sent copy), compose through the server (and with it any
delivery to non-SithBit addresses — trustless send is on-chain only,
there is no plaintext fallback), account settings (mail password,
timezone, do-not-disturb), and the marketplace and
alias
listings — those ride the gateway’s off-chain index, which a raw RPC
node cannot answer. Resolving an alias you send to still works: that
is a plain account read.
Sending without a server
The trustless compose card follows the same path
sithbit mail send takes, client-side:
- Resolve the recipient — a wallet address or alias — and their published encryption key from the chain.
- Check their mailbox. A mailbox with the
no_ipfsopt-out refuses the send outright: the flag means “my mail never touches public IPFS”, a trustless client cannot deliver any other way, and the opt-out is honored client-side before anything is pinned. - Check your frombox toward them. No frombox yet? The card quotes the postage and points at the Balances pane to buy stamps — your draft stays intact.
- Seal the message to the recipient’s key in the page, pin the
sealed bytes to your configured pin service, then sign and submit the
SendMailtransaction and poll it to commitment.
The signing step follows your session’s wallet flavor. An unlocked in-page wallet signs the transaction right in the page. A connected external wallet (Phantom or Ledger) is asked to approve the same transaction instead — the page builds it unsigned with your wallet as the fee payer, your wallet extension signs and broadcasts it, and the page polls the returned signature to commitment. Either way the sealing happens in the page before anything leaves it; the external wallet only ever sees the finished transaction.
Replying, and attaching a bounty
The trustless reader’s Reply on-chain action seeds this compose
card: the To field takes the decrypted sender (for a
local-only
message, the sealed envelope’s sender), the subject gets its Re:
prefix, and the draft carries the parent message’s account address — the
same privacy-preserving linkage
--reply-to
makes, so only its blake3
hash reaches the chain. A chip on the card shows the linkage; Clear
drops it and keeps the draft. Replying does not require a bounty.
Nor does replying require trustless mode: even with an account API configured, opening a message in the trustless viewer and clicking Reply on-chain opens this card, prefilled the same way — it floats in the corner exactly like the server compose pane. (Earlier releases seeded the draft but left the card hidden in API mode, so the click appeared to do nothing.)
The card’s Attach a reply bounty fields escrow SOL on the message
exactly like
--bounty/--bounty-window:
an amount in SOL (blank = plain send) and a claim window in days
(default 7, the CLI’s default window). A window that would make the
bounty born expired is refused in the page before anything is pinned —
the same rule the chain enforces. Both signing flavors author bounties;
what doesn’t is compose through a mail server, which stays
bounty-less by design — bounty authoring is a direct-signed surface.
No frombox yet? Prepay inline
When step 3 finds no frombox toward the recipient, the card holds your draft and quotes the purchase instead of failing: a stamp-count input (default 1 — the on-chain prepayment rule: a non-owner’s first purchase is at least one stamp) and the exact funding math per stamp — the recipient’s posted postage, the fixed settlement surcharge, and the postoffice’s live per-stamp protocol fee, read off the chain rather than assumed from its genesis default (it is waived only when you buy toward your own mailbox), plus the account rent the chain adds on a first purchase.
Prepay & send buys the stamps with whichever wallet flavor your
session runs — the in-page wallet signs directly; an external
Phantom/Ledger wallet approves the same purchase built unsigned — and,
once the purchase confirms, automatically re-sends the held draft,
bounty and reply linkage included. Whether the purchase opens the
frombox or tops it up is decided by a fresh chain read at click time,
exactly like
frombox stamp’s create-on-first-purchase.
That same read sets the purchase’s
price ceiling
at the per-stamp postage the card quoted you, so a recipient repricing
between the quote and the click makes the buy refuse rather than
overcharge — and your draft is still held.
A purchase still pending past the poll budget does not auto-retry
(it would only bounce off the stamps check) — the card keeps the draft
and says to send again shortly. The Balances pane remains the standing
place to buy stamps outside a compose, and its purchases ride both
signing flavors too, exactly like this card’s own Prepay & send:
the in-page wallet signs directly, an external Phantom/Ledger wallet
approves the same purchase built unsigned.
The receipt line carries the on-chain message id and transaction
signature. There is no Sent folder in this mode — the on-chain message
account is the record.
The pin lifecycle caveat (operators, read this)
A body pinned by a trustless client sits outside any mail server’s pin lifecycle. When mail is deleted or settled, a sithbitd operator’s pipeline releases the pin its own delivery made — it has no idea your daemon holds a client-made pin for the same message, and it will never unpin it. The client’s pin is the client’s lifecycle, exactly like the per-recipient pin providers’ standing rule that settlement releases the operator’s pin, never yours: a client-pinned body stays on the daemon it was pinned to until someone removes it there.
So an operator offering a pin service to trustless webmail users is
signing up to store senders’ outbound bodies indefinitely, on a surface
browsers can write to. Treat it accordingly: set the daemon’s
auth_token (the pin URL settings take a bearer token) or put network
isolation in front, mind the max_pin_bytes cap, and own the retention
story — sweeping stale client pins is your policy, because no protocol
sweep will do it.
The live proof
webclients/shared/test/e2e-noapi.test.js is this chapter runnable: the
seeded dev validator plus one sithbit-ipfsd, no account-api and no
mail server, driving the real wasm bundle through enumeration, the
postage affordance, a client-pinned send, and the recipient’s trustless
read-back. The env-gated runbook is in the file header.
The Chrome extension
An installable Chrome MV3 extension for using a SithBit account from the
browser: wallet login, the onboarding wizard, aliases, balances,
do-not-disturb schedules, delegated encryption-key management, and an
in-popup mail reader — the same shared panes the
Thunderbird,
Outlook, and
webmail clients carry, because all four hosts run the same
shared core (webclients/shared/). Everything that must be signed is
signed inside the extension by a WebAssembly module compiled from
this workspace’s own crates; the server only relays already-signed
transactions and never holds your key.
It reads mail two ways, exactly as webmail does. When an
account-api is reachable it shows the full
three-pane reader — folder rail, message list, message view, compose, and
search — over the API’s /v1/mail surface. With the mail server down (or
no account API configured at all), it falls back to a trustless on-chain
inbox: your mailbox’s messages listed straight from Solana and read by
unsealing their IPFS-stored bodies in wasm, no server involved. You can
still also point any IMAP/ POP/SMTP mail client at the same account.
Either way this same surface also runs in Chrome’s side panel, a
persistent panel that stays open as you browse (the toolbar-icon click
still opens the transient popup; an Open in side panel button promotes
it). It is not a Gmail integration — it is a self-contained SithBit mail
client, packaged as a browser extension.
What you need from your operator
Nothing, to start: the extension installs on your own machine. The panes that read the chain — aliases, balances, encryption keys — do need your operator’s account-api to have its chain surface configured, and report themselves unavailable when it does not; login and the settings panes keep working either way. The trustless on-chain inbox needs no operator at all, only a Solana RPC endpoint and an IPFS gateway you set under Connection settings — which is why it is the reader you get with the account API url left blank.
Operators: what to configure is Serving the browser clients.
Installing from the store
Once the extension is published, install it from the
Chrome Web Store:
open _todo_store_listing_url_ (or search the store for
_todo_store_listing_name_) and click Add to Chrome. Store
installs update automatically.
Note: the extension is not yet published to the Chrome Web Store. The
_todo_store_listing_name_and_todo_store_listing_url_placeholders resolve when the listing goes live; until then, install via Building and installing below.
For pre-release distribution the Web Store offers two non-public visibilities — Unlisted (installable by direct link, never surfaced in search) and Private (restricted to a trusted-tester group or a Google Workspace domain) — plus draft sharing with trusted testers, so a pre-GA listing can exist without public exposure. The load-unpacked path below remains the zero-store development route.
Building and installing
Until the store listing is live this is how you install the extension — building it yourself, from this workspace:
cd webclients/chrome
./build.sh # wasm-pack build + stages shared/ + zips the package
build.sh writes two things: a staging/ directory (the unpacked
extension) and sithbit-chrome.zip (the same tree, packaged). Install
it either way:
- Load unpacked (development). Open
chrome://extensions, enable Developer mode, choose Load unpacked, and point it atwebclients/chrome/staging. - Packaged. Distribute or side-load
sithbit-chrome.zip.
An extension package cannot reference files outside its own root, so the
shared core and the wasm module are copied into staging/ at build time
— always load staging/, never the source tree. The manifest requests
the storage and sidePanel permissions and host permissions for
http://127.0.0.1 / http://localhost by default, with https://* as
an optional grant for pointing it at a remote API.
What it does
Opening the popup routes to one of a few views from the session it probes (see the onboarding wizard):
- Wallet manager — import more than one Solana keypair file (a JSON array of 64 numbers) into the same Chrome profile, each sealed under its own passphrase (PBKDF2 + AES-GCM), and unlock, switch between, lock, or forget them. Decrypted keys live only in memory and are gone when the browser exits, so each session re-prompts for the passphrases you want unlocked. One unlocked wallet is active at a time; every pane acts on it, and switching between already-unlocked wallets needs no passphrase.
- Onboarding wizard — a brand-new profile (no wallet stored) or an unlocked wallet with no mailbox drops straight into the shared five-step wizard: create or import a wallet, claim an optional handle, set your default postage, and mint your mailbox on-chain — the mailbox and its alias ride one signed transaction built in wasm.
- Dashboard panes — once unlocked and mailbox-registered, under the popup’s Mail / Account / Marketplace switch (the popup opens on Mail) and, in the Account view, the shared five-tab strip — Wallet, Mailbox, Names, Services, Sign-in, each with a half-second hover note — so the narrow popup fits without scrolling: Balances (wallet SOL, on-chain message count, per-sender stamp lookup, plus an outbound Quote that pins a purchase to the price it shows and a Reclaim unspent button for idle postage in a frombox of your own — see the webmail write-up of the same shared pane), Aliases (every alias pointing at your wallet; registration and transfer stay in the CLI), Domains (list a domain you hold for sale on the open marketplace, or buy a listed one by name and current authority; reassigning a domain’s authority is an operator action, not offered here), Reply bounties (settle reply bounties on-chain — claim one on a message you received and replied to, or refund one you placed that went unclaimed past its deadline), 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; see pinning leases), Encryption key (publish, rotate, or close a delegated X25519 key; export the secret when it is shown — it appears once), Mailbox (claim your on-chain mailbox and set its registration config — an optional handle, the sending domain, the default postage unknown senders pay per stamp, and an on-chain-only (no IPFS) option; once registered it shows the message count and points per-sender price changes to Balances), Settings (the derived mail password for the active wallet and its IANA timezone), and Do not disturb (weekly or date-range retry windows). Close mailbox sits alongside them and is the one pane that does not act at once: closing runs a two-step, 7-day timelock, so the request only starts a clock — the mailbox stays open and keeps receiving mail, nothing is refunded, and a cancel is offered the whole time — and the rents come back only when you return after the wait and finish the close. Both reach your wallet for a mailbox you created yourself; a sponsored mailbox returns its mailbox rent to the sponsor instead, and the pane says so. The delay prices identity-cycling in time, since an instant refund made discarding a burned sending identity free.
- Mail — the shared three-pane reader (folder rail, message list,
message view, compose, and search) over the account API’s
/v1/mailsurface. This is the primary in-popup reader, shown whenever an account API is reachable; it is the same reader webmail and the Outlook add-in mount. - On-chain inbox — your mailbox’s messages listed newest-first straight from Solana, no mail server: open a row to read it in the Trustless viewer below. A KEPT pane in both modes, and — with the three-pane reader above — the whole of the in-popup mail experience when the mail server is down.
- Trustless viewer — reads a single on-chain message with nothing but a Solana RPC endpoint, any IPFS gateway, and your unlocked wallet, with the SithBit mail server completely down: pick a message from the On-chain inbox (or enter a mailbox message id) and the extension resolves the message account for its sealed-body CID, fetches the sealed bytes from the gateway, and unseals them in wasm. A wallet-only read only works if the body was sealed to your wallet — a recipient who published a delegated key needs that key’s secret instead. Its header also carries a Reply on-chain action — see Trustless reply and compose below. Once a body has rendered, a Lease this message button beside Reply prefills the Pinning leases pane (Account view, Services tab) with the open message’s CID and id — prefill only: you still review the deposit and submit, and entering a CID by hand works as before.
- Open in side panel — promotes the whole popup into Chrome’s persistent side panel, which stays open while you browse instead of closing on blur like the toolbar popup. It is the same app either way.
- Connection settings — where the popup points: the account API, the Solana RPC, the IPFS gateway, and — for trustless sending — the IPFS pin service. This popup never connects to the mail servers itself. They sit in a collapsed disclosure at the foot of every view, including first-run onboarding and the unlock gate — deliberately, and not merely for convenience: reaching them must never depend on being signed in, because signing in depends on the account API these settings configure. If the configured API is unreachable, the popup says so and continues, so you can correct the URL — or blank it to run trustlessly — from where you already are.
Trustless reply and compose
Reading is no longer the trustless surface’s whole story: the
Trustless viewer’s header carries a Reply on-chain button, and
the popup mounts the same floating on-chain compose card as
trustless webmail — a
Compose on-chain button opens it blank; Reply seeds it with the
decrypted sender and the parent message’s account address. The card is
always available (it rides the on-chain path, not the account API) and
follows the webmail card’s lifecycle exactly: resolve the recipient and
their published key on-chain, seal in the extension’s wasm module, pin
the sealed bytes to your configured IPFS pin service, then sign and
submit the SendMail transaction. The same seam ships in the
Thunderbird extension and
the Outlook add-in.
A reply sent this way is an on-chain send, not an SMTP compose: it never touches an outgoing mail server, and the operator’s SMTP/IMAP/spooler pipeline plays no part in delivery — the reply lands as an on-chain message account even while those servers are down. Chain reads, sealing, and pinning go straight to your configured endpoints; the transaction is signed in the extension’s wasm module like every other on-chain action here, and as always nothing the popup talks to can alter what you sign (see Security notes).
The card carries webmail’s three affordances, documented in full there rather than restated here:
- The reply chip — the draft threads to the parent message with the
same privacy-preserving linkage
--reply-tomakes (only its blake3 hash reaches the chain); Clear drops the link and keeps the draft. See Replying, and attaching a bounty. - Attach a reply bounty — an amount in SOL (blank = plain send) and
a claim window in days escrow SOL on the message exactly like
--bounty/--bounty-window. - Inline prepay — no frombox toward the recipient yet? The card holds the draft, quotes the postage (live per-stamp protocol fee included), and Prepay & send buys the stamps then re-sends the held draft once the purchase confirms — the same prepayment rule and behavior as webmail’s card. The Balances pane remains the standing place to buy stamps outside a compose.
Sending needs one endpoint reading does not: Connection settings
gains an IPFS pin service URL (ipfsPinUrl, default
http://127.0.0.1:8182 — an unauthenticated loopback
sithbit-ipfsd) plus its optional bearer
token (ipfsPinToken, default empty), where outbound sealed bodies are
pinned. As with a remote API origin, saving a non-loopback pin URL
prompts for that origin’s host permission on the same Save click — the
extension ships with loopback-only host permissions. Operators offering
that pin surface should read
the pin lifecycle caveat:
a client-made pin sits outside any mail server’s pin lifecycle.
Configuration
There is no hosted config: the endpoints live in chrome-store (backed by
chrome.storage.local) under the Connection settings pane, each with a
loopback dev default so a fresh install runs against a local stack with
nothing to set —
apiUrl— the account API (defaulthttp://127.0.0.1:8180).rpcUrl— the Solana JSON-RPC endpoint for trustless viewing (default a local surfpool athttp://127.0.0.1:8899).gatewayUrl— the IPFS gateway servingGET /ipfs/{cid}(defaulthttp://127.0.0.1:8183— e.g. a sithbit-gateway).ipfsPinUrl— the IPFS pin service that receives the sealed body on a trustless send (defaulthttp://127.0.0.1:8182— an unauthenticated loopback sithbit-ipfsd).ipfsPinToken— the optional bearer token for that pin service (default empty — the loopback default needs no auth).
Security notes
- The wallet secret at rest is exactly as strong as your passphrase.
- The session token authorizes account changes (password, timezone, DND) for up to 24 hours; on-chain actions additionally require the unlocked wallet, which never survives a browser exit.
- The API relay cannot alter what you sign: transactions are built from the workspace’s own instruction encoders compiled to wasm, and a parity test pins them byte-for-byte to the CLI’s.
The Outlook add-in
An Office.js taskpane add-in for self-service on a SithBit account:
wallet login, mail password, timezone, do-not-disturb schedules,
aliases, balances, and delegated encryption-key management — the same
panes as the Thunderbird extension, because both hosts run the same
shared core (webclients/shared/). Everything that must be signed is
signed inside the pane by a WebAssembly module compiled from this
workspace’s own crates; the server only relays already-signed
transactions and never holds your key.
Runs in new Outlook on Windows and Outlook on the web via the unified
JSON manifest. On classic Windows desktop Outlook the add-in
installs via its XML manifest instead (build.sh renders it to
package/manifest.xml), and Lockbox sealing is not available at
send time there: the classic send runtime gives the hook no module
loading and no network access, so the send hook states that sealing is
unavailable and lets your message send exactly as you typed it. Not
Outlook for Mac — the unified JSON manifest doesn’t run there; the
same legacy XML manifest route is the recorded fallback.
What you need from your operator
An Office add-in is an https-hosted web page, so the pane itself is served by your operator’s account-api — you will be given that origin, and the manifest you sideload below points at it. As with Thunderbird, the aliases, balances, and encryption-key panes need that API’s chain surface configured; login and the settings panes work without it.
Operators: what to serve and how to mount it is Serving the browser clients.
Installing from the store
Once the add-in is published, install it from
Microsoft Marketplace (formerly
Microsoft AppSource): in
Outlook open Apps → Get Add-ins and search for
_todo_store_listing_name_, or open _todo_store_listing_url_ in a
browser and choose Get it now. Store installs update automatically.
Note: the add-in is not yet published to Microsoft Marketplace. The
_todo_store_listing_name_and_todo_store_listing_url_placeholders resolve when the listing goes live; until then, install via Building and sideloading below.
Microsoft Marketplace listings are public-only and Microsoft-validated
— there is no unlisted tier. The supported private paths are sideloading (the
atk flow below) and Microsoft 365 admin center → Integrated Apps →
Upload custom app, which deploys the add-in privately to a whole
tenant — the de-facto org-wide pre-release deployment.
Building and sideloading
Until the marketplace listing is live this is how you install the add-in — building the package yourself, from this workspace:
cd webclients/outlook
./build.sh # wasm-pack build + stages shared/ into staging/ + packages sithbit-outlook.zip
Sideload sithbit-outlook.zip with the Agents Toolkit CLI
(atk auth login m365, then atk install --file-path sithbit-outlook.zip) or via Teams → Apps → Upload a custom app. The
Outlook-web “Add-Ins” upload dialog accepts only XML manifests — use
one of the two paths above. On classic Windows desktop Outlook,
sideload the rendered package/manifest.xml instead (aka.ms/olksideload
→ My add-ins → Add a custom add-in → Add from file) — that XML manifest
is what carries the add-in there. The full dev runbook, including the
localhost-https trust step, lives in webclients/outlook/README.md.
First run and onboarding
The first time you open the pane in a browser profile with no wallet
stored yet, it opens on the shared five-step
onboarding wizard
instead of the wallet manager. It creates or imports a wallet (the create
branch shows your secret key once, with the save it — it is the only
copy gate), claims an optional handle, sets your
default postage, and mints your mailbox
on-chain — mailbox plus, if claimed, its alias in one transaction, signed
in the pane’s wasm module and relayed through /v1/chain. Until it
finishes, the pane shows only the wizard.
The pane routes to one of four views from the session it probes on open:
- Onboarding wizard — no wallet stored yet, or an unlocked wallet with no mailbox (a returning user finishing setup).
- Unlock — a stored wallet that is locked this run; the shared wallet-list pane below owns the passphrase prompt.
- Dashboard — unlocked and mailbox-registered: the normal panes.
- (a brief loading view while it probes.)
The add-in needs no endpoint configuration for onboarding: because the bundle is served same-origin from account-api, it infers the API base URL from the page’s own origin, and the onboarding create relays through that same API. Only the optional trustless fallback and its on-chain compose read endpoints of their own (RPC, IPFS gateway, and pin service, from localStorage).
Using it
Select any message and open Apps → SithBit Account (the pane is
pinnable, so it stays open as you move around). The pane opens on a
wallet manager, not a single-account gate: paste a Solana keypair file
and choose a passphrase to import it — the key is encrypted with that
passphrase before it is stored in the browser’s storage — and repeat
for as many SithBit addresses as you want available in this browser
profile. Each is unlocked with its own passphrase (needed again each
time the taskpane opens), several can stay unlocked at once, and
switching which one is active needs no passphrase. The settings,
DND, aliases, balances, mail view, and delegated-key panes all act on
the active wallet, each on-chain action signed client-side in wasm and
relayed through /v1/chain. As with Thunderbird, each address still
needs its own native Outlook mail account — Office.js add-ins run
inside an already-configured mailbox and have no API to provision one.
Every flow above is exercised headlessly by the env-gated live suite
webclients/outlook/test/e2e-outlook.test.js — the bundle served from
a real account-api, login and settings through the real storage
adapters, and the key lifecycle confirmed on-chain.
Panes
The taskpane mounts the same shared dashboard the other clients do, so the pane set — and its five-tab strip (Wallet / Mailbox / Names / Services / Sign-in, with a half-second hover note on each tab) — is identical to Thunderbird’s. The taskpane’s own Mail / Settings / Marketplace buttons pick the view; the panes are the Settings view.
- Balances — wallet SOL, the on-chain mailbox message count, and a per-sender stamp lookup (stamps are held per sender, so there is no single total). Outbound, Quote shows the live per-stamp price toward a recipient and pins the purchase to it; Reclaim unspent takes idle postage back out of a frombox of your own — see the webmail write-up, which describes the same shared pane.
- Aliases — the aliases pointing at the active wallet. Registering and transferring them stays in the CLI.
- Domains — list a domain you hold for sale on the open marketplace, or buy a listed one by name and current authority. Reassigning a domain’s authority is an operator action, not offered here.
- Reply bounties — settle reply bounties on-chain: claim one on a message you received and replied to (bountied message id, original sender, your reply’s id), or refund one you placed that went unclaimed past its deadline.
- Pinning leases — escrow a refundable deposit asking operators to keep a message’s pinned body past the default retention (pinning leases): create one by the message’s CID and id (deposit prefills to the protocol minimum; a one-time fee applies), check a CID for your wallet’s lease, or close it anytime to reclaim the deposit — even after the message has settled. The trustless fallback reader’s Lease this message button (beside Reply, shown once a body has rendered) prefills this pane’s create fields with the open message’s CID and id, switches the taskpane to the settings view (a plain view switch — the taskpane keeps no history) and opens the panes’ Services tab; you still review the deposit and submit, and manual CID entry works as before.
- Encryption key — publish, rotate, or close a
delegated X25519 key,
signed in wasm and relayed through
/v1/chain. Export the secret while it is on screen — it is shown once, and mail sealed to an old key still needs that key. - Settings — the derived mail password for the active wallet (see The mail password below) and its IANA timezone.
- Do not disturb — weekly or date-range windows in which senders are asked to retry later, evaluated in your timezone.
- Mailbox — claim your on-chain mailbox and set its registration config: an optional handle (claimed as an alias in the same transaction), the sending domain (blank uses sithbit.com), the default postage unknown senders pay per stamp, and an on-chain-only (no IPFS) option. Once registered the pane shows the message count and points per-sender price changes to the Balances pane.
- Close mailbox — request, cancel, or finish closing the mailbox. This one is not instant: the request starts a fixed 7-day wait and refunds nothing, the mailbox stays open and keeps receiving mail meanwhile, and the pane offers Cancel for the whole period. Only after the clock runs out does finalizing become available, closing the mailbox and returning its rent and the transient request record’s — both to your wallet for a mailbox you created yourself, while a sponsored mailbox sends the mailbox rent back to the sponsor that funded it and leaves you the request record’s. The pane says which happened. The wait exists because an instant refund made discarding a burned sending identity free; it costs an honest owner time on a rare action, not money. The Encryption key close above is unaffected and stays instant.
Zero-config setup (autodiscover)
If the operator runs the domain-sithbit
service, Outlook’s native Add Account flow can fill in the
IMAP/ POP/SMTP connection settings for you — no add-in, no hand-typed
servers. Type your address as <wallet-base58>@<domain>; Outlook POSTs it
to the domain’s autodiscover endpoint and auto-fills the servers, ports,
and TLS modes from the operator’s advertised coordinates.
Two limits, by design:
- It does not create the account. Autodiscover fills in connection
settings only; native account auto-provisioning is deliberately not
offered. Your mailbox must already exist on-chain (
sithbit mailbox create) before you connect. - It does not set your password. The login name is auto-filled as your
wallet address (the base58 public key — the
@domainis not leaked), but you still supply a mail password. Get it from the operator’s self-serve enrollment page (/addin/enroll.htmlon the account API) or offline from the CLI — see The mail password just below.
Where the operator hasn’t enabled autodiscover, enter the same settings by hand; the username and password rules are identical.
The mail password
Like the Thunderbird extension, your IMAP/POP/SMTP client authenticates as your wallet with no separate stored password. The “mail password” is a wallet signature over a fixed challenge that the servers verify against your wallet address; nothing is stored server-side, so a password on the account is now optional (see the account API).
Get the credential for the active wallet from the Settings pane’s Copy mail password button, or offline from the CLI:
sithbit mailbox credentials --keypair ~/.config/solana/id.json
The same pane’s Rotate the wallet mail password… button retires it when
you need to: a two-step control that bumps your account’s auth epoch, so
every copy of the old password — this add-in’s included, until you copy the
new one — stops authenticating at once, and the CLI derives the replacement
with sithbit mailbox credentials --epoch <N>. It rotates the wallet-derived
password alone; a stored mail password, your session, and a
client-certificate login are
unaffected. See
Rotating the wallet mail password.
Rotation is
step-up gated:
the server refuses it with 428 Precondition Required until the request
carries a wallet signature made seconds ago, so a live session alone cannot
change your mail credentials. The pane answers that for you — it fetches the
challenge and has your wallet sign it, which is the approval prompt a Phantom
or Ledger wallet raises — and what surfaces in the add-in is only the case
where no signature could be produced (locked or disconnected wallet, declined
prompt, stale challenge): “This change needs a fresh wallet signature. Make
sure your wallet is unlocked (or reconnected) and try again.” Do exactly
that — unlock or reconnect, arm the control again, approve the prompt.
Waiting achieves nothing, because every attempt spends its own challenge and
needs its own signature; and a refused rotation leaves the epoch and the
password this add-in already holds untouched. Offline, the same signer is
sithbit mailbox sign-text,
signing the challenge string with --keypair.
Enter it in your mail client as username = your wallet address (base58
public key), password = the derived base58 signature, mechanism = SASL
PLAIN over TLS (the signature is bearer-equivalent, so always use TLS).
This is the tested path for reading mail (POP3/IMAP retrieval);
wallet-authenticated submission authenticates identically, and its
envelope sender is fixed to exactly your own wallet address at a domain
the submission server serves — and, when that server is connected to the
chain, one your wallet also owns on-chain (the domain’s recorded
authority), not merely any domain the server happens to serve. Set the
Outlook account’s email address to <wallet-base58>@<the operator's domain>,
the same string the add-in POSTs to autodiscover. Another wallet’s address,
your address at a domain that server does not serve (or, on a chain-connected
server, that your wallet does not own on-chain), and a case-variant of your
own base58 (base58 is case-sensitive, so the match is exact) are each refused
553 5.7.1; the
Thunderbird page and the configuration
reference
carry the full rule and the chain-less-dev-stack gotcha. Regenerating the
credential is free and offline.
Client-certificate login (SASL EXTERNAL)
The passwordless alternative to the mail password above, exactly as on
Thunderbird:
instead of pasting a signature, you hand your mail client a TLS client
certificate minted from your wallet, and the server authenticates you
over the TLS handshake — no password entered, nothing stored server-side.
It requires client_cert_auth on the listener (see the
configuration reference).
-
Mint the certificate — offline, no RPC:
sithbit mailbox create-cert --keypair ~/.config/solana/id.json --out sithbitThis writes
sithbit.p12— the combined, password-less PKCS#12 bundle meant for your certificate store’s import dialog — alongsidesithbit.crtand its PKCS#8 keysithbit.keyas a PEM pair for other apps (omit--outto print just the PEM blocks to stdout). The certmgr / Keychain import in step 2 hasn’t been walked end-to-end here yet; Thunderbird’s own certificate store is known to refuse this bundle — see the known issue. The certificate is a self-signed Ed25519 leaf whose public key is your wallet address — no CA, no expiry to manage. The.p12and.keyembed your wallet secret; guard them like the wallet itself. -
Install it. The Office.js taskpane above manages only the account settings, not the mailbox’s IMAP/POP/SMTP transport, so a client certificate is configured in the host mail app, not the add-in. In classic Outlook that is File → Options → Trust Center → Email Security → import your certificate — pick
sithbit.p12and leave the password prompt blank (the bundle is password-less) — then attach it on the account’s outgoing/incoming server security settings; on new/web Outlook the certificate is selected by the OS/browser certificate store when the server requests one. Set each of the account’s SMTP, IMAP, and POP entries to SSL/TLS and select this certificate as the client certificate. -
Leave the password blank — with EXTERNAL the certificate is the whole credential; keep your wallet address as the username.
The add-in mints the same files without the CLI: the taskpane’s
settings view carries a Certificate sign-in section on the panes’
Sign-in tab, whose Download client certificate button derives
everything in the wasm module and saves three files named after your
wallet address: the combined <pubkey>.p12 first, then the
<pubkey>.crt / <pubkey>.key PEM pair for other apps —
byte-for-byte what sithbit mailbox create-cert writes. Importing
stays manual as in step 2 — an Office add-in cannot touch the OS
certificate store — so bring the .p12 into certmgr on Windows or
Keychain Access on macOS (or your mail app’s own certificate settings)
— the same import caveats as step 1 apply. The .p12 and .key
files embed your wallet secret;
guard them like the wallet itself. The button needs a
wallet unlocked in the add-in’s wasm module: while every wallet is
locked it is disabled, and an external signer (a Phantom or Ledger
wallet) reads as locked too — it holds no local seed to derive from,
so external-wallet users stay on the CLI path above.
Regenerate and reinstall it freely; it carries no secret beyond your wallet key, which never leaves your machine — the derivation is fully deterministic per wallet, so re-downloading on any device yields the identical certificate.
Trustless (server-down) viewing
The taskpane wires two read paths. The primary one is the server
mail view over account-api’s /v1/mail surface (the same three-pane
reader the webmail app shows). Behind it sits a trustless fallback
that reads a message straight from a
Solana RPC endpoint and an IPFS
gateway with the mail server down: it resolves the message’s
on-chain account for its sealed-body CID, fetches the sealed bytes from
the gateway, unseals them in wasm with your unlocked wallet, and renders
the MIME — HTML in a fully sandboxed frame that blocks scripts and
remote loads. The fallback is a no-op until the wallet is unlocked.
Its two endpoints come from localStorage and default to the dev
stack: the Solana RPC (config.rpcUrl, default the local surfpool at
http://127.0.0.1:8899) and the IPFS gateway (config.gatewayUrl,
default http://127.0.0.1:8183 — e.g. a
sithbit-gateway). Paste a bare URL into
either key to point the fallback at a remote endpoint.
The same two caveats as the Thunderbird extension apply:
- A wallet-only read only works if the body was sealed to your wallet; a recipient who published a delegated encryption key needs that delegated secret instead.
- Under the default
[spooler.settle]auto-settle worker a settled message is unpinned past its window (keep_pin = false), so trustlessly reading an old, settled message depends on the operator having kept the pin (keep_pin = true).
Trustless reply and compose
Viewing is no longer the fallback’s whole surface: the trustless
reader’s header carries a Reply on-chain button, and the mail view
mounts the same floating on-chain compose card as
trustless webmail —
a Compose on-chain button opens it blank; Reply seeds it with the
decrypted sender and the parent message’s account address. The card is
always available (the taskpane has no trustless mode to switch into)
and follows the webmail card’s lifecycle exactly: resolve the recipient
and their published key on-chain, seal in the pane’s wasm module, pin
the sealed bytes to your configured IPFS pin service, then sign and
submit the SendMail transaction. The same seam ships in the
Thunderbird extension.
A reply sent this way is an on-chain send, not a host SMTP compose:
it never touches the Outlook account’s own outgoing server, and the
operator’s SMTP/IMAP/spooler pipeline plays no part in delivery — the
reply lands as an on-chain message account even while those servers are
down. Chain reads, sealing, and pinning go straight to your configured
endpoints; the signed transaction is relayed through account-api’s
/v1/chain like every other on-chain action in the pane, and as
always the relay cannot alter what you sign.
The card carries webmail’s three affordances, documented in full there rather than restated here:
- The reply chip — the draft threads to the parent message with the
same privacy-preserving linkage
--reply-tomakes (only its blake3 hash reaches the chain); Clear drops the link and keeps the draft. See Replying, and attaching a bounty. - Attach a reply bounty — an amount in SOL (blank = plain send) and
a claim window in days escrow SOL on the message exactly like
--bounty/--bounty-window. - Inline prepay — no frombox toward the recipient yet? The card holds the draft, quotes the postage (live per-stamp protocol fee included), and Prepay & send buys the stamps then re-sends the held draft once the purchase confirms — the same prepayment rule and behavior as webmail’s card. Settings → Balances remains the standing place to buy stamps outside a compose.
Sending needs one endpoint viewing does not: the connection-settings
pane gains an IPFS pin service URL (config.ipfsPinUrl, default
http://127.0.0.1:8182 — a
sithbit-ipfsd) plus its optional bearer
token (config.ipfsPinToken, default empty), where outbound sealed
bodies are pinned. Operators offering that pin surface should read
the pin lifecycle caveat:
a client-made pin sits outside any mail server’s pin lifecycle.
The Thunderbird extension
A MailExtension for self-service on a SithBit account: wallet login, mail password, timezone, do-not-disturb schedules, aliases, balances, and delegated encryption-key management. Everything that must be signed is signed inside the extension by a WebAssembly module compiled from this workspace’s own crates — the server only relays already-signed transactions and never holds your key.
Requires Thunderbird 140 or later.
What you need from your operator
Only an account-api to point the extension at — there is nothing for an operator to host, since a MailExtension installs from a file on your own machine. For the aliases, balances, and encryption-key panes that API needs its chain surface configured, and reports those panes as unavailable when it does not; login and the settings panes keep working either way.
Operators: what to configure is Serving the browser clients.
Installing from the store
Once the extension is published, the one-click route is
addons.thunderbird.net (ATN): search
for _todo_store_listing_name_ in the Add-ons Manager (gear menu →
Add-ons and Themes) and install it directly from the search results,
or open _todo_store_listing_url_ in a browser and install from the
listing page. Store installs update automatically.
Note: the extension is not yet published to addons.thunderbird.net. The
_todo_store_listing_name_and_todo_store_listing_url_placeholders resolve when the store listing goes live; until then, install via Building and installing below.
Thunderbird MailExtensions need no signing, so self-distributing the
.xpi — the build path below — is a fully supported permanent
channel, not a workaround. ATN listings are public-only (no unlisted or
private tier), which makes the store listing an optional later step
rather than a pre-release necessity.
Building and installing
With no store listing yet — and none needed, per the note above — this is the
install route: build the .xpi yourself, from this workspace.
cd webclients/thunderbird
./build.sh # wasm-pack build + stages shared/ + zips the xpi
Install sithbit-thunderbird.xpi via Thunderbird’s Add-ons Manager
(gear menu → Install Add-on From File). Thunderbird accepts
self-built, unsigned xpi files permanently — no store listing is
needed. For development, point Load Temporary Add-on (Tools →
Developer Tools → Debug Add-ons) at thunderbird/staging/manifest.json.
The extension ships with host permissions for http://localhost /
http://127.0.0.1 only. Pointing it at a remote API (extension
options) prompts for that origin’s permission when you save.
First run and onboarding
Opening the SithBit dashboard for the first time — with no wallet stored in this Thunderbird profile yet — drops you straight into the shared five-step onboarding wizard rather than the wallet manager. It creates or imports a wallet (the create branch reveals your secret key once, with the save it — it is the only copy gate), claims an optional handle, sets your default postage, and mints your mailbox on-chain — the mailbox and, if you claimed a handle, its alias ride one signed transaction, built and signed in the extension’s wasm module. Until it finishes, the dashboard shows only the wizard.
The dashboard routes to one of four views from the session it probes on open:
- Onboarding wizard — no wallet stored yet, or an unlocked wallet with no mailbox (a returning user finishing setup).
- Unlock — a stored wallet that is locked this run; the shared wallet-list pane below owns the passphrase prompt.
- Dashboard — unlocked and mailbox-registered: the normal panes.
- (a brief loading view while it probes.)
The onboarding relay and the trustless endpoints all come from the
extension’s options page, not from any hosted config: the
account-API origin (which also prompts for its host permission when you
save a remote one), the Solana RPC (config.rpcUrl), the IPFS
gateway (config.gatewayUrl), and — for the
on-chain compose — the IPFS pin
service (config.ipfsPinUrl plus its bearer token), each defaulting to
the local dev stack. See
Trustless (server-down) viewing
for the read-path endpoint defaults.
Wallets and their passphrases
The SithBit button in the spaces toolbar opens a wallet manager, not a single-account gate — you can import more than one Solana keypair file (a JSON array of 64 numbers) into the same Thunderbird profile, each under its own passphrase, and unlock several of them at once. Every imported wallet is encrypted with its passphrase (PBKDF2 + AES-GCM) before it is stored in the Thunderbird profile; decrypted keys live only in memory and are gone when Thunderbird exits, so each restart asks for the passphrase again for whichever wallets you want unlocked this session.
Unlocking a wallet is a login challenge: the API issues a nonce, the extension signs it with that wallet, and a day-long session token comes back. No password ever exists for login — the wallet is the account. One unlocked wallet at a time is active; the settings, balances, aliases, keys, and trustless-viewer panes all act on whichever wallet is active, and switching between already-unlocked wallets needs no passphrase. This lets one profile manage several SithBit addresses (e.g. a personal one and a work/domain one), but each address still needs its own separate account added through Thunderbird’s native Add Mail Account wizard — Thunderbird’s stable extension API has no way to create that account programmatically (see The mail password below for what to enter there).
Panes
Since v0.86.0 the panes sit under a five-tab strip so the dashboard fits without scrolling: Wallet (Balances, Encryption key), Mailbox (Mailbox, Do not disturb, Close mailbox), Names (Aliases, Domains), Services (Reply bounties, Pinning leases) and Sign-in (Settings — the mail password — plus the certificate and connection sections described below). Hovering a tab for half a second shows a one-line note of what it holds. Above the strip, a Mail / Account / Marketplace switch picks the view; the panes are the Account view. The dashboard opens on Account — Thunderbird itself reads your server mail — and the Mail view holds the on-chain viewer and composer.
- Balances — wallet SOL, on-chain mailbox message count, and a per-sender stamp lookup (stamp balances are held per sender, so there is no single “total stamps” number). Outbound, Quote shows the live per-stamp price toward a recipient and pins the purchase to it; Reclaim unspent takes idle postage back out of a frombox of your own — see the webmail write-up, which describes the same shared pane.
- Aliases — every alias pointing at your wallet. Registration and transfer stay in the CLI.
- Domains — list a domain you already hold for sale on the open marketplace, or buy one that’s listed by name and current authority. Reassigning a domain’s authority is an operator action and isn’t offered here. Needs the unlocked wallet.
- Reply bounties — settle reply bounties on-chain: claim one on a message you received and replied to (giving the bountied message’s id, the original sender, and your reply’s id), or refund a bounty you placed that went unclaimed past its deadline.
- Pinning leases — escrow a refundable deposit asking operators to keep a message’s pinned body past the default retention (pinning leases). Create a lease by the message’s CID and id — the recipient defaults to your own mailbox, the deposit prefills to the protocol minimum, and a one-time creation fee applies — then check whether your wallet holds a lease on a CID, or close it anytime to reclaim the deposit. Closing works even after the message itself has settled: the lease is addressed by the CID, not the message. The trustless reader further down this dashboard carries a Lease this message button beside Reply (shown once a body has rendered) that prefills this pane’s create fields with the open message’s CID and id — prefill only, so the deposit is still yours to review and submit, and typing a CID by hand works as before.
- Encryption key — publish, rotate, or close a
delegated X25519 key. The transaction is
built and signed in the extension’s wasm module and relayed through
the API. Export the secret when it is shown — it appears exactly
once, in the same JSON format
sithbit mail get --delegated-key-pathreads; mail sealed to an old key still needs that old key’s secret, so keep every export. - Settings — mail-client credentials for the active wallet (see The mail password below) and its IANA timezone. The pane’s Copy mail password button derives the wallet-signature credential for you, so you never have to run the CLI.
- Do not disturb — weekly or date-range windows during which senders are asked to retry later, evaluated in your timezone.
- Mailbox — where you claim your on-chain mailbox and set its registration config: an optional handle (claimed as an alias in the same transaction), the sending domain (blank uses sithbit.com), the default postage — the per-stamp price unknown senders pay to reach you — and whether to store bodies on-chain only (opting out of IPFS). Once the mailbox is registered the pane shows the received-message count and sends you to the Balances pane to adjust per-sender prices.
- Close mailbox — request, cancel, or finish closing your mailbox. Closing is not instant: the request opens a fixed 7-day wait during which the mailbox stays open, keeps receiving mail, and refunds nothing. The pane shows the remaining time and offers Cancel throughout — cancelling leaves the mailbox exactly as it was — and only once the clock has run does the finalize button appear, closing the mailbox and returning its rent and the transient request record’s. Both come back to your wallet for a mailbox you created yourself; for a sponsored mailbox the mailbox rent returns to the sponsor who funded it and you keep the request record’s, which the pane states when it happens. The wait is deliberate: an instant refund made discarding a burned sending identity free, so the cost of leaving is time on a rare action rather than money. It does not apply to the Encryption key close above, which stays instant on purpose.
Zero-config setup (autoconfig)
If the operator runs the domain-sithbit
service, Thunderbird can fill in all the IMAP/ POP/SMTP connection settings
for you — no plugin, no hand-typed hostnames. In the native Account
Setup wizard (File → New → Existing Mail Account), enter your address
as <wallet-base58>@<domain> and Thunderbird fetches the server settings
from the domain’s Mozilla autoconfig document and auto-fills them.
Two things the wizard does not do, by design:
- It does not create the account. Autoconfig fills in connection
settings only; native account auto-provisioning is deliberately not
offered. Your mailbox must already exist on-chain (
sithbit mailbox create) before you connect. - It does not set your password. The username is auto-filled as your
wallet address (the base58 public key — the
@domainis dropped), but you still supply a mail password. Get it either from the self-serve enrollment page the operator hosts (/addin/enroll.htmlon the account API) or offline from the CLI — see The mail password just below. Then finish the wizard.
If the operator hasn’t enabled autoconfig, enter the same settings by hand — the username and password rules are identical, and the mail-server coordinates are whatever the operator advertises (standard IMAPS 993 / POP3S 995 / submission 587 by default).
The mail password
Your IMAP/POP/SMTP client authenticates as your wallet with no separate stored password: the “mail password” is a signature your wallet produces over a fixed challenge, which the servers verify against your wallet address. Nothing is stored server-side — the signature is self-proving — so you never “set” a password on the account (see the account API, where a stored password is now optional).
Two ways to get the credential:
-
In the extension — the Settings pane’s Copy mail password button derives it in the wasm module and copies it to your clipboard.
-
On the command line — run:
sithbit mailbox credentials --keypair ~/.config/solana/id.jsonwhich prints the pair offline (no RPC, no on-chain write).
Because the signature is deterministic it never expires either, so the
credential comes with a way to retire it: the same Settings pane’s
Rotate the wallet mail password… button, a deliberate two-step that
adds one to your account’s auth epoch and with it changes the bytes a
valid password signs over. Every copy of the old password stops working at
once — including the one in this extension, until you copy the new value —
and the CLI takes the epoch as an argument
(sithbit mailbox credentials --epoch <N>). It rotates the wallet-derived
password and nothing else: a stored mail password, your signed-in session,
and a client-certificate login
all survive it untouched. The full walk-through, with the warning the
control shows before it commits, is on the
webmail
page — the pane is the same one.
A rotation can also be refused, and that is the design rather than a
fault: bumping the epoch is one of the account API’s
step-up gated
calls, so the server turns it down with 428 Precondition Required until the
request carries a wallet signature made seconds ago — a live session on its
own may not change your mail credentials. Usually you never meet the 428: the
pane answers it for you, fetching the challenge and asking your wallet to sign
it, which is exactly the approval prompt a Phantom or Ledger wallet raises at
that moment. What reaches you is the case where no signature could be
produced — a locked or disconnected wallet, a declined prompt, a challenge
that went stale — and the pane says so in one sentence: “This change needs a
fresh wallet signature. Make sure your wallet is unlocked (or reconnected) and
try again.” Do that literally: unlock or reconnect the wallet, arm Rotate
the wallet mail password… again, and approve the prompt when it appears.
Waiting is not the remedy — each attempt spends its own challenge, so a fresh
try needs a fresh signature, never the previous one — though it is worth not
hammering the button either, since these mutations share a
per-wallet budget
that counts refused attempts too. A refused rotation changes nothing: the
epoch stays where it was and the password already in this extension keeps
working. From a shell the same proof is produced by
sithbit mailbox sign-text,
signing the challenge string with --keypair.
Enter it in your mail client as:
- Username — your wallet address (the base58 public key), exactly as the CLI prints it.
- Password — the derived base58 signature.
- Mechanism — SASL PLAIN over TLS (STARTTLS or implicit TLS). The signature is bearer-equivalent for the connection, so always use TLS.
This is the tested path for reading mail — POP3 and IMAP retrieval
authenticate with the wallet signature end to end. Wallet-authenticated
submission (sending) authenticates the same way, and the envelope
sender you may present is fixed: exactly your own wallet address, at
a domain the submission server is authoritative for — and, when that
server is connected to the chain, one your wallet also owns on-chain
(the domain’s recorded authority), not merely any domain the server
serves. Set the account’s identity to
<your-wallet-base58>@<the operator's domain> — the same address you
typed as the username, with the operator’s mail domain — and sending
works. Anything else is refused 553 5.7.1: another wallet’s address,
your address at a domain that server does not serve (or, on a
chain-connected server, that your wallet does not own on-chain), or a
case-variant of your own base58 (base58 is case-sensitive, so Alice
and alice are different keys and the match is exact). Copy the address
from the CLI rather than retyping it. Regenerating the credential is
free and offline — the wallet key never leaves your machine.
If your operator runs a chain-less dev stack and sends are refused
553 5.7.1 even though the address looks right, the server’s
local_domains is the likely culprit — see Wallet submission
envelopes.
Client-certificate login (SASL EXTERNAL)
The passwordless alternative to the mail password above: instead of a
signature you paste as a password, you hand your mail client a TLS
client certificate minted from your wallet, and the server logs you in
over the TLS handshake itself — no credential typed, nothing stored on
either side. The server must have client_cert_auth turned on for the
listener (see the
configuration reference);
where it does, this and the mail password both work, so pick whichever
your client handles best — though from Thunderbird itself this path is
currently blocked by an import failure; see the known issue in step 2
below.
-
Mint the certificate — offline, no RPC:
sithbit mailbox create-cert --keypair ~/.config/solana/id.json --out sithbitThis writes
sithbit.p12— the combined, password-less PKCS#12 bundle, for mail clients whose certificate store takes one (Thunderbird itself currently cannot; see the known issue in step 2) — alongsidesithbit.crt(the certificate) andsithbit.key(its PKCS#8 private key) as a PEM pair for other apps. Omit--outto print just the two PEM blocks to stdout instead. The certificate is a self-signed Ed25519 leaf whose public key is your wallet address, so it needs no CA and never expires into a renewal chore — regenerate it any time from the same keypair. The.p12and.keyfiles embed your wallet secret; guard them like the wallet itself. -
Install it in Thunderbird — currently blocked by the known issue below. The intended path is importing
sithbit.p12under Settings → Privacy & Security → Manage Certificates → Your Certificates → Import — the bundle is password-less, so the password prompt is left blank — and then, on each of the account’s SMTP (outgoing), IMAP, and POP server entries, setting the connection security to SSL/TLS, the authentication method to Encrypted certificate, and selecting this certificate.Known issue: Thunderbird 140 ESR refuses this import, so SASL EXTERNAL from Thunderbird is effectively unusable until NSS (Thunderbird’s certificate store) accepts the key — sign in with the mail password above instead. Specifically: Your Certificates → Import rejects
sithbit.p12with “Failed to decode the file” (blank password given, as instructed), and re-encoding the same certificate and key into NSS’s preferred PKCS#12 shape (a sha256 MAC with a PBES2-shrouded key bag, still an empty password) gets past decoding but fails with “The PKCS #12 operation failed for unknown reasons” — consistent with NSS refusing to import an Ed25519 private key into its soft token, not merely disliking the bundle’s encoding. The.p12and thesithbit.crt/sithbit.keyPEM pair remain valid credentials for other mail clients — see Outlook for the Windows certificate-store path, for example. -
Leave the password blank. With EXTERNAL there is no password to enter — the certificate is the whole credential. Use your wallet address as the username exactly as before.
No CLI at hand? The extension mints the identical files: the
dashboard’s Account view carries a Certificate sign-in section on
the panes’ Sign-in tab, whose Download client certificate button
derives everything in the wasm module — no server involved — and saves
three files named after your wallet address: the combined
<pubkey>.p12 first (the PKCS#12 bundle for other clients’
certificate stores — Thunderbird itself currently cannot import it;
see the known issue in step 2), then the <pubkey>.crt /
<pubkey>.key PEM pair for other apps — byte-for-byte what
sithbit mailbox create-cert writes.
Importing stays manual exactly as in step 2 — a WebExtension has no
way into Thunderbird’s certificate store, so the download is the whole
affordance. The .p12 and .key files embed your wallet secret;
guard them like the wallet itself. The button needs the active wallet unlocked in the
extension’s wasm module: while every wallet is locked it is disabled,
and an external signer (a Phantom or Ledger wallet) reads as locked
too — it holds no local seed to derive from, so external-wallet users
stay on the CLI path above.
Because the certificate carries no secret beyond your wallet key (which never leaves your machine), you can regenerate and reinstall it freely — the derivation is fully deterministic per wallet, so re-downloading on any device yields the identical certificate, and a fresh download never invalidates one already imported.
Trustless (server-down) viewing
The dashboard also carries a Read mail trustlessly pane — a read path that needs nothing but a Solana RPC endpoint, any IPFS gateway, and your unlocked wallet. It works with the SithBit mail server (account-api) completely down: enter a mailbox message id and the extension resolves that message’s on-chain account for its sealed-body CID, fetches the sealed bytes from the gateway, unseals them in the wasm module with your wallet, and renders the MIME. HTML bodies render in a fully sandboxed frame that blocks scripts and remote loads, exactly like every other reader here.
Two endpoints drive it, both set in the extension options and
defaulting to the dev stack: the Solana RPC (config.rpcUrl, default the
local surfpool at http://127.0.0.1:8899) and the IPFS gateway
(config.gatewayUrl, default http://127.0.0.1:8183 — e.g. a
sithbit-gateway). The pane relies on the
messagesRead permission, already declared in the manifest.
Two caveats:
- A wallet-only read only works if the body was sealed to your
wallet. A recipient who published a
delegated encryption key had their mail sealed
to that key instead, so the trustless read needs the delegated
secret, not the wallet — the same secret
sithbit mail get --delegated-key-pathreads. - The sealed IPFS copy is only fetchable while it stays pinned. Under the
default
[spooler.settle]auto-settle worker, a message settled past its window is unpinned (keep_pin = false), so a trustless read of an old, settled message depends on the operator having setkeep_pin = true.
Trustless reply and compose
Reading is no longer the pane’s whole surface: the trustless reader’s
header carries a Reply on-chain button, and the dashboard mounts
the same floating on-chain compose card as
trustless webmail — a
Compose on-chain button opens it blank; Reply seeds it with the
decrypted sender and the parent message’s account address. The card is
always available (the dashboard has no trustless mode to switch into)
and follows the webmail card’s lifecycle exactly: resolve the recipient
and their published key on-chain, seal in the extension’s wasm module,
pin the sealed bytes to your configured IPFS pin service, then sign and
submit the SendMail transaction.
A reply sent this way is an on-chain send, not a Thunderbird compose: it never opens a compose window or touches the account’s outgoing SMTP server, and the operator’s SMTP/IMAP/spooler pipeline plays no part in delivery — the reply lands as an on-chain message account even while those servers are down. Chain reads, sealing, and pinning go straight to your configured endpoints; the signed transaction is relayed through the account API like every other on-chain action here, and as always the relay cannot alter what you sign (see Security notes).
The card carries webmail’s three affordances, documented in full there rather than restated here:
- The reply chip — the draft threads to the parent message with the
same privacy-preserving linkage
--reply-tomakes (only its blake3 hash reaches the chain); Clear drops the link and keeps the draft. See Replying, and attaching a bounty. - Attach a reply bounty — an amount in SOL (blank = plain send) and
a claim window in days escrow SOL on the message exactly like
--bounty/--bounty-window. - Inline prepay — no frombox toward the recipient yet? The card holds the draft, quotes the postage (live per-stamp protocol fee included), and Prepay & send buys the stamps then re-sends the held draft once the purchase confirms — the same prepayment rule and behavior as webmail’s card. The Balances pane remains the standing place to buy stamps outside a compose.
Sending needs one endpoint reading does not: the options page gains an
IPFS pin service URL (config.ipfsPinUrl, default
http://127.0.0.1:8182 — a
sithbit-ipfsd) plus its optional bearer
token (config.ipfsPinToken, default empty), where outbound sealed
bodies are pinned. As with a remote API origin, saving a non-loopback
pin URL prompts for that origin’s host permission on the same Save
click — the extension ships with loopback-only host permissions.
Operators offering that pin surface should read
the pin lifecycle caveat:
a client-made pin sits outside any mail server’s pin lifecycle.
Reading sealed mail in the message pane
Messages sealed with Lockbox open in Thunderbird’s own message pane — the same place you read everything else. Open the message and the extension replaces the armored block with the decrypted text under a notice line saying what happened.
This needs the SithBit tab open, with a wallet unlocked. The message pane holds no wallet of its own: it hands the sealed block to the SithBit tab, which opens it and hands back the plaintext, so your key material never leaves the one place that already had it. With the tab closed or the wallet locked, the message shows the armored block and a line telling you to unlock — the same degradation as any other locked read, never a broken message.
Three further behaviours worth knowing:
- Only the text is rendered. A sealed message’s rich HTML is not displayed here. The web clients render it inside a sandboxed view; the Thunderbird message pane has no equivalent sandbox, so sender-authored HTML is deliberately never parsed in it.
- A message sealed to someone else says so rather than appearing to fail.
- Sealed attachments are listed under a Sealed attachments heading with a save button and the file size, which is known without downloading anything. Nothing is fetched until you click: an attachment carried inside the envelope decodes locally, and an offloaded one is fetched from your own configured IPFS gateway and decrypted on your machine. The save itself goes through the extension’s background rather than the message pane directly — the message pane’s own document cannot trigger a download — so it works even if you close the SithBit tab right after the attachment finishes fetching.
Nothing here changes the stored message — only what is displayed.
Security notes
- The wallet secret at rest is exactly as strong as your passphrase.
- The session token authorizes account changes (password, timezone, DND) for up to 24 hours; on-chain actions additionally require the unlocked wallet, which never survives a restart.
- The API relay cannot alter what you sign: transactions are built from the workspace’s own instruction encoders compiled to wasm, and a parity test pins them byte-for-byte to the CLI’s.
Lockbox: end-to-end encrypted mail
Lockbox mail makes a message readable only by its recipient — the mail
server, the relay, and anyone who later reads the stored copy all see ciphertext.
It reuses SithBit’s sealed-box encryption (the same
crypto_box_seal that protects on-chain
mail bodies)
but applies it client-side, over ordinary email: the SithBit plugin seals the
body before it leaves your machine and unseals it after it arrives, so lockbox
mail rides your existing email account with no SithBit mail server in the path.
It is also fully automatic. Once you and a correspondent both have the plugin, there is no button to press and nothing to remember — every message between you is sealed and reopened transparently, the same way TLS quietly protects a web page. This is the single best reason to run the Thunderbird extension or the Outlook add-in instead of a plain IMAP/POP/SMTP account in your everyday mail client: the plugin is the only way to get true end-to-end encryption, and it costs you nothing to keep it turned on.
Why it matters: who can read your mail without it
Conventional providers do more with your mail than store it: most marketing mail carries a tracking pixel that reports when and where you opened it, and the largest provider has been fined by a national regulator for advertising inside the inbox without consent. Lockbox is the opposite posture: the message is sealed on your device, your operator never sees plaintext, and there is nothing in the stored copy to read, mine, or serve.
Every SithBit mailbox lives on a
domain whose authority runs the mail server behind it — for
a custom domain, that’s often your employer or whoever administers
acme.com, not you. Without lockbox, that operator can read your mail in the
clear:
- Your IMAP / POP client fetches each message body from the operator’s own storage, and that copy is kept as plaintext — it has to be, so the server can hand it back to you on request. The separate, sealed copy that gets pinned to IPFS is computed from that plaintext at delivery time; it protects the public copy on IPFS, not the one sitting on your operator’s disk.
- For mail relayed in from ordinary SMTP, the same operator also sees the envelope and headers in the clear — see the domain authority is fully trusted for relayed mail.
None of this is a bug — it’s the same trust model every traditional mail server already has (an IT admin can read mail on a company Exchange server, Gmail’s operator can technically read Gmail), and SithBit documents it honestly rather than pretending otherwise; see what’s public and private for the full picture. But it means that on-chain sealing alone protects you from the public internet, not from whoever runs your own mail domain.
Lockbox closes exactly that gap. Because sealing happens on your device before the message ever reaches a server — yours or anyone else’s — your own domain’s operator never holds a plaintext copy to begin with. There is nothing on their disk to subpoena, leak, or simply read.
How it works
Sending. When you send a message, the plugin:
-
Resolves each recipient to a wallet — a raw wallet address, or a alias — using only a public Solana RPC endpoint.
-
Looks up the recipient’s published encryption key (or falls back to sealing straight to their wallet address).
-
Packs the message into a small JSON envelope — the text body plus, when the sending client supplies them, rich HTML and any attachments, so everything seals and travels as one unit — then seals that envelope to the key and replaces the message body with an ASCII-armored block:
-----BEGIN SITHBIT SEALED MESSAGE----- Version: 1 To: alice …base64 sealed body… -----END SITHBIT SEALED MESSAGE-----The envelope is capped at 12 MiB, measured before sealing; a larger message is refused with a clear error rather than silently truncated. The headroom exists because the payload inflates twice on the wire — attachment bytes ride as base64 inside the envelope, and the sealed result is base64-armored again, roughly ×1.78 combined — so 12 MiB of raw content still clears the SMTP server’s default 25 MiB message-size limit. Messages sealed by earlier plugin versions carried the bare body with no envelope; they remain readable — opening simply falls back to treating the unsealed bytes as the text body.
Large attachments offload instead of hitting that cap when the sending client has an IPFS pin service configured. Before the cap is measured, each attachment over 1.5 MiB (an eighth of the ceiling, derived from it rather than configured) is individually encrypted, pinned to IPFS, and replaced inside the envelope by a small reference — the content id, the decryption key, and the plaintext size. The key rides inside the sealed envelope and the reference carries no URL: a content id is permanent where a gateway hostname is not, so the recipient fetches from their own configured gateway and old mail never expires with the sender’s domain. Without a pin service nothing changes: attachments seal inline exactly as before, and an over-cap message is refused. Configuring the pin service is the on/off switch — there is no second setting to disagree with it.
Concretely, “configured” means a pin service URL has been saved in the client’s connection settings, not merely that the field shows a value: the settings pane pre-fills it with the loopback development default, so saving connection settings at all arms the offload, while a client whose settings have never been saved keeps sealing inline. That distinction is deliberate — the pin URL always reads as something, so arming on the value alone would point every install at a port that usually has nothing behind it, and turn a large-attachment send that seals fine today into a failure.
All three steps run automatically on every send — there is no compose-time toggle to find or forget. A recipient without the plugin sees this block plus a short notice telling them how to read it — never a broken message.
Receiving. The recipient’s plugin automatically detects the armored block, unseals it with their wallet, and shows the plaintext — again, with no action from the reader beyond opening the message as usual. Ordinary (non-lockbox) mail is passed through untouched.
The web clients’ shared mail reader — the webmail app, the Chrome extension popup, and the Outlook task pane — opens sealed messages the same way. With your wallet unlocked, a sealed message shows its decrypted text (and rich HTML, through the same sandboxed view as ordinary mail) with a notice line naming what happened; locked, it shows the armored block with a hint to unlock, and a message sealed to a different recipient says so instead of failing. The envelope’s attachments are listed under Sealed attachments with a per-file download: an attachment that traveled inside the envelope decodes on your device, and an offloaded one (see the sending step above) is fetched from your own configured IPFS gateway and decrypted locally — the gateway sees only ciphertext and a content id, never the key, and a fetch whose bytes do not match the size the sealed message declares is refused rather than saved.
The Thunderbird extension opens sealed mail in Thunderbird’s own message pane, so there is no separate reading surface to switch to. Two differences follow from where that pane runs, and both are deliberate. First, the reading needs the SithBit tab open with your wallet unlocked: the message pane itself never holds your wallet, and instead hands the sealed block to that tab to open — so key material stays in one place. With the tab closed or locked, the message shows the armored block and a line telling you to unlock. Second, an opened message shows its text only: unlike the web clients, which render a sealed message’s rich HTML inside the same sandboxed view they use for ordinary mail, the Thunderbird message pane has no such sandbox, so sender-authored HTML is not rendered there at all. Sealed attachments are listed with a per-file save, and behave exactly as described above.
The recoverable reading key
Standard S/MIME and PGP have a painful weakness: lose the private key and your archived mail is gone, and using more than one device means hand-copying key files. SithBit derives your reading key from your wallet instead.
Your wallet signs one fixed, domain-separated message; that signature is run through a KDF to produce your X25519 reading key. Because the signature is deterministic, any device holding your wallet reproduces the exact same reading key — including a signing-only hardware wallet — with nothing to back up. Publish its public half once:
sithbit mailbox set-key --derive
Senders then seal to that published key. The trade-off is forward
secrecy: the derived key never changes, so if it’s ever compromised, every
message ever sealed to it — past and future — is readable. A random delegated
key you rotate periodically (sithbit mailbox key) limits a compromise to
whatever was sealed under that one key; older mail sealed under a previous,
now-discarded key stays safe. If you prefer that protection over
never needing a backup, keep generating a random delegated key instead.
Trade-off. Anyone who can trick your wallet into signing this exact message can reconstruct your reading key, so approve the signing prompt only in the SithBit plugin. See the threat model.
Autocrypt-style key discovery
Sealing to a recipient means first knowing their key. The plugin can always learn it from the chain (that lookup is the second step of Sending above), but a chain round-trip on every send is avoidable when the two sides have already exchanged mail. Borrowing the idea behind OpenPGP’s Autocrypt, the plugins advertise the sender’s key in an ordinary mail header and quietly remember it on the receiving end — so a reply to someone who has written to you seals without touching the chain at all.
The header. Outgoing lockbox mail carries a SithBit-specific header:
X-SithBit-Key: v=1; wallet=<base58 wallet>; key=<base58 X25519 key>
It names the sender’s wallet and their published X25519 reading key. The header is emitted opportunistically: the plugin adds it only when the sender resolves to a published key, and its absence never blocks or fails a send.
Both ends participate. The Thunderbird extension sets
the header as the message is composed; the Outlook add-in sets
it as the message is sent. On the receiving side, both plugins read the header
off displayed mail and cache the wallet → key pair. A later send to that same
wallet is served straight from the cache — no fresh RPC lookup — while the plugin
still knows the sender is SithBit-capable.
The chain stays the source of truth. The header and its cache are a
convenience and a “this sender speaks SithBit” signal, not an authority. The
published key lives on-chain (set with sithbit mailbox set-key), and any send
can fall back to the chain lookup — so a reply seals correctly even if no header
was ever seen.
Not OpenPGP Autocrypt. This borrows Autocrypt’s shape — advertise your key in a header, remember peers’ keys from received mail — but it is a separate, SithBit-only mechanism. The advertised key is an X25519 reading key, not a PGP key, and the header does not interoperate with real Autocrypt or any OpenPGP client.
Limitations. Only the wallet named in the header is remembered, so the cache
short-circuits future sends addressed to that bare wallet or to
wallet@host — an alias-addressed reply such as bob@acme.com still resolves
through the chain, because the header advertises the wallet, not the alias.
Thunderbird caches when a message is displayed; Outlook has no event for
“message read”, so its read-caching rides the task pane loading on an opened
message. In every case a cache miss simply falls back to the normal chain
lookup.
What v1 does — and does not — do
- Both ends need the plugin. Lockbox is end-to-end encryption between SithBit users, not a way to send encrypted mail to someone running plain Apple Mail. (S/MIME interop for plugin-less recipients is a possible later layer.)
- Recipients must be SithBit-native. Lockbox seals to a raw wallet address or
a global alias. Alias resolution is
domain-blind — the suffix is parsed off and ignored, so
alice,alice@acme.com, andalice@anything.exampleall seal to whoever holds the globalalice. This is load-bearing, not incidental: sealing follows resolution, so if any third party could redefine which wallet an address names, it would thereby choose which key your browser seals to. No domain authority can. A recipient that can’t be resolved is reported as unsupported and the message is sent as ordinary plaintext. - All-or-nothing per message. If any recipient can’t be sealed to, nothing is sealed — the message goes as plaintext rather than leaking who could and could not be reached. A message to several SithBit recipients carries one sealed block per recipient.
- The plugins seal the body, the rich HTML, and the attachments. Both the Thunderbird and Outlook plugins read the composed message out of the host mail client and hand the whole thing to the envelope, so formatting and attachments seal and travel as one unit. Attachment bytes ride inside the sealed envelope, which is why the originals are detached from the outgoing message once the seal succeeds — a recipient without the plugin sees the armored block and no attachments, rather than the files in the clear beside it.
- Two things a message can contain that stop it being sealed. Both fail
closed: the message is sent exactly as you typed it, unencrypted, and the
plugin tells you why. It is never sent partly sealed.
- Inline (embedded) images. A pasted or dragged-in image lives in the
message body as a
cid:reference rather than as an attachment, and neither host’s plugin API can enumerate those parts — so the plugin cannot seal them, and rewriting the body around them would either destroy the image or leave it readable beside a sealed body. Attach the images as files instead and the message seals normally. - Cloud attachments and attached messages. A OneDrive-style cloud
attachment is a link, not bytes, and a forwarded
.emlitem is handed to the plugin in a form that carries no bytes either. Download and re-attach either one as an ordinary file to seal it.
- Inline (embedded) images. A pasted or dragged-in image lives in the
message body as a
Relationship to on-chain mail
Lockbox mail and on-chain SithBit mail solve the same privacy problem from two directions. On-chain mail seals the body server-side at delivery time and stores that ciphertext on IPFS, but — as covered above — the operator’s own mailbox copy stays plaintext so IMAP/POP can serve it back to you. Lockbox mail seals client-side instead, so the operator never sees plaintext in the first place — at the cost of requiring the plugin on both ends. See what’s public and private for the full picture.
The name marketplace
The open market for aliases and
domains, in the browser: a single browse
pane that lists every name currently offered for sale, lets you buy
one with a click, list your own for a fixed price, and look back over
completed sales. It is the graphical twin of the CLI’s
alias sell/alias buy and
domain sell/domain buy commands — the
same fixed-price, first-come-first-served listings, driven from a page
instead of a terminal. Prices are in SOL; a completed sale settles on-chain
with the seller keeping 90% and the postoffice the remaining 10% (the
standard marketplace split).
The marketplace ships two ways, both from the same shared core
(webclients/shared/marketplace-panes.js):
- a standalone page (
webclients/marketplace/) that does nothing but the marketplace, and - the identical pane mounted inside the webmail app, the Outlook add-in, and the Thunderbird extension, alongside their other settings panes.
Two wallets meet here
Every other browser pane signs in the page with the in-wasm keypair (see the webmail app). The marketplace is deliberately different, because you may want to buy a name with a hardware wallet or a Phantom account that never touches this app:
- Browsing and sale history are plain reads. They ride the
authenticated
/v1/chain/listingsand/v1/chain/salesaccount-api routes with your normal session JWT. The listing index is global — the JWT only authenticates the request; whose wallet it belongs to is irrelevant to what you see. - Buying and listing sign with an external wallet. When you click
Buy or List for sale, the pane connects your browser wallet —
Phantom or a Ledger
through it — builds the unsigned wire transaction from a wasm
*_unsignedbuilder, and hands it to that wallet to sign and broadcast. The in-wasm keypair never signs a purchase, and your external wallet’s key never enters the page.
If no browser wallet is injected, the buy/list controls report the wallet as unavailable; browsing still works.
The tabs
The pane opens on For sale — the names you can buy right now — and offers four filters:
- For sale (default) — live listings: every offer with no expiry, or whose expiry is still ahead. These are purchasable.
- Expired — listings past their binding window. Nothing sweeps expired listings on-chain (see the binding window), so they linger in the index; this tab is where they land, and they are no longer buyable.
- Sold — completed sales, newest first. This tab lazy-loads sale
history from
/v1/chain/salesthe first time you open it. - Participants — the opt-in campaign pool: wallets that published a discovery beacon advertising the topics they’ll accept paid mail on. Like Sold, it lazy-loads on first view.
The Participants tab
The first three tabs trade names; the Participants tab is a window
onto the campaign pool — the people who
have opted in to be reached, not the names for sale. A tag-filter box
narrows the pool to wallets carrying every tag you enter (a logical
AND); Apply re-pulls the list from the global
/v1/chain/participants account-api route.
Tags go by the same names the
sithbit campaign --tag
flag takes — interest.technology, region.apac, and so on,
case-insensitive — and the filter also accepts raw bit positions, mixed
freely in one comma-separated list (interest.technology,10). A name
the vocabulary doesn’t know is reported as an error right in the pane,
before anything is fetched. Each row shows a participant’s
wallet
address, its self-attested tags by name (a tag newer than the page’s
vocabulary shows as its bit number rather than disappearing), and
whether it advertises an off-chain detail profile.
Browsing the pool is a plain read; reaching the people in it is a
separate, paid step — you send them bountied mail,
most easily with the sithbit campaign
tree. See Campaigns for the whole flow and
what it costs.
Managing your own beacon
Below the pool list, a My beacon heading carries a single Manage my beacon button — disabled until a browser wallet is available, because authoring your beacon, like buying and listing, signs with your external wallet, never the in-wasm keypair. Clicking it connects the wallet, reads your own beacon from chain, and shows one of three states:
- Not opted in — a “not in the pool yet” prompt, six checkbox
groups of tag pickers by name (interest / skill / age / region /
language / role —
the same 71-tag vocabulary the
sithbit campaign --tagflag takes), an optional detail CID field, and a Publish beacon button. Publishing requires at least one tag picked. - Opted in — your published tags by name, above the same form pre-seeded from your chain state, with Update beacon, Disable, and Close beacon buttons.
- Disabled — a clear notice that your beacon is disabled and advertises no tags, with Re-enable alongside Update and Close.
The detail CID field is paste-only: it takes a literal CID,
mirroring the CLI’s
--detail-cid —
the page never encrypts or pins a profile for you (that is the CLI’s
--profile-file path). And like the CLI, an update is a wholesale
replacement: the picked tags and the pasted CID replace whatever the
beacon carried, so an empty CID field clears a published one.
Disable is a convention of this pane, not a chain state. Disabling publishes an update carrying no tags: the beacon account and its rent stay on-chain, but because a pool search must match at least one tag, a tag-less beacon drops out of every tag search. The pane remembers the tags you disabled locally in your browser, per wallet, so Re-enable restores exactly the set you had — the chain never stores that remembered set, so a different browser or a cleared profile has nothing to restore and says so; pick your tags again and Update instead. Close is the full opt-out: it closes the beacon account and refunds its rent to your wallet.
Success, pending-signature, and error feedback share the pane’s existing status lines, exactly as a buy or a listing does.
Each For sale row shows the name, whether it is an alias or a domain, and the asking price in SOL, with a Buy button. A successful buy refreshes the index and shows the transaction signature.
Listing your own name
The List for sale form takes the kind (alias or domain), the name,
and an asking price in SOL. It connects your external wallet — which
must be the name’s current
holder/authority — signs the
listing transaction, and refreshes the index so your new offer appears
under For sale. Listings default to the program’s 30-day binding
window; the CLI’s alias sell /
domain sell let you set a different
--expires-in.
A marketplace listing names nobody: whoever pays first wins. To hand a name to one specific recipient for a fee, use the escrowed alias transfer or domain transfer offer instead; for an ascending-bid sale, see Auction an alias.
The standalone page
Besides the panes built into every client, the marketplace has a standalone page of its own, served by your operator at a URL they choose.
The standalone shell asks you to import a Solana keypair on first run: that keypair unlocks the browse JWT only. Buying and listing still use your separate Phantom/Ledger wallet, which connects on demand from the pane. (The in-client panes reuse whichever wallet already logged that client in, so they skip this step.)
Operators: building and mounting that page is Serving the browser clients.
Honest notes
- Purchases are public. The buyer’s wallet, the asking price, and the winning transaction all live on-chain — an observer can see who bought which name and for how much. This is inherent to a public ledger; see What’s public and private.
- Sale history comes from an indexer, not the chain directly. The Sold tab is only as complete and current as the operator’s alias/marketplace indexer; an operator who disables it leaves the tab empty.
[chain]is required. All three routes the pane uses answer 503 without account-api’s[chain]section configured, just like the other chain-backed panes.
Economics
In one breath: strangers pay postage to reach you and you keep roughly 90% of it; the operator whose domain carried the message takes 10% by protocol default; funding a friend’s postage yourself is fee-free, so mail among people you know is free beyond ordinary transaction fees; and every amount below moves in native SOL — there is no token.
Where the SOL goes: every lamport transfer in the protocol, traced from the
on-chain programs (the mail_program, alias_program, and domain_program
processors), and what each participant
class pays and collects. Amounts marked ≈ are rent-exemption minimums —
a refundable, one-time deposit every Solana account needs to exist, sized to
the account’s byte length rather than to any price or value it represents
(see Closing accounts for what rent is and
how to reclaim it) — and vary with account size at current rent parameters.
This is a completely separate quantity from postage: postage is a price
the recipient chooses; rent is a storage deposit nobody chooses and
everybody gets back.
The stamp lifecycle
A stamp is prepaid postage for one email from one sender (“from” address) to one recipient wallet. Its value cycles through three accounts, then settles across four destinations:
- Buying (
AddStamps/CreateFrombox): anyone may fund the frombox for a (from, recipient) pair. Each stamp costs the frombox’srequired_postageplus a two-signature fee surcharge (STAMP_FEE_SURCHARGE_LAMPORTS= 10 000 = 2 × the 5 000-lamport base fee); the surcharge prefunds both settlement signature refunds (SendMail and DeleteMail). The whole amount transfers into the frombox account. A new frombox inheritsrequired_postagefrom the recipient mailbox’sdefault_postage; only the recipient may change it afterwards (UpdateFromboxrequires the recipient’s signature). Price changes do not revalue stamps already bought — value amortizes over the remaining stamp count. Third-party purchases additionally pay the per-stamp protocol fee — the greater of a flat per-stamp amount and a small percentage of the escrowed postage, split between the recipient’s domain authority and the postoffice (see “The per-stamp protocol fee” below); purchases paid by the recipient wallet itself are fee-free. - Sending (
SendMail): the signer must be the sender named in the email (or the active domain authority for the recipient’s domain — the MX operator’s wallet, for relayed mail). The signer pays the transaction fee and fronts the message account’s rent-exemption (≈0.003–0.006 SOL depending on address/cid sizes — a deposit sized to the account’s byte length, not to the postage price; see the note above) as a new deposit, separate from anything paid at the Buying step. One proportional share of the frombox’s value —(balance − frombox rent) / stamps— moves onto the message account alongside that rent, and one stamp burns. A frombox whose share rounds to zero still sends (the stamp count is the gate; the value is what settles later). - Settling (
DeleteMail): only the sender or the recipient may delete. The message balance is split, in order: the sender recovers the message rent + one signature fee, the delete signer recovers this transaction’s signature fee (both prefunded by the surcharge), the recipient’s domain authority collects the operator share (operator_share_bps— defaultOPERATOR_SHARE_BPS= 10% of the remaining postage, delegate-tunable since v0.36.0), and the recipient collects the rest.1 Postage only settles on delete; until then it sits parked on message accounts.
Try it yourself: a worked example
The script below re-derives the three steps above as plain arithmetic —
paste it into a browser console or run it with node (no dependencies,
no network access, nothing on-chain). Change postageLamports,
messageRentLamports, or thirdPartyBuyer at the top to try a different
scenario.
Money enters this example at two different points, not one: the buyer’s
purchase at Buying, and the sender’s rent deposit at Sending (the same
wallet, if the buyer is also the sender — but still two separate
transactions). That’s why the settlement totals add up to more than
buyerPays alone: the conservation check sums both inputs
(buyerPays + messageRentLamports) against everything paid out, and only
that combined total should balance.
Note: the default
postageLamportsbelow is priced at roughly what a single US first-class postage stamp costs (about $0.82 as of this writing), converted to lamports at the SOL/USD rate on the date this page was last updated ($81.16/SOL, 2026-07-06) — a deliberate nod to the stamp analogy, not a protocol default. Change it to see how the split scales.
// SithBit stamp economics calculator -- a hypothetical worked example.
// Pure JavaScript, no dependencies: paste into a browser console, or run
// with `node stamp-calculator.js`. All amounts are in lamports
// (1 SOL = 1,000,000,000 lamports).
// Protocol constants (from mail_model::constants). The two bps rates are
// the DEFAULTS -- the delegate can retune both (SetSettlementBps), each
// bounded by its on-chain cap.
const SIGNATURE_FEE_LAMPORTS = 5_000;
const STAMP_FEE_SURCHARGE_LAMPORTS = 10_000; // 2 x SIGNATURE_FEE_LAMPORTS
const POSTOFFICE_STAMP_FEE_LAMPORTS = 100_000; // flat arm; waived if the buyer is the recipient
const DEFAULT_STAMP_FEE_BPS = 100; // bps arm: 1% of the escrowed postage
const OPERATOR_SHARE_BPS = 1_000; // 10% of postage, to the domain authority
const POSTAGE_ROUNDING_LAMPORTS = 1_000; // recipient payout rounds down to this
// --- Inputs: change these to try a different scenario ---
const postageLamports = 10_000_000; // ~0.01 SOL -- priced at ~$0.82, a US first-class stamp
const messageRentLamports = 4_000_000; // ~0.004 SOL -- rent-exemption sized to account bytes, independent of postage
const thirdPartyBuyer = true; // false = the recipient self-funds (fee waived)
const solUsdPrice = 81.16; // SOL/USD rate used for the $ comments below (2026-07-06)
function sol(lamports) {
return (lamports / 1_000_000_000).toFixed(9) + " SOL";
}
function usd(lamports) {
return "$" + ((lamports / 1_000_000_000) * solUsdPrice).toFixed(2);
}
// 1. Buying: AddStamps / CreateFrombox
// The protocol fee is a hybrid: the greater of the flat per-stamp fee and
// the bps share of the escrowed postage (the surcharge is a refundable
// prefund, not stamp value). At this example's 0.01 SOL postage the two
// arms meet exactly; raise postageLamports to watch the bps arm take over.
const stampValue = postageLamports + STAMP_FEE_SURCHARGE_LAMPORTS;
const hybridFee = Math.max(
POSTOFFICE_STAMP_FEE_LAMPORTS,
Math.floor((postageLamports * DEFAULT_STAMP_FEE_BPS) / 10_000),
);
const protocolFee = thirdPartyBuyer ? hybridFee : 0;
const buyerPays = stampValue + protocolFee;
// The purchase carries the operator tail (as current clients build it),
// so the fee splits: operator_share_bps to the recipient's domain
// authority, the remainder to the postoffice. The buyer's total is the
// same either way.
const feeOperatorShare = Math.floor((protocolFee * OPERATOR_SHARE_BPS) / 10_000);
const feePostofficeCut = protocolFee - feeOperatorShare;
console.log("== Buying: AddStamps / CreateFrombox ==");
console.log(`Buyer pays: ${buyerPays} lamports (${sol(buyerPays)})`);
console.log(` // ≈ ${usd(buyerPays)}`);
console.log(` -> frombox value: ${stampValue} lamports (${sol(stampValue)})`);
console.log(` -> protocol fee: ${protocolFee} lamports (${sol(protocolFee)})${thirdPartyBuyer ? "" : " (waived)"}`);
if (thirdPartyBuyer) {
console.log(` ${feeOperatorShare} to the domain authority (operator share) + ${feePostofficeCut} to the postoffice`);
}
// 2. Sending: SendMail
const messageBalance = messageRentLamports + stampValue;
console.log("\n== Sending: SendMail ==");
console.log(`Sender fronts message rent: ${messageRentLamports} lamports (${sol(messageRentLamports)})`);
console.log(` // a new deposit -- separate from what the buyer paid above`);
console.log(`One stamp's value moves onto the message account: ${stampValue} lamports (${sol(stampValue)})`);
console.log(`Message account now holds: ${messageBalance} lamports (${sol(messageBalance)})`);
// 3. Settling: DeleteMail
const senderShare = messageRentLamports + SIGNATURE_FEE_LAMPORTS;
const deleteSignerShare = SIGNATURE_FEE_LAMPORTS;
const remainingPostage = messageBalance - senderShare - deleteSignerShare; // == postageLamports
const operatorShare = Math.floor((remainingPostage * OPERATOR_SHARE_BPS) / 10_000);
const recipientRaw = remainingPostage - operatorShare;
const recipientShare = Math.floor(recipientRaw / POSTAGE_ROUNDING_LAMPORTS) * POSTAGE_ROUNDING_LAMPORTS;
const postofficeResidue = recipientRaw - recipientShare;
console.log("\n== Settling: DeleteMail ==");
console.log(`Sender recovers: ${senderShare} lamports (${sol(senderShare)}) [rent + 1 signature fee]`);
console.log(` // ≈ ${usd(senderShare)}`);
console.log(`Delete signer recovers: ${deleteSignerShare} lamports (${sol(deleteSignerShare)}) [1 signature fee]`);
console.log(` // ≈ ${usd(deleteSignerShare)}`);
console.log(`Domain authority earns: ${operatorShare} lamports (${sol(operatorShare)}) [10% operator share]`);
console.log(` // ≈ ${usd(operatorShare)}`);
console.log(`Recipient collects: ${recipientShare} lamports (${sol(recipientShare)}) [the rest, rounded down]`);
console.log(` // ≈ ${usd(recipientShare)}`);
// 4. Conservation check
const totalIn = buyerPays + messageRentLamports;
const totalOut = senderShare + deleteSignerShare + operatorShare + recipientShare + protocolFee + postofficeResidue;
console.log("\n== Conservation check ==");
console.log(" // totalIn = buyerPays + messageRentLamports: money entered at TWO steps, not one");
console.log(`Total in: ${totalIn} lamports`);
console.log(`Total out: ${totalOut} lamports`);
console.log(totalIn === totalOut ? "Every lamport is accounted for." : "Mismatch -- check the math!");
Example output for the defaults above (a stranger buying one first-class-stamp-priced stamp, with the recipient later deleting the message to collect payment):
== Buying: AddStamps / CreateFrombox ==
Buyer pays: 10110000 lamports (0.010110000 SOL)
// ≈ $0.82
-> frombox value: 10010000 lamports (0.010010000 SOL)
-> protocol fee: 100000 lamports (0.000100000 SOL)
10000 to the domain authority (operator share) + 90000 to the postoffice
== Sending: SendMail ==
Sender fronts message rent: 4000000 lamports (0.004000000 SOL)
// a new deposit -- separate from what the buyer paid above
One stamp's value moves onto the message account: 10010000 lamports (0.010010000 SOL)
Message account now holds: 14010000 lamports (0.014010000 SOL)
== Settling: DeleteMail ==
Sender recovers: 4005000 lamports (0.004005000 SOL) [rent + 1 signature fee]
// ≈ $0.33
Delete signer recovers: 5000 lamports (0.000005000 SOL) [1 signature fee]
// ≈ $0.00
Domain authority earns: 1000000 lamports (0.001000000 SOL) [10% operator share]
// ≈ $0.08
Recipient collects: 9000000 lamports (0.009000000 SOL) [the rest, rounded down]
// ≈ $0.73
== Conservation check ==
// totalIn = buyerPays + messageRentLamports: money entered at TWO steps, not one
Total in: 14110000 lamports
Total out: 14110000 lamports
Every lamport is accounted for.
Per-participant flows
Recipient (mailbox owner)
| Pays | Collects |
|---|---|
Mailbox rent ≈0.0012 SOL (once, at CreateMailbox) | Stamp value of every received-then-deleted mail |
| Encryption-key account rent ≈0.005 SOL (once) | |
| Frombox funding for correspondents they allow-list | That same funding back, as mail arrives and is deleted |
The recipient is the protocol’s revenue side: strangers pay
required_postage per message, and pricing is the recipient’s spam
control (the CLI defaults new mailboxes to 1 SOL per stamp — unknown
senders are priced out until the recipient lowers the price for them,
though a stranger with a real on-chain spending record pays a scaled
share of that default — see reputation-scaled first-contact
pricing).
When the recipient funds a correspondent’s frombox themselves, the value
round-trips back minus transaction fees — allow-listing costs only fees.
Accumulates SOL in proportion to mail from senders who paid their own
postage — roughly 90% of each stranger’s postage, everything after the
10% operator share and the sub-1 000-lamport rounding — and net of their
one-time rents otherwise.
Sender (wallet-holding)
Pays postage (the recipient’s price) plus the fee surcharge per stamp, and
fronts the message rent per send. On delete, rent and one signature fee
come back. Net cost per mail ≈ required_postage — the intended price of
communication. Spends SOL by design.
MX operator (runs sithbitd + the gRPC gateway)
For relayed mail, the operator’s domain-authority wallet is the on-chain
“sender”: it fronts the message rent and transaction fee per inbound
delivery, and recovers rent plus the surcharge-funded signature fee at
DeleteMail. On-chain revenue: the operator share — every settled
message for a mailbox on the operator’s active domain pays the authority
the operator share of the postage (operator_share_bps, default
OPERATOR_SHARE_BPS = 10%), and every third-party stamp purchase that
presents the operator accounts (what current clients build) pays the
authority the same share of the per-stamp protocol fee (v0.37.0). At
scale this turns relaying
into a revenue stream rather than a pure cost. Off-chain costs remain
( IPFS pinning, servers, RPC); the share offsets them on-chain.
The per-stamp protocol fee
The postoffice’s primary revenue: a hybrid fee per purchased stamp —
the greater of a flat amount and a bps share of the escrowed postage
(v0.36.0) — collected at purchase time. A purchase that presents the
recipient’s mailbox/domain/authority accounts (the operator tail,
which current clients build automatically) splits the fee:
operator_share_bps (default 10%) to the recipient’s domain authority,
the remainder to the postoffice PDA. A legacy account list keeps the
whole fee with the postoffice.
- Amount: the greater of the two arms —
max(stamp_fee_lamports × stamps, postage × stamp_fee_bps / 10 000). The flat arm isstamp_fee_lamportsfrom the postoffice account (defaultPOSTOFFICE_STAMP_FEE_LAMPORTS= 100 000 = 0.0001 SOL) per stamp; the bps arm isstamp_fee_bps(defaultDEFAULT_STAMP_FEE_BPS= 100 = 1%) of the postage being escrowed, so the fee scales with premium-priced stamps instead of rounding to noise beside them. At the defaults the arms cross at 0.01 SOL of postage per stamp: cheap friend-tier stamps pay the flat fee, a default-priced 1-SOL stranger stamp pays 0.01 SOL. The refundable signature surcharge is not stamp value and is never in the bps base. - Operator split (v0.37.0): when the purchase’s account list carries
the operator tail — the recipient’s mailbox, its named domain, and the
domain authority — the authority receives
operator_share_bpsof the fee and the postoffice the rest. The buyer’s total is identical either way; only the fee’s destination splits. The lapse and filler rules mirror the settlement share: no domain named, a closed or inactive domain, or a closed mailbox lapse the share back to the postoffice, while present-but-wrong accounts are refused (a purchaser cannot reroute the share to itself). Legacy five/six-account purchases keep today’s whole-fee-to-postoffice behavior — the tail is optional, so old clients never break. - Waiver: the fee is skipped iff the purchase’s fee payer is the
recipient wallet (
fee_payer == to_account) — the whole hybrid, both arms. The payer is a required signer, so the check is unforgeable. This keeps the adoption path free: a mailbox owner who prices a correspondent’s frombox low and prefunds its stamps pays no protocol fee, and the recipient’s settlement payouts are untouched — mailbox owners perceive zero cost. Gift purchases by anyone else pay the fee. - Governance: the delegate tunes the
flat arm with
SetStampFee(sithbit postmaster fee stamp <LAMPORTS>), hard-capped on-chain atMAX_POSTOFFICE_STAMP_FEE_LAMPORTS(1 000 000), and the bps arm withSetSettlementBps(sithbit postmaster fee settlement <OPERATOR_SHARE_BPS> <STAMP_FEE_BPS>), capped atMAX_STAMP_FEE_BPS(1 000 = 10%) — so a compromised delegate key cannot price-gouge purchasers. A zero bps rate stores the “unset” sentinel and resolves to the protocol default (the rate cannot be tuned to literal zero; the flat arm can). There is no on-chain read instruction — account state is world-readable; anyone can query the current schedule with the ungatedsithbit postoffice fee stamp(both arms) orsithbit postoffice fee settlement(both bps rates). - Layout migration: postoffice accounts created before the fee field (32-byte layout) still work everywhere — readers parse them with a versioned load that applies the default fee, and the writers (the fee setters and the ownership instructions) grow the account in place (resize + rent top-up from the signing delegate) on their next run.
Domain authorities
CreateDomain takes an explicit payer — either the delegate or the
domain authority may fund the domain account’s rent plus the
DOMAIN_AUTHORIZATION_FEE_LAMPORTS (0.01 SOL default, delegate-tunable
via SetDomainFee up to MAX_DOMAIN_AUTHORIZATION_FEE_LAMPORTS) paid to
the postoffice.
Either way the delegate must sign: domain ownership is proven
off-chain (the authority’s pubkey in the domain’s DNS TXT record, checked
by the delegate’s trusted agent), so on-chain authorization is always
the delegate’s — authority-pays is a co-signed two-signature
transaction, never authority-alone. The domain records its rent_payer,
and CloseDomain (delegate-signed) refunds the rent to whoever paid.
When the delegate sponsors a domain itself, the fee is a wash — it lands
in the postoffice the postmaster can sweep. Deactivation remains a
separate, reversible toggle. In exchange, the authority earns the
operator share (default 10%) on every settled message for its domain.
The permissionless proof-carrying
path (AuthorizeDomainByProof, the CLI’s
sithbit domain authorize) charges its payer the same fee, once the
on-chain DNSSEC verification succeeds — fee parity keeps the two
authorization routes economically interchangeable; a failed proof charges
nothing.
Alias holders
An alias costs its own account rent plus a claim fee paid to the
mail program’s postoffice — squatting a name now has a price. The claim
fee is length-tiered (v0.35.0): names of 5 or more characters pay the
flat ALIAS_FEE_LAMPORTS (0.01 SOL default), while 1–4 character names
are scarce assets (36 one-character, ~1.3k two-character combinations)
and pay a scarcity premium from the ALIAS_TIER_FEES_LAMPORTS schedule —
by default 10 SOL (1 char), 1 SOL (2), 0.1 SOL (3), and 0.05 SOL (4).
The CLI and the web register form both quote the fee before you sign;
premium pricing is never charged silently. Handing an alias to a new
holder for free likewise
pays the ALIAS_TRANSFER_FEE_LAMPORTS (0.001 SOL default) — charged when
the recipient accepts a zero-fee transfer offer (v0.7.0: every transfer is
an offer the recipient must accept), and waived when the offer’s holder is
the standing delegate; a priced sale pays the 90/10 split below instead.
The flat fees are
delegate-tunable via SetAliasFee (one instruction sets the claim and
transfer fees together) and the premium schedule via SetAliasTierFees,
every value bounded by its own on-chain cap
(MAX_ALIAS_FEE_LAMPORTS / MAX_ALIAS_TRANSFER_FEE_LAMPORTS /
MAX_ALIAS_TIER_FEES_LAMPORTS, the tier caps at 10× their defaults).
Delegate reservations waive the claim fee at every length, so the
postmaster can reserve premium short names for rent alone and resell them
on the marketplace at seller-set
prices. Names
remain globally unique, first-come, and never expire. CloseAlias lets
the holder (the wallet the alias points at) reclaim the rent; the fee is
not refunded.
Escrowed alias transfers
Selling an alias
(alias transfer init --fee) stages an offer that the named recipient later
accepts by paying the fee. Since v0.7.0 this escrowed offer is the ONLY
transfer path — a zero fee stages a free hand-off that still needs the
recipient’s accept. The money flow for a priced offer:
- Nothing monetary ever sits in escrow. The escrow account holds only the offer’s terms (recipient, fee, expiry) plus its own rent-exemption; the fee itself never parks anywhere. It moves at accept, in the same atomic instruction that repoints the alias — so a cancelled or expired offer has no refund leg, because no money was ever taken.
- At accept, the fee splits 90/10 (at the default rate): the paying
wallet (the recipient,
or a sponsor co-signing via
--payer-keypair) transfers the seller’s share to the alias’s current holder and the operator share to the mail program’s postoffice. The cut isoperator_share_bps(defaultOPERATOR_SHARE_BPS= 1 000 bps = 10%), the same delegate-tunable rate theDeleteMailsettlement uses for the domain operator’s share — retuned withSetSettlementBpsup toMAX_OPERATOR_SHARE_BPS(2 000 = 20%), one rate for every settlement split; the postoffice’s leg is floored and the rounding dust goes to the seller. - Escrow rent round-trips to the seller. The holder fronts the escrow account’s rent-exemption when staging the offer and gets it back when the escrow closes — on accept and on cancel (including the expiry-reclaim cancel). Replacing a standing offer reuses the funded account: no additional rent.
- No flat transfer fee rides a PRICED offer. The 90/10 split is the
paid path’s entire economics. A zero-fee accept (a free hand-off)
instead pays the flat
ALIAS_TRANSFER_FEE_LAMPORTSlever above to the postoffice — waived when the offer’s holder is the standing delegate, keeping operator reservation hand-offs fee-free end to end.
Open marketplace listings
Listing an alias or a
domain for sale (alias sell / domain sell) inherits the escrowed-transfer money flow above wholesale,
with one difference: there is no named recipient — any buyer may pay the
fixed price and take the asset. The seller is the alias’s current holder,
or the domain’s current authority (the one authority-signed domain
instruction; every other domain mutation is delegate-gated). The money
flow:
- Nothing monetary ever sits in the listing. The listing account holds only the sale’s terms (seller, price, staged-at, expiry) plus its own rent-exemption; the price never parks anywhere. It moves at buy, in the same atomic instruction that swaps ownership — so a cancelled or expired listing has no refund leg, because no money was ever taken.
- At buy, the price splits 90/10 (at the default rate): the paying
wallet (the buyer, or a
sponsor co-signing via
--payer-keypair, which also fronts the two-signature transaction fee) transfers the seller’s share to the seller and the operator share to the mail program’s postoffice. The cut isoperator_share_bps(defaultOPERATOR_SHARE_BPS= 1 000 bps = 10%), the same delegate-tunable rate theDeleteMailsettlement, the escrowed alias transfer, and the reply-bounty claim use — retuned withSetSettlementBpsup toMAX_OPERATOR_SHARE_BPS(2 000 = 20%), one rate for every settlement split; the postoffice’s leg is floored and the rounding dust goes to the seller. - The fee is seller-side. The buyer pays exactly the listed price, no more; the share comes out of the seller’s proceeds. A seller who wants to net a target amount prices it in — the marketplace’s positioning matches the rest of the protocol, where the postoffice’s revenue rides the party monetizing an asset, never the party adopting one.
- Listing rent round-trips to the seller. The seller fronts the listing account’s rent-exemption when staging and gets it back when the listing closes — on buy and on cancel (including the expiry-reclaim cancel). Replacing a standing listing reuses the funded account: no additional rent.
- No flat fee on this path either. Like the escrowed accept, a buy
pays no
ALIAS_TRANSFER_FEE_LAMPORTS-style flat fee — the 90/10 split is the marketplace’s entire economics, for aliases and domains alike (a marketplace domain sale also pays noDOMAIN_AUTHORIZATION_FEE_LAMPORTS; that fee prices authorizing a new domain, not re-selling an authorized one). - A listed name is locked to its listing. While a listing is open the
asset can’t be moved out from under it:
closeand the transfer paths (alias transfer init, the delegate’sdomain transfer) refuse until the seller cancels, and a domain mid-deactivation-timelock can be neither listed nor bought. The buy itself also re-checks the seller, so a stale listing surviving a change of ownership can never sell the new owner’s name at the old owner’s price. See the guard notes on the alias and domain listing pages. - A domain sale conveys protocol authority only — the mail-injection signing right and the operator share; never the DNS name, MX hosting, or DKIM keys, which stay with whoever holds them off-chain. Buyers should read What buying a domain does — and does not — buy before paying.
Reputation-scaled first-contact pricing
The mailbox default postage is a blunt instrument on purpose: 1 SOL per stamp — a wall the recipient lowers per sender — prices out spam, phishing, and any other free-to-send abuse from senders the recipient has never met. But “never met” describes two very different wallets — a fraudster’s freshly-minted burner and a legitimate organization that has been paying postage across the network for months. Since v0.39.0 the protocol tells them apart: the default price a stranger pays at first contact scales with the sender wallet’s on-chain track record, while spam economics are untouched (a burner wallet has no record and pays full price).
The record is the sender-reputation account — a small mail-program PDA,
one per sender wallet, holding the wallet’s cumulative
distinct-recipient postage spend. Every third-party CreateFrombox
(each one a first purchase toward a new recipient) that carries the
reputation tail records the postage it escrows — postage only, never the
refundable surcharge or the protocol fee — onto the payer’s record. Current
clients (the CLI, the wasm builders, and the web prepay) carry the tail by
default; the account is lazily created on its first use, rent funded by the
payer.
That cumulative spend steps the rate a stranger’s first contact is priced
at, in basis points of the recipient’s default_postage:
| Cumulative postage spend | First-contact rate | At the 1-SOL default postage |
|---|---|---|
| below 0.1 SOL | 10,000 bps (full price) | 1 SOL |
| from 0.1 SOL | 7,500 bps | 0.75 SOL |
| from 1 SOL | 5,000 bps | 0.5 SOL |
| from 10 SOL | 2,500 bps | 0.25 SOL |
Three boundaries keep the mechanic honest:
- Only the default is scaled — a recipient-set price is never touched. The discount applies exactly where the frombox would have inherited the mailbox’s default postage: a stranger’s first purchase. A price the recipient chose — cheaper for a friend, punitive for a nuisance — applies verbatim, whatever the sender’s reputation, and repricing stays exclusively the recipient’s lever. Recipients keep full sovereignty and full revenue: the discounted postage still settles to them, and a recipient who wants full price from everyone simply sets their prices rather than relying on the default.
- The discount has a floor. However much reputation a wallet
accumulates, first contact never prices below
reputation_floor_bpsof the recipient’s default —DEFAULT_REPUTATION_FLOOR_BPS= 1,000 bps (10%) by default, delegate-tunable withSetReputationFloor(sithbit postmaster fee reputation-floor <BPS>) up to the 10,000-bps cap (100%, which disables the discount entirely; over-cap refuses with custom error 102ReputationFloorAboveCap, and zero resets to the protocol default). And a nonzero asking price never rounds to zero — first contact is never free. - A verified-sender attestation is the fast path. A purchase whose account tail carries the payer’s attestation for the from address’s domain prices first contact at the floor immediately — no spend history required. Proven DNS control plus the attestation fee substitutes for months of postage: the two friction mechanics compose instead of stacking.
Owner purchases — the recipient prefunding a sender’s frombox — are
unaffected: they ride the legacy account list, pay the raw default, and
record no spend (a recipient prepaying their own inbound mail is not
sender reputation). Anyone can read a wallet’s standing with the
ungated sithbit postoffice reputation <WALLET>, which prints the
recorded cumulative spend and the effective first-contact rate in bps,
floor included; server operators get the same two figures over gRPC
via the gateway’s
GetSenderReputation RPC.
This is the positioning principle again, applied to the sender side: reputable and attested senders earn cheaper first contact — friction should price out spam, not commerce — while every discounted lamport still flows to the recipient, whose own prices the protocol never overrides.
Pinning leases
The auto-settle sweeper
releases a delivered copy’s IPFS pin about 30 days after delivery — a
generous default that most mail never needs to outlive. For the mail that
does, a pinning lease (sithbit mail lease)
pays for extended retention: a small on-chain account, one per
(CID, holder wallet), whose existence asks operators to keep that CID
pinned. Operators consult it before releasing a pin, and the check fails
safe — an operator that cannot prove a CID unleased keeps the pin.
The shape is deliberately deposit-heavy, fee-light:
- The deposit is reclaimable capital, not spend. A lease escrows at
least
PIN_LEASE_MIN_DEPOSIT_LAMPORTS(0.01 SOL) on its own account and returns it in full — with the rent — the moment the holder closes the lease. Retention costs opportunity, not money. - The only spend is a one-time creation fee (default 0.001 SOL, tunable up to 10×), split at the standard operator share with the recipient’s registered domain authority — the operator actually storing the bytes — the remainder to the postoffice. No recurring or renewal fee exists, deliberately: a lease held for a year costs exactly what a lease held for a week costs.
- Anyone may hold one. Retention is usually a recipient desire, but senders and third parties can lease a CID too; per-(CID, holder) keying means independent leases never contend.
Against the positioning principle: the default retention stays generous and free (nobody needs a lease to read their mail — the local copy survives settlement regardless), the lease is a strictly opt-in power-user extension, and its fee is sender/holder-side revenue that partly lands on the operator storing the data. The read path stays toll-free.
Where SOL parks or strands
- Message accounts hold rent + stamp value until settlement. Both
sides have an incentive to settle (sender: rent; recipient: postage),
but nothing on-chain forces it, and the only client-driven trigger is an
IMAP/POP expunge — so a recipient who archives mail forever, never
deleting it from their client, leaves every message’s postage parked on
its PDA indefinitely: their own postage revenue unrealized, the sender’s
rent locked, and the operator’s 10% share uncollected. The auto-settle
worker (
[spooler.settle], on by default) closes that gap without waiting on the recipient’s mail client: it firesDeleteMailafter_days(default 30) past confirmed delivery, which realizes the recipient’s postage and the operator share and returns the sender’s rent — while keeping the recipient’s local copy. Settlement reclaims the on-chain value; it does not delete the mail the recipient reads. What it does remove is the on-chain message account, and by default the sealed IPFS body too (keep_pin = falseunpins it), so a past-window settled message is only trustlessly fetchable from the decentralized copy ifkeep_pinwas set to leave the pin in place. - Every other account class now has a close path:
CloseFrombox(the recipient reclaims rent plus any residual stamp value — stamps never used; a sender who prepaid against their own wallet address can instead withdraw that residual themselves withReclaimFromboxStamps, leaving the account alive on its rent), the two-step mailbox close andCloseKey(owner reclaims rent — the mailbox’s only atFinalizeCloseMailbox, 7 days after the request; the key account’s on the spot),CloseAlias(holder reclaims rent),CloseDomain(rent back to the recorded payer), andWithdrawPostoffice(the postmaster — a revealed key-ceremony key, see The Postmaster — sweeps accumulated revenue). A closed mailbox’s undelivered message accounts stay open and settle individually viaDeleteMail; recreating the mailbox restarts message ids at zero, so new sends fail until those old message accounts are deleted — an availability nuisance, never a loss of funds.
Refunds: postage as a refundable deposit
DeleteMail settles a message to the recipient; RefundMail settles the
same message back to the sender. It is the mirror image of the split in
Where SOL parks or strands: where settlement
collects a stranger’s postage to the recipient and the domain operator, a
refund returns that postage to the sender in full — and, as in DeleteMail,
the message account then drains and is reaped.
The rent and signature legs are unchanged from DeleteMail: the sender
recovers the message rent plus one signature fee, and the refund signer
recovers this transaction’s signature fee — both still prefunded by the
STAMP_FEE_SURCHARGE_LAMPORTS bought at the Buying step. What changes is
where the postage goes:
- the operator share is waived — a refund is not a revenue event, so
OPERATOR_SHARE_BPSis not applied and the domain authority collects nothing; - the recipient collects nothing — the postage they would have earned on
a
DeleteMailis not theirs on a refund; - the whole remaining postage returns to the sender, on top of the rent and signature fee they already recover.
A refund is recipient-signed: only the mailbox owner can give a
stranger’s postage back, so a refund can never be used to claw postage away
from a recipient who means to keep it. Trigger it with the CLI —
sithbit mail refund <message_id> — or the RefundMail method on the
gRPC chain gateway.
Why refunds matter: a deposit, not a price
Without refunds, postage is a one-way price: a legitimate stranger who pays
the recipient’s spam-pricing floor (the CLI’s default 1 SOL, which the
recipient lowers per sender — see Sender) has no way to get it back, so the very
defense that prices out spammers also prices out good-faith strangers.
RefundMail reframes stranger postage as a refundable deposit rather
than a sunk cost. The spam defense is untouched — a spammer’s postage still
settles to the recipient on DeleteMail, and spammers never see a refund —
while a recipient who recognizes a good-faith first contact can choose to
make that sender whole. Because the deposit is only ever returned by the
recipient’s own signature, pricing stays the recipient’s spam control
exactly as it was; refunds add a release valve, not a loophole.
Reply bounties
Where postage pays the recipient for attention, a reply bounty pays them for an answer — the flagship of the protocol’s sender-pays positioning: the recipient doesn’t just avoid cost by being on SithBit, they earn by replying. Campaigns batch exactly this flow across an opted-in audience. The money flow:
- The escrow is the message account itself.
mail send --bountyfolds the bounty into the message account’s opening balance (rent + postage + bounty, all fronted by the sender at send time); no separate escrow account exists, so there is no extra rent leg and nothing else to close. - At claim, the bounty splits 90/10: the recipient — having put a
reply on-chain that carries the bounty’s reply linkage — collects 90%,
and the 10% operator share follows the same domain-resolution rules
as the
DeleteMailsettlement above: the claimant’s domain authority collects it when their mailbox names an active domain, and it falls to the mail program’s postoffice when the chain legitimately doesn’t resolve — no mailbox, no domain named, domain closed or inactive (whereDeleteMail’s unresolved share folds into the recipient’s postage, a claim’s goes to the postoffice). As inDeleteMail, a named, active domain must be presented with the correct authority account or the claim is rejected, so a claimant can’t redirect the operator’s cut with filler accounts. The cut isoperator_share_bps(defaultOPERATOR_SHARE_BPS= 1 000 bps = 10%), the same delegate-tunable rate theDeleteMailsettlement and the escrowed alias transfer use; the operator’s leg is floored and the rounding dust goes to the claimant. At the default rate a 5 000 007-lamport bounty splits as 500 000 to the claimant’s domain authority — or to the postoffice, for a domainless claimant — and 4 500 007 to the claimant. - An expired bounty refunds in full. Claims are legal through the
exact expiry instant; strictly after it,
mail refund-bountyreturns the whole bounty to the sender. LikeRefundMailabove, a bounty refund is not a revenue event — no operator or postoffice share is taken. - Deletion returns a riding bounty to the sender. If a bountied
message is settled by
DeleteMail— including the auto-settle worker’s — the unclaimed bounty joins the sender-refund leg. It never converts into recipient postage: the only path that pays the recipient is a claim. - Nothing is claimable without the on-chain reply linkage. The claim
evidence is a reply, in the sender’s mailbox, whose
reply_to_hashnames the bountied message account (a blake3 hash — no addresses on chain); claiming zeroes the bounty so no reply can claim twice.
Campaigns
A
campaign introduces no new money mechanics —
it is a batch of the flows already traced above, fired from one funded wallet
at a set of opted-in recipients. Economically it is N bountied
SendMails, and every lamport is sender-side: the
campaign wallet fronts the entire per-recipient cost, which is exactly the
quote itemization:
| Per recipient, the campaign wallet fronts | Which flow above |
|---|---|
| message account rent (≈, refundable) | the stamp lifecycle — parked on the message account |
| a new frombox’s rent (≈, refundable — first contact only) | one frombox per (campaign wallet, recipient) pair |
| postage | the recipient’s live price — settles to the recipient on DeleteMail |
| the stamp-purchase protocol fee on that postage | the hybrid flat/bps fee every prepay pays |
| the escrowed reply bounty | folded into the message account, three exits |
SIGNATURE_FEE_LAMPORTS per transaction + one STAMP_FEE_SURCHARGE_LAMPORTS | the base tx fees and the two-signature settlement prefund |
One line is campaign-wide, not per-recipient: a sender whose very first first-contact prepay happens in this campaign also fronts the one-time rent of their sender-reputation account (the prepay lazily creates the PDA), charged once no matter how many first contacts the batch holds.
The quote behind this table reads each recipient’s live chain state rather than assuming the 1 SOL protocol default: a recipient whose frombox already holds prepaid stamps costs only the send-side terms (no postage, no fee, no surcharge), a stampless frombox tops up at its stored — possibly lowered — price, and only true strangers price at the reputation-scaled first-contact rate. Unprepaid recipients carry two signature fees, one on the prepay transaction and one on the send transaction; prepaid recipients carry only the send’s.
The recipient never pays — they only collect. A participant profits twice per campaign message: the postage lands as recipient income when the message settles, and the reply bounty pays 90% at the default rate (the operator share goes to their domain authority, or the postoffice when no active domain resolves) if they answer before the window closes. Unanswered bounties are refunded to the campaign wallet in full — a refund, not a revenue event.
Opting in costs a participant nothing but a refundable deposit: the beacon account’s rent, returned when they close it. This keeps campaigns squarely on the right side of the sender-pays positioning — the burden sits entirely on the advertiser, and the recipient’s whole interaction is upside.
Modeling the postoffice’s revenue base
The settlement rates above exist because the postoffice’s naive revenue model — “a flat fee on every stamp” — mostly rounds to zero once you segment who actually buys stamps. The honest model, at the defaults and the pinned $81.16/SOL rate used throughout this page:
Stamp purchases segment into three populations, and two of them pay nothing or almost nothing:
- Owner-prefunded fromboxes (the “friends mail you free” path): the recipient buys stamps for their correspondents, the waiver applies, revenue is zero by design. This waiver is load-bearing for the protocol’s feels-free positioning and is not a lever — every fee design must survive it.
- Known-sender third-party prepay (a sender funding their own frombox after the recipient priced it down): postage here is friend-tier — thousands to tens of thousands of lamports — so the flat arm dominates and each stamp yields the 100 000-lamport flat fee (~$0.008). Real, but linear in mail volume and small.
- Default-priced strangers (1 SOL postage): mostly priced out —
that is the postage floor’s job — so volume is low by construction.
(Since v0.39.0, reputation-scaled first-contact
pricing steps this
segment’s postage down for senders with a spending track record; the
bps fee arm rides whatever postage is actually escrowed, so a
discounted conversion pays proportionally less fee but converts more
often.)
Before v0.36.0 each rare conversion still paid only the 100 000
flat fee: the protocol earned ~$0.008 on a
$81 postage escrow. The bps arm fixes exactly this segment: at the default 100 bps a converted 1-SOL stamp now pays 0.01 SOL ($0.81) — 100× the flat fee — while segments 1 and 2 are untouched (waived, or below the 0.01-SOL crossover).
(Since v0.37.0, purchases carrying the operator tail split each charged
fee operator_share_bps to the recipient’s domain authority — so the
postoffice’s take in segments 2 and 3 is ~90% of the figures above at
the default rate, with the other 10% funding the operator the same way
settlements do.)
The non-stamp streams scale with marketplace activity, not mail
volume, and all ride the one operator_share_bps rate: alias/domain
marketplace sales and auction settlements (the share lands on the
postoffice directly), reply-bounty claims (domain authority, postoffice
only for domainless claimants), and the DeleteMail operator share
(domain authority — the postoffice keeps only the sub-1 000-lamport
rounding residue). A 1-SOL alias sale yields the postoffice 0.1 SOL at
the default rate; premium-name claim fees (the length tiers above) are
one-time but far larger per event.
Sensitivity, honestly stated: the dominant unknown is the waiver share — the fraction of stamps bought on the waived path — which no protocol lever changes and which the positioning wants high. Bps revenue scales with postage prices the protocol does not set (recipients do) and with conversion rates on a floor designed to deter conversion. The model’s conclusion is therefore structural, not a projection: stamp-fee revenue is real but bounded, the settlement rates are the scalable levers because they piggyback every value flow without new per-feature fees, and both are capped (10% / 20%) so the “minimal rake, no token” positioning survives a hostile delegate key.
Which unit each surface speaks
On-chain there is exactly one money unit: the lamport, one-billionth of a SOL. Every price, fee, deposit, and balance the three programs store is an integer count of them, and so is every figure on this page. What differs is how each client spells that integer, and the split between them is a rule:
- The CLI takes lamports.
alias sell --price,alias sell --auction --reserve,alias bid --amount,mail send --bounty,mailbox create --default-postage, the delegate’spostmaster fee …setters — every money argument thesithbitbinary reads is an integer number of lamports. The CLI is a scripting surface, and an exact integer in the chain’s own unit composes: it survives shell substitution and generated command lines with no float rounding, no locale decimal separator, and no ambiguity about which unit a bare number is in — and it is the same unit an RPC response or a program error quotes back. Reports may annotate a figure with a SOL and best-effort USD tail for readability (sithbit earnings,frombox get,mailbox get --usd); nothing the CLI accepts is denominated in SOL. - The human-facing clients show SOL. Webmail, the marketplace, and the Outlook / Thunderbird / Chrome panes quote stamp prices, wallet balances, reply bounties, and asking prices in SOL, because SOL is the unit a person holds in a wallet and compares against a price. They convert at their own edge — someone types an asking price of 0.05 SOL, the pane sends 50 000 000 lamports.
No surface mixes the two: everything on a sithbit command line is
lamports, everything in a webmail or marketplace field is SOL. The
auction flags follow the
rule rather than carve an exception out of it — --reserve and --amount
are lamports because their siblings --price and --fee are, and a
marketplace that read a reserve in SOL and a fixed price in lamports would
be a foot-gun for anyone scripting both.
Constants worth knowing
Protocol economics are consensus constants in mail_model::constants. Four
are fixed and never change without a program upgrade:
| Constant | Value | Used by |
|---|---|---|
SIGNATURE_FEE_LAMPORTS | 5 000 (0.000005 SOL) | the real base tx fee — replaces the deprecated 10 000-lamport fee-calculator default that overcharged buyers ~2× |
STAMP_FEE_SURCHARGE_LAMPORTS | 10 000 (2 signatures) | prefunds the SendMail + DeleteMail signature refunds; charged per stamp at AddStamps/CreateFrombox |
DEFAULT_POSTAGE_LAMPORTS | 1 000 000 000 (1 SOL) | the spam-pricing floor CreateMailbox applies when the instruction omits a price — a wall the owner lowers per sender, not a going rate |
POSTAGE_ROUNDING_LAMPORTS | 1 000 | the quantum a recipient’s DeleteMail payout rounds down to; the remainder accrues to the postoffice |
The other nine are defaults, not fixed constants — the delegate can
retune each one, and each is bounded on-chain by its own MAX_* cap so a
compromised delegate key cannot price the protocol out of reach:
| Constant (default) | Default value | Hard cap | Set with | Charged by |
|---|---|---|---|---|
POSTOFFICE_STAMP_FEE_LAMPORTS | 100 000 (0.0001 SOL) | MAX_POSTOFFICE_STAMP_FEE_LAMPORTS = 1 000 000 | SetStampFee | AddStamps/CreateFrombox, third-party purchases only — the flat arm of the hybrid per-stamp fee |
DEFAULT_STAMP_FEE_BPS | 100 (1%) | MAX_STAMP_FEE_BPS = 1 000 (10%) | SetSettlementBps (a zero rate means “unset” and charges its default) | AddStamps/CreateFrombox — the bps arm of the hybrid, on the escrowed postage; same purchases, same waiver |
OPERATOR_SHARE_BPS | 1 000 (10%) | MAX_OPERATOR_SHARE_BPS = 2 000 (20%) | SetSettlementBps (one instruction sets both bps rates; zero means “unset”) | DeleteMail settlement, escrowed alias transfers, marketplace listings, auction settlements, and reply-bounty claims alike |
DOMAIN_AUTHORIZATION_FEE_LAMPORTS | 10 000 000 (0.01 SOL) | MAX_DOMAIN_AUTHORIZATION_FEE_LAMPORTS | SetDomainFee | CreateDomain and AuthorizeDomainByProof — both paths cost the same |
DEFAULT_SENDER_ATTESTATION_FEE_LAMPORTS | 10 000 000 (0.01 SOL) | MAX_SENDER_ATTESTATION_FEE_LAMPORTS = 100 000 000 | SetSenderAttestationFee (a zero fee means “unset” and charges the default) | AttestSender — the one-time verified-sender attestation, paid by the attesting payer; a failed proof charges nothing |
ALIAS_FEE_LAMPORTS | 10 000 000 (0.01 SOL) | MAX_ALIAS_FEE_LAMPORTS | SetAliasFee | CreateAlias, names of 5+ characters |
ALIAS_TRANSFER_FEE_LAMPORTS | 1 000 000 (0.001 SOL) | MAX_ALIAS_TRANSFER_FEE_LAMPORTS | SetAliasFee (one instruction sets both alias fees) | AcceptTransferAlias of a zero-fee (free hand-off) offer only — priced offers and marketplace sales pay the operator-share split instead; waived for a delegate holder |
ALIAS_TIER_FEES_LAMPORTS | 10 / 1 / 0.1 / 0.05 SOL for 1/2/3/4-character names | MAX_ALIAS_TIER_FEES_LAMPORTS (10× each default) | SetAliasTierFees (one instruction sets all four slots; a zero slot means “unset” and charges its default) | CreateAlias, names of 1–4 characters; waived — like every claim fee — for the delegate |
DEFAULT_REPUTATION_FLOOR_BPS | 1 000 (10%) | MAX_REPUTATION_FLOOR_BPS = 10 000 (100% — disables the discount) | SetReputationFloor (a zero rate means “unset” and resolves to the default) | not charged by anything — the floor under reputation-scaled first-contact pricing: the share of a mailbox’s default postage below which a stranger’s first contact never prices |
DEFAULT_PIN_LEASE_FEE_LAMPORTS | 1 000 000 (0.001 SOL) | MAX_PIN_LEASE_FEE_LAMPORTS = 10 000 000 | SetPinLeaseFee (a zero fee means “unset” and charges the default) | CreatePinLease — the one-time pinning-lease creation fee, split at the operator share with the recipient’s domain authority |
PIN_LEASE_MIN_DEPOSIT_LAMPORTS | 10 000 000 (0.01 SOL) | — (a plain constant, not a tunable) | — | the reclaimable deposit floor a CreatePinLease must escrow; returned in full at ClosePinLease |
Tune them with sithbit postmaster fee stamp / fee domain / fee alias
/ fee alias-tiers / fee settlement / fee attestation /
fee reputation-floor / fee pin-lease
(a delegate-only op), and read the current values back with the public,
read-only sithbit postoffice fee stamp / fee domain / fee alias /
fee settlement / fee pin-lease (the
alias getter prints the flat fees and the effective per-length premium
schedule together; the settlement getter prints both bps rates; the
effective floor prints with any wallet’s sithbit postoffice reputation). A
postoffice account that predates a fee field reads back its protocol
default.
On-chain CreateMailbox applies the 1-SOL DEFAULT_POSTAGE_LAMPORTS
spam-pricing floor (the wall the owner then lowers per sender) when the
instruction omits a price — a raw-instruction
caller must opt into a cheaper (or free) mailbox explicitly; the safety
margin is no longer client-side only.
Rent hygiene (implemented)
The follow-ups the original analysis recommended are now protocol behavior (see the “Economics” record in HANDOFF.md):
- Domain economics —
CreateDomaintakes an explicit payer (delegate or authority; the delegate always signs), chargesDOMAIN_AUTHORIZATION_FEE_LAMPORTSto the postoffice, and records therent_payersoCloseDomaincan refund the rent to whoever funded it. - Close instructions —
CloseFrombox(recipient reclaims residue + rent) and its sender-side counterpartReclaimFromboxStamps(a wallet-address sender withdraws its own unspent postage; the account survives on its rent),RequestCloseMailbox/FinalizeCloseMailboxandCloseKey(owner reclaims rent; see the close timelock below),CloseAlias(holder reclaims rent), andWithdrawPostoffice(the postmaster — via a key-ceremony proof — sweeps the postoffice balance above its rent-exempt minimum — without which the accumulated protocol fee revenue would be unspendable). - Alias fee — an
ALIAS_FEE_LAMPORTScharge on alias creation (and anALIAS_TRANSFER_FEE_LAMPORTScharge on transfer), CPI-transferred to the mail program’s postoffice, pricing out squatting. Both are delegate-tunable (SetAliasFee, capped on-chain) and waived when the payer is the delegate (the round-trip waiver kept its shape through the delegation cutover) — see bulk alias reservation.
The mailbox close timelock
Rent hygiene has an abuse edge. Rent that comes back instantly makes an
identity disposable: a sender whose wallet had burned its reputation could
CloseMailbox, take the full refund, and stand up a fresh identity for the
price of a signature. Cheap identity-cycling is the one thing a
postage-priced system cannot afford, because postage only bites a sender
who has something to lose by being recognized.
So closing a mailbox is now
timelocked: a request
starts a 7-day clock and refunds nothing (the mailbox stays open and keeps
receiving mail), a finalize past the clock closes it and returns both the
mailbox’s rent and the transient pending record’s, and a cancel aborts the
request meanwhile. The one-step CloseMailbox is refused on-chain with
error 94, InstantCloseDisabled.
The lever here is deliberately a delay, not a fee, and that follows straight from the positioning principle this whole page is written against — the system must feel free to use and be profitable for recipients, with the cost burden on senders. A mailbox owner is a recipient. Charging them to leave would be a recipient-side cost, which is presumptively the wrong shape. Time is the only currency available that bills the spammer (capital stuck for a week per burned identity, plus a week in which operators can watch a mailbox announce its own close) while costing an honest owner nothing but patience on an action they take approximately never. The rent comes back in full either way.
CloseKey is deliberately exempt and stays instant — it revokes a
compromised delegated encryption key, where a waiting period would protect
the attacker rather than the owner. See
the threat model.
Money is only half the picture: for what each participant must trust —
the MX operator’s sender authentication, the postoffice admin keys, public
message metadata, and frombox custody (including that CloseFrombox
returns third-party-funded stamps to the recipient, not the buyer — a
sender can only withdraw postage they prepaid against their own wallet
address) — see
Trust assumptions and threat model.
-
The recipient’s payout is rounded down to the nearest
POSTAGE_ROUNDING_LAMPORTS(1 000 lamports); the sub-quantum remainder — at most 999 lamports per message — accrues to the postoffice. This is below the smallest price increment anyone quotes and isn’t something senders, recipients, or operators need to think about. ↩
CLI Quickstart
This documentation is primarily geared towards developers rather than end-users. (Operators looking to run the mail services themselves should start at Running a mail server.) To follow the examples, you’ll need the sithbit CLI built from the sithbit-solana repository: clone the repo and build it with cargo build -p mail-client -r, which produces the sithbit binary at target/release/sithbit.
The fast path: sithbit setup
If you just want to get going, run the guided setup wizard and follow the prompts:
sithbit setup
It walks you through the two things you need — a Solana RPC endpoint and a signing wallet, offering to generate a fresh wallet if you don’t already have one — checks that the wallet holds enough SOL to claim a mailbox (funding it automatically from the faucet on devnet-style clusters, or walking you through a transfer on mainnet), and can optionally claim your on-chain mailbox in the same pass, then prints the next steps to start receiving mail. The wizard is re-runnable and non-destructive: it shows your current settings, changes only what you explicitly ask it to, and never overwrites an existing keypair, so running it again on a configured machine is safe. Pressing Enter at any prompt keeps the current value (with one exception: on a machine with no wallet yet, Enter at the endpoint prompt accepts a suggested default — mainnet for release builds, devnet for development builds); the mailbox step — the one that spends SOL — defaults to skip, so a scripted or piped run never sends a transaction. See First-run setup for a full walkthrough.
The rest of this page does the same two steps by hand — useful when you want to
understand exactly what setup writes, or to script the pieces individually.
Configuring a Solana RPC endpoint
Talking to the chain also needs a Solana RPC endpoint — the URL of a server that answers reads and forwards transactions for one of Solana’s networks (see Solana clusters and RPC endpoints for what the public clusters are and which one SithBit runs on). Configure one with sithbit config set --url <cluster> (e.g. https://api.devnet.solana.com), or point a JSON_RPC_URL environment variable at one directly.1
Creating a wallet
You’ll need a cryptographic keypair / wallet; its public key becomes your first email address. sithbit can generate one directly:
sithbit wallet create
# Wallet keypair written to '/home/you/.config/solana/id.json'
# Address: 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q03
By default this writes to the path sithbit config get reports as your
keypair path.2 Pass --outfile <path> to write elsewhere; sithbit wallet create refuses to overwrite an existing keypair file unless you
also pass --force.
Even though there is no domain portion of the address, i.e., 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q03@sithbit.com, your standalone public key address is already a legitimate email address in the system, although you will need to create a mailbox for it and at least one frombox before emails can be routed to your address. A frombox is keyed by a pair: your wallet address as the recipient, and a sender’s “from” address — but only the recipient side needs to be an on-chain wallet. The sender’s “from” address is just a plain, off-chain RFC822-compliant address string (e.g. jane_doe@sithbit.com, or any other domain) — it never has to resolve to a Solana keypair.
Neither step above needs the Solana CLI/SDK installed at all — sithbit config and sithbit wallet create are a complete substitute for it in this workflow.
-
If you already have the Solana CLI installed,
solana config set --url <cluster>writes the same config file. ↩ -
If you already have the Solana CLI/SDK installed,
solana-keygen newfollowed bysolana-keygen pubkeygets you the same keypair file and address. ↩
First-run setup: sithbit setup
sithbit setup
A guided, re-runnable first-run wizard. It configures the two things every other command depends on — a Solana RPC endpoint and a signing keypair — using plain stdin prompts, makes sure the wallet can afford a mailbox, can optionally claim your on-chain mailbox in the same pass, and then points you at the next steps. It takes no flags: everything is driven by the dialogue.
It is deliberately non-destructive. It reads your current config values up front and shows them, writes a change only when you type an explicit new value (pressing Enter keeps the current one), and confirms before generating a wallet — so re-running it on an already-configured machine, or against a funded keypair, never clobbers anything. The one exception to “Enter changes nothing” is a brand-new machine: when no wallet exists yet the wizard suggests a default endpoint, and pressing Enter accepts and writes that suggestion. The one step that spends SOL and sends a transaction — the mailbox create — defaults to no, so a piped or closed stdin never fires it by accident.
The wizard runs five steps:
- RPC endpoint. Shows the currently configured endpoint. Enter a new URL
to switch clusters (this writes the same
config
sithbit config set --urledits), or press Enter to keep the current one. On a machine with no wallet yet, the wizard instead suggests a build-appropriate default — mainnet for release builds, devnet for development builds — and Enter accepts and persists the suggestion. (The two endpoint constants live at the top ofmail_client/src/commands/setup.rs, so an operator shipping a hosted RPC endpoint swaps them in one place.) - Signing keypair. Shows the current keypair path and lets you change it.
If no readable keypair exists at the chosen path, it offers to generate a
fresh wallet there (the same artifact
sithbit wallet createwrites). It asks first, so a re-run never overwrites an existing wallet; decline and it prints the manualsithbit wallet createcommand instead. - Wallet funding. Checks that the settled wallet — freshly generated or
pre-existing — holds enough SOL to pay for a mailbox create (the account
rents, the flat alias fee, and a small fee buffer; about 0.0124 SOL). A
wallet that already covers it, or that already owns a mailbox, skips the
step with a note. Otherwise, on clusters with a faucet (devnet, testnet, a
local validator) the wizard requests an airdrop of one SOL, retrying a few
times because the public devnet faucet is flaky; on mainnet — or if the
faucet stays dry — it prints your wallet address and the required amount,
re-checks the balance each time you press Enter, and lets you type
skipto move on unfunded. (The devnet web faucet at faucet.solana.com is the manual fallback when the RPC faucet errors.) - Mailbox. Offers to claim your on-chain mailbox
right now, defaulting to skip (only an explicit
y/yesproceeds). Accept and it prompts for the mail domain (defaultsithbit.com) and the default stamp price in lamports (default one SOL), then drives the same on-chain create assithbit mailbox create— which also mints the wallet’s self-alias in the same transaction. It is re-run-safe: a create against a mailbox that already exists errors on-chain, and the wizard surfaces that message and carries on to completion rather than aborting. Decline and it prints the manualsithbit mailbox createcommand instead. - Next steps. Static guidance — create your on-chain
mailbox (if you skipped step 4), set a default
stamp price, and review your settings with
sithbit config get.
Example
A first run on a machine with no wallet yet, switching to devnet, letting the wizard generate a keypair, fund it from the faucet, and claim a mailbox inline:
$ sithbit setup
SithBit setup — configure your RPC endpoint and wallet.
Config file: /home/you/.config/solana/cli/config.yml
Step 1/5 RPC endpoint
Current: https://api.mainnet-beta.solana.com
New URL (Enter keeps current): https://api.devnet.solana.com
Set RPC endpoint to https://api.devnet.solana.com.
Step 2/5 Signing keypair
Current: /home/you/.config/solana/id.json
No keypair file found at /home/you/.config/solana/id.json.
Generate a new wallet there now? [Y/n]: y
Generated a new wallet at /home/you/.config/solana/id.json.
Step 3/5 Wallet funding
Requesting 1000000000 lamports (1 SOL) from the cluster faucet…
Airdropped 1000000000 lamports (1 SOL).
Step 4/5 Mailbox
A mailbox is your on-chain inbox — claim one to start receiving mail.
Create your mailbox on-chain now? [y/N]: y
Domain [sithbit.com]:
Default stamp price in lamports [1000000000]:
Created your mailbox for domain sithbit.com.
Step 5/5 Next steps
You're configured. To start receiving mail:
1. sithbit mailbox create # claim your on-chain mailbox
2. sithbit mailbox update # set your default stamp price
3. sithbit config get # review your settings anytime
Done.
On mainnet the funding step has no faucet to lean on, so a wallet short of the mailbox cost is shown its own address and the amount to send, and the wizard re-checks the balance each time you press Enter:
Step 3/5 Wallet funding
Creating a mailbox costs about 12415720 lamports (0.01241572 SOL).
Send at least that much to your wallet address:
85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q03
Press Enter to re-check the balance, or type 'skip':
If a keypair already exists at the chosen path the wizard reports
Found an existing keypair at … and skips generation entirely; a wallet that
already holds enough SOL passes the funding step with a one-line note, and one
that already owns a mailbox skips both the funding wait and the create prompt.
Declining the mailbox step (the default) prints Claim one later with: sithbit mailbox create and moves on, and re-running the wizard against a wallet that
already owns a mailbox prints You already have a mailbox — skipping. and
still finishes. The prompts also default cleanly on end-of-input, so a piped
or closed stdin takes every default — including skipping the funding wait
and the mailbox create — rather than hanging.
Prefer a browser? The four web clients walk a brand-new user through the same
five steps without a terminal — see
Getting started. And once you’re set up, the
read-only sithbit earnings snapshot shows what your wallet
holds and has taken in.
Looking up a mailbox
See Mailboxes for what a mailbox is
and the settings it holds. This page covers the sithbit mailbox get command.
sithbit mailbox get [owner_address]
mailbox get is a read-only query: it derives the mailbox
PDA for the
given address, reads the account, and prints its settings. It signs
nothing, spends nothing, and needs no CLI feature flag — anyone can inspect
any mailbox.
Arguments
[owner_address](optional) — the address whose mailbox to look up. Defaults to the address of your own configured keypair, sosithbit mailbox getwith no arguments looks up your own mailbox.
What it prints
mailbox get prints the mailbox’s mail count, default postage, domain, and
no-IPFS opt-out flag — see Mailbox settings
for what each one means.
Related commands
- Create a mailbox — create the mailbox for an address that doesn’t have one yet.
- Update a mailbox — change an existing mailbox’s settings.
- Mailbox keys — publish a delegated encryption key for signing-only wallets.
See Closing accounts for how to close a mailbox you no longer need.
Create a mailbox
See Mailboxes for what a mailbox is,
the settings it holds, and why creating one also claims a self-alias and
carries an IPFS opt-out trade-off. This page covers the sithbit mailbox create command.
sithbit mailbox create \
[--keypair <path>] \
[--default-postage <lamports>] \
[--domain <domain>] \
[--no-ipfs] \
[--for <address>] \
[--skip-preflight]
Before running it you need a Solana wallet — see
CLI Quickstart — and, if you’re naming a domain, that
domain must already be registered and active on-chain;
creation refuses an unregistered or deactivated name. A wallet has exactly
one mailbox, and a mailbox names exactly one domain (switch it later with
mailbox update --domain).
No encryption setup is required: mail servers seal mail straight to your wallet address, and your wallet keypair file decrypts it. Only wallets that cannot expose a decryption key (hardware and browser wallets, which can only sign) need to publish a delegated encryption key.
Arguments
--keypair <path>(optional) — the mailbox owner’s keypair. Defaults to the keypair in your Solana CLI config when omitted.--default-postage <lamports>(optional) — the starting default postage for the mailbox, in lamports. Defaults to 1 SOL worth of lamports; set it high to price out spam, then lower it per-sender by adjusting a frombox’s stamp price.--domain <domain>(optional) — the domain that can send mail to this mailbox, by name (e.g.sithbit.com) or by its account address. Defaults tosithbit.comwhen omitted.--no-ipfs(optional) — a bare flag; present opts the mailbox out of public IPFS body storage from the start, absent leaves it off (the default). See Opting out of IPFS storage.--for <address>(optional) — create the mailbox for a different owner (a base58 wallet address): a sponsored create, see below. Requires--domain, and the fee payer must be that domain’s on-chain authority.--skip-preflight(optional) — submits the transaction without a local simulation pass first.
Sponsored creation (--for)
A domain’s on-chain authority can provision mailboxes for other wallets
under its domain — see
Sponsored mailboxes
for the concept and its guards. With --for, the fee payer funds the
mailbox but the named address owns it:
- The payer must be the on-chain
authorityof the--domaindomain; anyone else is refused (UnauthorizedDomainSponsor, error 96). Omitting--domainis refused client-side, and a raw instruction without a domain fails on-chain (SponsoredMailboxRequiresDomain, error 95). - Any
--default-postageyou pass is overridden to the 1-SOL spam floor on-chain. - The self-alias is not bundled — the owner claims their own alias.
- The payer is recorded as the mailbox’s funder: closing the mailbox refunds its rent to the payer, not the owner.
Examples
Create a mailbox with an explicit postage and domain:
sithbit mailbox create \
--default-postage 100000 \
--domain sithbit.com \
--keypair <path to wallet keypair>
Create a mailbox that opts out of public IPFS storage from the start:
sithbit mailbox create \
--domain sithbit.com \
--no-ipfs \
--keypair <path to wallet keypair>
As a domain’s authority, sponsor a mailbox for one of your users (the postage you’d pass is forced to the 1-SOL floor either way):
sithbit mailbox create \
--for <user wallet address> \
--domain example.com \
--keypair <path to the domain authority's keypair>
Related commands
- Looking up a mailbox — inspect a mailbox’s settings, including whether it opted out of IPFS.
- Update a mailbox — change postage, domain, or the no-IPFS flag on a mailbox that already exists.
- Mailbox keys — publish a delegated encryption key for signing-only wallets.
Update a mailbox
See Mailboxes for what a mailbox is
and the settings it holds. This page covers the sithbit mailbox update
command.
sithbit mailbox update \
[--keypair <keypair>] \
[--default-postage <lamports>] \
[--domain <name or address>] \
[--no-ipfs <true|false>] \
[--skip-preflight]
All of --default-postage, --domain, and --no-ipfs are optional — only the
ones you supply are changed. For example, raising the default postage to price out a
recent wave of spam:
sithbit mailbox update --default-postage 2000000000
…or moving to a different domain:
sithbit mailbox update --domain sithbit.net
The new domain must already be registered on-chain and active — updating a mailbox to reference an unregistered or deactivated domain is refused.
…or toggling the IPFS opt-out:
--no-ipfs true opts out (your operator keeps bodies in its own store, off
public IPFS), and --no-ipfs false clears it so new mail is pinned to IPFS
again. Omitting the flag leaves the current setting untouched. The change
applies to mail delivered after it lands — bodies already pinned stay where
they are.
sithbit mailbox update --no-ipfs true
Arguments
--keypair <path>(optional) — the mailbox owner’s keypair. Defaults to the keypair in your Solana CLI config when omitted.--default-postage <lamports>(optional) — the new default postage for the mailbox, in lamports. Omitted leaves the stored value unchanged.--domain <name or address>(optional) — the domain that can send mail to this mailbox, by name (e.g.sithbit.net) or by its account address. Omitted leaves the stored domain unchanged.--no-ipfs <true|false>(optional) —trueopts the mailbox out of public IPFS body storage,falseclears the opt-out. Omitted leaves the stored flag unchanged. See Opting out of IPFS storage.--skip-preflight(optional) — submits the transaction without a local simulation pass first.
Examples
Raise the default postage to price out a recent wave of spam:
sithbit mailbox update --default-postage 2000000000
Move to a different domain:
sithbit mailbox update --domain sithbit.net
Opt a mailbox out of public IPFS storage, then opt back in later:
sithbit mailbox update --no-ipfs true
sithbit mailbox update --no-ipfs false
Related commands
- Looking up a mailbox — inspect a mailbox’s current settings before or after an update.
- Create a mailbox — create the mailbox in the first place.
- Mailbox keys — publish a delegated encryption key for signing-only wallets.
When a mailbox is no longer needed, see
Closing accounts for mailbox close and mailbox key close.
Mailbox keys
See Mailboxes
for why mail is sealed to your wallet address by default and when a
delegated encryption key is needed. This page covers the sithbit mailbox key commands that publish, rotate, read, and clear that optional on-chain
key.
CLI build note:
key createandkey setare gated behind the CLI’srandfeature, which is on by default — a stockcargo build -p mail-clienthas them, and only a--no-default-featuresbuild that leavesrandout drops the pair.key getandkey closeare ungated. Seesithbit mailbox reading-secretfor the full note on what else that feature carries.
mailbox key create
Generates a delegated X25519 keypair client-side and writes it to a file — this does not touch the chain:
sithbit mailbox key create <output path>
mailbox key set
Publishes (or replaces) the delegated key on your mailbox:
sithbit mailbox key set <keypair path> \
[--keypair <path>] \
[--skip-preflight]
Arguments
<keypair path>(required) — the delegated X25519 keypair file to publish, as generated bymailbox key create.--keypair <path>(optional) — the mailbox owner’s keypair, used to sign the transaction. Defaults to the keypair in your Solana CLI config when omitted.--skip-preflight(optional) — submits the transaction without a local simulation pass first.
Re-running mailbox key set with a fresh keypair file rotates the key:
senders start sealing to the new public key immediately, and mail already
sealed to the old key still opens with the old key file.
mailbox key get
Reads the published key back, if one exists:
sithbit mailbox key get [owner_address]
[owner_address](optional) — the address whose mailbox to look up. Defaults to your own configured keypair’s address.
Prints the delegated X25519 public key as base58 when one is published, or reports that the mailbox has no delegated key (i.e. mail is sealed to the wallet address).
mailbox key close
Removes the delegated key account and reclaims its rent, returning the mailbox to wallet-sealed mail:
sithbit mailbox key close \
[--keypair <path>] \
[--skip-preflight]
Examples
Generate a delegated key, publish it, then confirm it’s live:
sithbit mailbox key create my_delegated_key.json
sithbit mailbox key set my_delegated_key.json --keypair <path to wallet keypair>
sithbit mailbox key get
Rotate to a fresh key:
sithbit mailbox key create my_new_key.json
sithbit mailbox key set my_new_key.json --keypair <path to wallet keypair>
Stop using a delegated key and fall back to wallet-sealed mail:
sithbit mailbox key close --keypair <path to wallet keypair>
Related commands
- Looking up a mailbox — inspect a mailbox’s other settings.
- Create a mailbox — create the mailbox that a delegated key attaches to.
- Update a mailbox — change postage, domain, or the no-IPFS flag.
See Appendix: How sealed-box encryption works
for the full protocol, and Closing accounts
for mailbox key close alongside other account-closing commands.
Mailbox credentials
See Addresses for how your wallet
address is your mail identity. This page covers the two commands that derive
a mail app’s login material from your wallet keypair, offline: sithbit mailbox credentials (who you are) and
sithbit mailbox reading-secret (what
opens your mail) — and their third neighbour,
sithbit mailbox sign-text, the raw signer
that answers a challenge the account
service just handed you.
None of the three is needed in the webmail app or the
Thunderbird/ Outlook
extensions: they derive both login values in the page, sign you in with
them, and have your wallet sign any challenge the account service asks for
without your typing anything. These commands are for configuring a stock
mail app by hand — and, for sign-text, for driving the account API from
a shell or a script.
sithbit mailbox credentials
sithbit mailbox credentials \
[--keypair <path>] \
[--epoch <N>]
credentials prints the username/password pair a stock mail app uses to
authenticate to the SithBit SMTP/IMAP/POP servers as your wallet — with
no separate stored password. The username is your wallet address (the
base58 public key); the password is a base58 wallet signature over a
challenge that embeds that same public key and your account’s current
auth epoch. The servers verify the signature against the username, and
nothing is stored server-side — the signature is self-proving.
Read that binding precisely: the password is tied to your wallet and that epoch, so nobody can present it as a different wallet, and it stops working the moment you rotate the epoch. It is not otherwise single-use. Anyone who captures it can replay it as you, on any of the three protocols, until you rotate — which is what makes rotation the revocation lever rather than a housekeeping step, and TLS non-optional. See the threat model for what else follows from that.
The command is fully offline: it contacts no RPC endpoint, reads nothing on-chain, and writes no files. Both values are derived purely from the local wallet keypair, and because the signature is deterministic, re-running the command for the same keypair and epoch prints the identical pair every time — paste it into a mail app once and you are done.
Arguments
--keypair <path>(optional,-k) — the wallet keypair file the credentials are derived from. Defaults to the keypair in your Solana CLI config when omitted.--epoch <N>(optional, default0) — the auth epoch to sign for.0is an account that has never rotated; after a rotation, pass the epoch the account now reports. Offline means offline: the command cannot look this up for you, so a wrong value here is the one way to get a well-formed password the servers refuse.
The auth epoch, and rotation
A wallet signature never expires on its own — whoever copied your mail password holds it for good. The auth epoch is the handle that takes it back: a counter the account keeps, mixed into the challenge the wallet signs, so adding one to it changes the bytes every valid password must sign over and retires every copy of the old one at once, on every listener.
Rotating is done from the account, not from here — the
webmail
app’s Settings pane
(the same control the Thunderbird/Outlook extensions and the operator’s
enrollment page carry), or
POST /v1/account/auth-epoch
directly. Both report the new epoch; GET /v1/account reports the current
one at any time. This command’s job is the other half: deriving the
password that matches whatever epoch the account is on now.
That is what the output leads with, so a mismatch is visible before it becomes a mystery:
Auth epoch: 0
Mail auth username: <your wallet address>
Mail password: <base58 signature>
These credentials are valid ONLY at auth epoch 0.
If your account shows a different auth epoch (webmail, or GET /v1/account),
re-run with --epoch <N> — otherwise this password will be rejected.
Three limits are worth knowing before you rotate, because a rotation is narrower than it sounds:
- It retires the wallet-derived password only. A
stored mail password,
if the account has one, keeps working exactly as before, so rotation is not
a way to shut that path. Closing it is a separate, equally deliberate
gesture — Remove the stored mail password on the
webmail
Settings pane,
or
DELETE /v1/account/password— and it leaves the auth epoch, and so this command’s output, untouched. - It does not end web or add-in sessions. A session token is untouched and stays valid to its expiry; only mail apps have to be re-provisioned with the new password.
- It does not revoke a
client-certificate login. SASL EXTERNAL proves identity
from the certificate in the TLS handshake rather than from a signature
over the epoch, so a bump leaves it working; only the operator turning
client_cert_authoff for the listener closes it.
The one compatibility note: a password derived before epochs existed
still authenticates an account that has never rotated, which is why
--epoch defaults to 0 and nothing broke on the day epochs landed. The
first rotation ends that grace for that account permanently — from then
on only a password derived at the account’s exact current epoch is
accepted.
Using the credentials
Enter the printed pair in your mail client as its ordinary username and
password — SASL PLAIN over TLS for IMAP/submission, or the plain
POP3 PASS login. The password is bearer-equivalent for the connection,
so always use TLS. The client walk-throughs show exactly where each
value goes, and how the browser/mail-app extensions derive the same pair
without the CLI:
Thunderbird and
Outlook.
Because the username here is your wallet address, the server looks nothing up: the signature proves the account directly. A client configured with an alias and a stored mail password instead — the password path the account service manages — does need that name resolved to a wallet, and aliases move: a transfer, a sale, or the settlement of an auction re-points one at a new holder, and nobody needs permission to make that happen. The guarantee is that the resolution happens once per login: the account your password authenticates is the account the session opens, so a name that changes hands in the instant between the two cannot hand your session to anyone else’s mail, or theirs to you. See a re-pointed alias cannot redirect a mail login for the full statement.
Examples
Print the credentials for the Solana CLI config’s default wallet:
sithbit mailbox credentials
Derive them for a specific keypair file:
sithbit mailbox credentials --keypair ~/.config/solana/id.json
Re-derive them after a rotation — for an account whose settings pane now reads auth epoch 3:
sithbit mailbox credentials --epoch 3
sithbit mailbox reading-secret
sithbit mailbox reading-secret \
[--keypair <path>]
reading-secret prints the base58
reading key secret that opens
mail sealed to this wallet address: the X25519 twin of your wallet keypair,
which is what senders — and your operator’s
at-rest sealing —
wrap to whenever the mailbox publishes no
delegated key. It is derived from the local keypair alone, so the
command is fully offline, deterministic, and prints the same string every
time. The address it belongs to goes to standard error and the secret alone
to standard output, so the command pipes cleanly.
This one really is a secret. The
credentialspassword above only authenticates — it proves you hold the wallet and is useless for anything else. The reading secret decrypts: whoever holds it reads every message ever sealed to that wallet, the delivered ones as much as the future ones, and no rotation takes that back for mail already sealed. Print it into a mail app’s password field and nothing else; never into a chat, a ticket, or a shell history you keep.
CLI build note:
reading-secretis one of the commands gated behind the CLI’srandfeature — butrandis on by default, so a stockcargo build -p mail-clientalready has it and there is nothing to enable. Only a slimmed--no-default-featuresbuild that does not addrandback drops the command, together with the feature’s other surfaces: delegated-key generation and the local body decryption flags. Its two neighbours on this page,credentialsandsign-text, are ungated and ship in every build.
Arguments
--keypair <path>(optional,-k) — the wallet keypair file the secret is derived from. Defaults to the keypair in your Solana CLI config when omitted.
Using the reading secret
A mail app that logs in with the wallet-signature password alone still sees
your account, its folders and its message list — but on a deployment that
seals mail at rest, the bodies it cannot open show up
locked. To
read them, append the reading secret to that password after a single .:
<password from `mailbox credentials`>.<secret from `mailbox reading-secret`>
. never appears in base58, so the two halves are unambiguous. Use the
joined value for the incoming server only (IMAP or POP, over TLS). The
submission (SMTP) server refuses a password carrying a reading secret:
sending mail never needs your decryption key, so a misconfigured client fails
loudly there instead of shipping the secret to a service that has no use for
it. The secret is held for the session, used to unwrap that session’s
messages, and dropped when the session ends; nothing stores it.
If your mailbox publishes a delegated key, that key — not this one — is what mail is sealed to; read it with the key file from Mailbox keys instead.
Examples
Print the reading secret for the Solana CLI config’s default wallet:
sithbit mailbox reading-secret
Derive it for a specific keypair file, keeping the address note out of the piped value:
sithbit mailbox reading-secret --keypair ~/.config/solana/id.json
sithbit mailbox sign-text
sithbit mailbox sign-text <TEXT> \
[--keypair <path>]
sign-text prints the base58 ed25519 signature of your wallet keypair over
the exact UTF-8 bytes of <TEXT> — the proof the
account service’s challenges ask for.
Fetch a challenge from POST /v1/auth/step-up,
sign the nonce it hands back, and send the signature in the gated write’s
x-sithbit-step-up header. Like its two neighbours above, the command is
fully offline: no RPC endpoint, no chain read, no HTTP call, nothing
written — so it runs on an air-gapped machine holding the wallet.
It is a raw signer: nothing is prepended, appended, hashed or trimmed.
Domain separation is the challenge’s job — the account service’s nonces
carry their own SithBit login nonce: or SithBit step-up nonce: prefix
and are verified against exactly the string that was issued, which is why a
signature collected at login can never be presented as a step-up proof, or
the reverse. The rule that follows is short: sign only text a server just
handed you, pasted verbatim. One stray trailing space is a different
message, and the signature over it will not verify.
The browser clients never need this. Webmail and the Thunderbird/ Outlook/ Chrome extensions sign the same challenge in the page with the wasm wallet — the identical signer, byte for byte — so the settings pane’s buttons already run the whole dance, invisibly with an in-app wallet and as an approval prompt on a Phantom or Ledger one.
sign-textis for the surfaces that have no button: a shell, a script, a provisioning job.
Arguments
<TEXT>(required, positional) — the string to sign, verbatim. Quote it: a challenge nonce contains spaces and a colon.--keypair <path>(optional,-k) — the wallet keypair file that signs. Defaults to the keypair in your Solana CLI config when omitted.
Output, and capturing it
The signature alone goes to standard output; the wallet it signed as goes to standard error, where it is visible interactively without polluting the captured value:
Signed as <your wallet address>:
<base58 signature>
So a shell capture holds exactly the header value, with nothing to trim:
PROOF=$(sithbit mailbox sign-text "$NONCE" --keypair ./wallet.json)
One signature, one request
A challenge lives 300 seconds and is spent by the first request that
carries it, whatever that request answers — a success, a wrong signature
and an expired one all consume it. So sign the nonce you were just handed,
send it once, and after any failure fetch a fresh challenge and sign that
one; re-sending the same proof answers 428 for good. Two gated writes are
two challenges, not one. See One proof, one
mutation for the
full contract.
Examples
Sign a step-up nonce with the Solana CLI config’s default wallet:
sithbit mailbox sign-text "SithBit step-up nonce: 0f9c2b1a-4d3e-4c5f-8a7b-6d5e4f3c2b1a"
Sign it with a specific keypair file:
sithbit mailbox sign-text "SithBit step-up nonce: 0f9c2b1a-4d3e-4c5f-8a7b-6d5e4f3c2b1a" --keypair ~/.config/solana/id.json
The per-recipient pin provider walkthrough is the worked example, end to end: challenge, signature, gated write.
Related commands
- Create a client certificate — the password-less alternative: a TLS client certificate that logs the same wallet in over SASL EXTERNAL, for servers with client-certificate auth enabled.
- Create a mailbox — the credentials log you in to a server account; the mailbox is what receives your on-chain mail.
- Looking up a mailbox — inspect a mailbox’s settings.
- Mailbox keys — publish a delegated encryption key, which replaces the wallet twin above as what mail is sealed to.
Create a client certificate
See Addresses for how your wallet
address is your mail identity. This page covers the sithbit mailbox create-cert command.
sithbit mailbox create-cert \
[--keypair <path>] \
[--out <prefix>]
create-cert mints the TLS client certificate a stock mail app
(Thunderbird, Outlook, …) presents to log in over SASL EXTERNAL: a
self-signed Ed25519 leaf whose public key is your wallet’s signing key,
so the SMTP/IMAP/POP servers read the wallet address straight off the
certificate during the TLS handshake — no password typed, nothing stored
on either side. The server must have client_cert_auth turned on for
the listener (see the
configuration reference);
your wallet address stays the username. (Thunderbird itself currently
cannot import the bundle — see
the known issue
in its walk-through; the files stay valid for other clients.)
The command is fully offline: it contacts no RPC endpoint and writes nothing on-chain. Everything is derived purely from the local wallet keypair, so you can regenerate the same files any time, anywhere — and because the certificate is self-signed, there is no certificate authority and no renewal to manage.
Arguments
--keypair <path>(optional,-k) — the wallet keypair file the certificate is derived from. Defaults to the keypair in your Solana CLI config when omitted.--out <prefix>(optional,-o) — output path prefix; the three files below are written as<prefix>.crt/<prefix>.key/<prefix>.p12(an extension already on the prefix is replaced, not appended). When omitted, the certificate and key PEM blocks are printed to stdout instead, and no.p12is written.
What it writes
With --out <prefix>, three files:
<prefix>.crt— the certificate, as PEM.<prefix>.key— the private key, as PKCS#8 PEM.<prefix>.p12— both combined in a password-less PKCS#12 bundle, the one file meant for a mail app’s certificate-import dialog (Thunderbird currently refuses it — see the known issue).
The .p12 carries no MAC and no password, so its bytes are
deterministic per wallet: re-running the command for the same keypair
reproduces the identical file. The .key and .p12 files embed your
wallet secret — guard them like the wallet itself.
Installing the certificate
Importing the files into a mail app — including leaving the .p12
import’s password prompt blank — is covered step by step in the client
walk-throughs:
Thunderbird
and
Outlook.
Both pages also show how their extension mints the identical files
without the CLI.
Examples
Print the certificate and key PEM to stdout:
sithbit mailbox create-cert
Write ./mywallet.crt, ./mywallet.key, and ./mywallet.p12:
sithbit mailbox create-cert --out ./mywallet
Related commands
- Mailbox keys — the other optional key: a delegated X25519 encryption key for signing-only wallets. Unrelated to login — the client certificate authenticates you to mail servers, the delegated key changes what senders seal mail to.
- Create a mailbox — the certificate logs you in to a server account; the mailbox is what receives your on-chain mail.
- Looking up a mailbox — inspect a mailbox’s settings.
Close a mailbox
See Mailboxes for what a mailbox is
and the rent it holds. This page covers the sithbit mailbox close command’s
three modes: request, --finalize, and --cancel.
Closing a mailbox is not instant. It runs through a two-step, 7-day close timelock: a request starts the clock and leaves the mailbox open and receiving mail, a finalize actually closes it once the clock has elapsed, and a cancel aborts the request meanwhile. The mailbox’s rent comes back only at finalize — the request refunds nothing.
sithbit mailbox close \
[--finalize] \
[--cancel] \
[--keypair <path>] \
[--skip-preflight]
Arguments
--finalize(optional) — closes the mailbox once the timelock has elapsed since the request, refunding the transient pending-close account’s rent to the owner and the mailbox’s rent to its recorded funder — the owner itself on a normal mailbox, or the sponsoring domain authority on a sponsored mailbox (the CLI reads the funder from the mailbox automatically, as does the webmail Mailbox pane). Run before the timelock has elapsed, it is rejected on-chain. Mutually exclusive with--cancel.--cancel(optional) — aborts an in-flight close request, refunding the transient account’s rent and leaving the mailbox exactly as it was. Mutually exclusive with--finalize.--keypair <path>(optional) — the mailbox owner’s keypair. Defaults to the keypair in your Solana CLI config when omitted.--skip-preflight(optional) — submits the transaction without a local simulation pass first.
With neither --finalize nor --cancel given, the command opens a new
timelock: it stamps the request with the current chain time and creates a
small transient PDA that tracks it. The mailbox stays open — mail keeps
arriving, and settlement keeps working — until the request is finalized;
the presence of that account is what marks the mailbox as closing.
The timelock duration
The wait between a close request and the earliest it can be finalized is a fixed 7 days. There is no flag to shorten it. It is a separate setting from the domain deactivation timelock, even though the two currently hold the same value.
Why a mailbox close waits at all: an instant rent refund made discarding a burned sending identity free, so a spammer could cycle mailboxes at no cost. The delay parks that capital for a week per identity and gives operators a window to notice. It costs an honest owner time on a rare action, not money — the rent still comes back in full. See Economics.
The one-step close is disabled
The original single-instruction CloseMailbox (discriminant 16) is refused
on-chain with error 94, InstantCloseDisabled. The discriminant still
decodes, so indexers replaying history resolve old transactions, but no
current client can emit it and the CLI has no spelling for it. Use the
request/finalize pair instead.
mailbox key close is unaffected
Closing the delegated encryption key account
stays instant and still refunds its rent on the spot. That is
deliberate, not an oversight: mailbox key close is the revocation path
for a compromised delegated key, and a seven-day window there would leave
MX servers sealing new mail to a key the owner already knows is
compromised — the timelock would protect the attacker.
Examples
Request a close, starting the 7-day clock:
sithbit mailbox close --keypair <path to wallet keypair>
Change your mind and keep the mailbox:
sithbit mailbox close --cancel --keypair <path to wallet keypair>
Finalize once at least 7 days have passed, reclaiming both rents:
sithbit mailbox close --finalize --keypair <path to wallet keypair>
Errors
MailboxCloseAlreadyPending(error 91) — a close is already in flight for this mailbox. Cancel it before requesting another.NoPendingMailboxClose(error 92) —--finalizeor--cancelwas run with nothing pending.MailboxCloseTimelockNotElapsed(error 93) —--finalizewas run before the 7 days elapsed.InstantCloseDisabled(error 94) — the retired one-stepCloseMailboxinstruction was submitted.--finalizeand--cancelcannot be combined.
Related commands
- Create a mailbox — create the mailbox in the first place.
- Update a mailbox — change postage, domain, or the no-IPFS flag instead of closing.
- Mailbox keys — including
mailbox key close, which stays instant.
Note: a closed mailbox’s still-open message accounts persist and settle individually via
mail delete, and recreating the mailbox restarts its message-id counter at zero. See Closing accounts for the full picture across every account class.
Looking up a frombox
See Fromboxes for what a frombox is
and how pricing works. This page covers the sithbit frombox get command.
sithbit frombox get <from> [to_address]
frombox get is a read-only query: it derives the frombox
PDA for the
(from, to) pair, reads the account, and prints its stamp price and
balance. It signs nothing, spends nothing, and needs no CLI feature flag —
anyone can inspect any frombox.
Arguments
<from>(required) — the sender’s “from” address, exactly as it keys the frombox: a wallet address, analias@domain/wallet@domainemail address, or a path to a.jsonkeypair file (its public key is used). The value is taken literally as the frombox’s from-key, so a bare local part and its fully-qualified email address are different fromboxes — match whatever was used at create time.[to_address](optional) — the recipient whose mailbox charges the postage. Accepts a wallet address, an alias/email address, or a.jsonkeypair path, and (unlike<from>) is resolved to a wallet pubkey, so an alias is looked up on-chain. Defaults to your own configured wallet — the recipient’s own point of view.
What it prints
For an existing frombox the report is two lines: the derived frombox
account address, its per-stamp postage
price in lamports,
the number of stamps currently available,
and the account’s total lamport balance (rent plus the residual prepaid
stamp value — what closing the frombox would
refund to the recipient, or what
frombox reclaim would return to a wallet-address
sender, less the rent).
If no frombox exists for the pair, the command reports that the account does not exist and exits successfully — that is the normal state for an unknown sender, who simply pays the recipient’s mailbox default postage instead.
Examples
Check the frombox a recipient (you) has set up for jane_doe@sithbit.com:
sithbit frombox get jane_doe@sithbit.com
Inspect the frombox from jane_doe@sithbit.com to a specific recipient
alias, rather than your own wallet:
sithbit frombox get jane_doe@sithbit.com bob@sithbit.com
Related commands
- Create a frombox — created on the sender’s first stamp purchase.
- Add stamps — top up the prepaid balance.
- Update postage — set the per-stamp price (owner only).
- Reclaim unspent stamps — the sender takes back postage it prepaid and never spent.
See Closing accounts for how to close a frombox and reclaim its rent and remaining stamp value.
Update a frombox
See Fromboxes for
why postage pricing matters. This page covers the sithbit frombox update
command.
sithbit frombox update <from> [required_postage] \
[--keypair <keypair>] \
[--skip-preflight]
Updating a frombox sets its per-stamp price — the
required_postage, in lamports, that one email from <from> to you costs.
Since every send burns exactly one stamp, this single number is the price
of a message from that sender.
Unlike buying stamps, which anyone may run, only the recipient can update the price. The command signs with the recipient (“to”) keypair, and the program checks that signer against the mailbox owner. That same signer requirement is what lets you price a sender before their frombox even exists — see Setting a price before the frombox exists.
Arguments and flags
<from>— the sender’s “from” address (alias, wallet address, or keypair path). Hashed client-side; only the hash reaches the chain.[required_postage]— the new per-stamp price in lamports. Optional; defaults to 1 SOL (1000000000lamports) when omitted.--keypair <keypair>(short-k) — the recipient’s keypair; defaults to the CLI’s configured key. This key must be the mailbox owner.--skip-preflight(short-s) — skip the RPC pre-flight simulation.
There is no --stamps flag here — updating only changes the price, never
the stamp balance. Prices are per-stamp and take effect on the next stamp
purchase; stamps already bought keep the value they were funded at.
Examples
Lower a trusted sender’s price to 0.1 SOL (100000000 lamports):
sithbit frombox update jane_doe@sithbit.com 100000000
Update frombox address 7XkQ…Qp9 for <From:jane_doe@sithbit.com To:9aBc…prj> ...
Required postage set to 100000000 lamports
see https://explorer.solana.com/tx/…?cluster=devnet
Reset a sender back to the 1 SOL default by omitting the amount:
sithbit frombox update jane_doe@sithbit.com
You can also raise the price above the mailbox’s own default — there is no CLI-side maximum.
Setting a price before the frombox exists
You do not have to wait for a frombox to exist before pricing it. When you run
frombox update against a (sender, you) pair that has no frombox yet, the
CLI opens one in the same transaction: it checks the chain for the frombox
PDA and, finding it
absent, submits [CreateFrombox { stamps: 0 }, UpdateFrombox { … }] — a
genuine two-instruction transaction that creates the empty frombox and then
writes your price onto it. When the frombox already exists it behaves as
before: a lone UpdateFrombox.
The frombox starts at your price with zero stamps, so the sender still can’t reach you until it holds at least one, funded by either side via Add stamps. See the anti-spam lever for why this stampless create is allowed only for the recipient.
On-chain effect
The UpdateFrombox instruction re-derives the frombox
PDA from the
recipient address and the from-hash, verifies the frombox and the
recipient’s mailbox both exist and are program-owned, requires the recipient
to sign, and overwrites the frombox’s required_postage field. No lamports
move — this is purely a price change; funding the frombox is
Add stamps’ job.
When the frombox does not exist yet, the CLI prepends a
CreateFrombox { stamps: 0 } (the owner exception
above), so the same transaction allocates the account and funds only its
rent-exemption reserve — no postage, since it carries zero stamps — before the
UpdateFrombox sets the price.
Add stamps
See Fromboxes
for what stamps are and why prepayment is required. This page covers the
sithbit frombox stamp command.
sithbit frombox stamp <from> [to_address] \
[--keypair <keypair>] \
[--stamps <count>] \
[--max-price <lamports> | --no-max-price] \
[--skip-preflight]
Adding stamps prepays postage into a frombox — creating the
frombox on the spot if it does not exist yet (see
Creating the frombox on first purchase).
This command buys <count> stamps at the frombox’s current
per-stamp price and adds them to the balance.
Anyone can add stamps — a sender buying their own postage to reach a recipient, or the recipient prefunding a sender so their mail stays free. The signer is the payer.
Arguments and flags
<from>— the sender’s “from” address (alias, wallet address, or keypair path). Hashed client-side; only the hash reaches the chain.[to_address]— the recipient whose frombox is being funded. Defaults to your own address.--keypair <keypair>(short-k) — the payer’s keypair; defaults to the CLI’s configured key.--stamps <count>(short-p) — how many stamps to buy. Defaults to1.--max-price <lamports>— the highest per-stamp price this purchase will accept. Defaults to the price quoted from the chain, so you never pay more than you were shown; pass a higher figure to pre-authorize a rise you are willing to absorb. See The price can move under you.--no-max-price— buy at whatever the price turns out to be, with no guard. Mutually exclusive with--max-price.--skip-preflight(short-s) — skip the RPC pre-flight simulation.
The frombox does not need to exist first: if it is absent this command creates it (see Creating the frombox on first purchase). Adding stamps never changes the price; run update for that.
Example
Top up a sender’s frombox by 10 stamps:
sithbit frombox stamp jane_doe@sithbit.com --stamps 10
Purchasing 10 stamps for frombox 7XkQ…Qp9 <From:jane_doe@sithbit.com To:9aBc…prj>
Protocol fee: waived (owner purchase)
Purchased 10 stamps
see https://explorer.solana.com/tx/…?cluster=devnet
When the recipient pays for their own mailbox’s frombox the per-stamp
protocol fee is waived, as shown. A third party funding a frombox to someone
else instead sees the fee it will pay, e.g.
Protocol fee: 1000000 lamports (100000 per stamp).
Creating the frombox on first purchase
When the target frombox does not exist yet, frombox stamp opens it in the
same transaction instead of failing. The CLI checks the chain for the frombox
PDA; if it is absent it
submits a single CreateFrombox carrying your requested stamp count rather
than an AddStamps against a missing account. That one instruction allocates
the account and credits the stamps at once — the fee and postage are identical
to buying the same stamps on an existing frombox, so a first purchase and a
top-up cost the same (no separate create step, and no double charge).
The trustless webmail compose rides this same create-or-top-up decision for its inline Prepay & send: when a send finds no frombox, the card quotes the purchase (postage + surcharge + the live protocol fee) and re-sends the held draft once the stamps confirm. The webmail Balances pane rides the identical decision for its own stamp purchases, and now supports both signing flavors as well — the in-page wallet signs directly, an external Phantom/Ledger wallet approves the same purchase built unsigned.
A newly created frombox’s per-stamp price is set to the recipient mailbox’s
current default postage — scaled by the payer’s on-chain sender reputation
when the create is a third party’s (see
Reputation-scaled first contact below).
Buying stamps never sets a custom price; to give a
trusted sender a cheaper rate the recipient runs
sithbit frombox update afterwards, or sets the price
up front before any stamps are bought — see
Setting a price before the frombox exists.
Reputation-scaled first contact
A third party’s first purchase toward a recipient — the create-if-absent
path above, buying at the mailbox’s default postage — is priced by
reputation-scaled first-contact
pricing:
the default steps down with the payer wallet’s cumulative
distinct-recipient postage spend, never below the tuned floor (10% of the
default by default), and never below one lamport for a nonzero price. The
same purchase also records its escrowed postage onto the payer’s
sender-reputation account, so every first contact a sender pays for makes
the next one cheaper. Only the default is scaled: a price the recipient
set with frombox update applies verbatim, and an
owner purchase (the recipient prefunding a sender) pays the raw default
and records nothing.
The CLI builds this automatically. A third-party create carries the
reputation tail by default — a 9-account CreateFrombox form: the
legacy six accounts, the operator pair (the recipient’s mailbox PDA
fills both slots when no operator split
resolves), and the payer’s sender-reputation PDA, lazily created
rent-exempt on first use. When the payer holds a verified-sender
attestation for the from address’s domain,
the CLI detects it on-chain and appends it as a tenth account, which
prices the first contact at the floor immediately — attested
organizations skip the spend ladder. (A wallet-literal or domainless
from address has no domain to attest, so the lookup is skipped.) Owner
purchases and top-ups of an existing frombox stay on their legacy account
lists: reputation is earned and priced at first contact, never on a
top-up.
The tail never carries a guessed account: a present-but-invalid attestation fails the transaction (error 19 for another wallet’s attestation, error 17 for a bad derivation) rather than silently repricing, so only a confirmed on-chain record is ever included.
Looking up a wallet’s reputation
sithbit postoffice reputation <WALLET>
Read-only, ships in every build. It prints the wallet’s recorded cumulative postage spend and the effective first-contact rate that spend earns, in basis points of a recipient’s default postage — computed through the same on-chain rule the program applies, tuned floor included. A wallet with no reputation account reads as zero spend at the full 10,000 bps (the common negative answer, not an error):
sithbit postoffice reputation mAiLiLdgjgGdWoCZkpW3cj7JLAC56qb4NErFyQFWNJg
MX operators can query the same figures over gRPC — the
gateway’s GetSenderReputation
RPC answers with identical semantics.
Tuning the floor
The delegate tunes the discount’s floor with:
sithbit postmaster fee reputation-floor <BPS> \
[--keypair <delegate keypair>] \
[--skip-preflight]
The value is basis points of a mailbox’s default postage, hard-capped
on-chain at 10,000 bps (100% — a floor that high disables the discount
entirely); an over-cap value refuses with custom error 102
(ReputationFloorAboveCap). Zero resets to the protocol default of
1,000 bps (10%): like the other tunable rates, a zero stores the “unset”
sentinel, and a postoffice account that predates the field reads back the
default (the setter grows the legacy account in place). See the
tunable-constants table.
The prepayment rule
A third party’s first purchase toward someone else’s frombox must buy at
least one stamp: running frombox stamp --stamps 0 as anyone other than
the recipient fails, since the on-chain CreateFrombox guard refuses a
zero-stamp create from a non-owner payer. The only way to open a stampless
frombox is for the recipient (the mailbox owner) to do it while setting the
price — see
Setting a price before the frombox exists.
The price can move under you
The recipient owns the per-stamp price and can change it at any moment — including between the instant this command quotes you a price and the instant your transaction is confirmed. A purchase carrying no ceiling simply pays whatever price it finds on arrival.
So the command sets one for you. By default it pins the ceiling to the price it just quoted, and prints the figure alongside the fee preview:
Protocol fee: 30000 lamports (greater of 10000 flat per stamp and 250 bps of postage)
Slippage guard: max 1000000 lamports per stamp (quote-pinned)
If the price has risen past that ceiling by the time the transaction lands,
the program refuses the purchase with custom error 107
(PriceExceedsMax) and your postage stays in your wallet. Nothing is
partially spent — buy again at the new price if you still want the stamps.
Two ways to change that posture:
--max-price <lamports>sets the ceiling yourself. Use it to pre-authorize a rise (“I’ll pay up to 2 SOL a stamp, whatever it says today”) so a modest increase does not bounce your purchase.--no-max-priceremoves the guard entirely, restoring the older pay-whatever behavior.
The ceiling is checked against the per-stamp price, not the total, and
it applies to both purchase paths — a top-up compares it against the
frombox’s stored required_postage, while a
first purchase compares it
against the effective first-contact price after
reputation scaling. Because reputation
scaling only ever prices at or below the mailbox’s default postage, the
quoted default is a safe ceiling for a first purchase.
The GUI clients take the same posture, so the CLI is no longer the only
guarded buyer: the webmail Balances pane,
the compose card’s inline prepay
and the self-service funding page
all pin every purchase to the price they quoted. They expose no equivalent of
--max-price or --no-max-price: raising or removing the ceiling stays a
power-user choice, and the panes re-read the quote at buy time rather than
trusting whatever is on screen.
On-chain effect
The AddStamps instruction transfers postage from the payer into the
frombox account and increments its stamp count. For each stamp bought the
payer deposits:
- the per-stamp price (
required_postage); plus - a settlement surcharge (
10000lamports) that reimburses the sender’s laterSendMailandDeleteMailsignatures.
On top of that it collects the flat per-stamp protocol fee to the postoffice, waived when the payer is the recipient wallet — see Economics.
On a first purchase the command runs CreateFrombox instead: it does
everything AddStamps does (crediting the stamps and charging the same
per-stamp fee) and additionally allocates the account and funds its
rent-exemption reserve, storing the mailbox’s default_postage — scaled
by the payer’s sender reputation on a
third-party create — as the new frombox’s required_postage, and
recording the escrowed postage onto the payer’s sender-reputation
account.
Stamps are prepaid postage, not a fee charged per send. When a message is delivered, the frombox’s stored balance is split evenly across its remaining stamps: one stamp’s share is moved into the message account to fund the delivery, and the stamp count drops by one. That escrowed postage is settled when the message is deleted (a share to the recipient’s MX operator, the remainder as the recipient’s postage income) — so the price you set is really the value backing each stamp, refundable as a deposit rather than spent as a toll. See the stamp lifecycle for the full settlement path.
If you prepaid more postage than you ended up needing, you can take the unspent remainder back — see Reclaiming unspent stamps. When a frombox is no longer needed at all, see Closing accounts, where the recipient closes it and reclaims its rent and remaining stamp value.
Reclaiming unspent stamps
See Fromboxes
for what stamps are and why prepayment is required. This page covers the
sithbit frombox reclaim command.
sithbit frombox reclaim <to_address> \
[--keypair <keypair>] \
[--skip-preflight]
reclaim takes back the postage you prepaid into someone else’s
frombox and never spent. It is the sender’s
counterpart to frombox close, which is the
recipient’s sweep of the same account: close hands the whole balance to
the recipient, reclaim returns only the unspent postage to the sender.
The frombox itself survives. Only the balance above the account’s rent-exemption reserve moves, so the account stays open at the price the recipient set — reclaiming is not a way to reset your standing with them, just a way to get idle postage out of escrow.
This is also reachable from a GUI: the webmail Balances pane offers a Reclaim unspent button once a quote shows a frombox of yours holding stamps. The pane applies the wallet-address rule below by hiding the button rather than surfacing a failure — it appears only when you are sending as your own wallet.
Only works for a wallet-address sender
A frombox is keyed on the blake3 hash of the sender’s “from” address, and this command works by reproducing that derivation from your signature: the program hashes your wallet’s address bytes and checks that the result names the frombox you passed. Reproducing the derivation is therefore the whole authorization — no separate ownership field exists, and no stranger can reach your frombox.
The flip side is that it only works when the “from” is a wallet address.
A frombox keyed on an email string (alice@example.com) hashes text that no
wallet key can reproduce, so those stay recipient-managed: the recipient’s
frombox close remains the only way their balance comes back out. If you
expect to reclaim, buy postage against your wallet address.
Arguments
<to_address>— the recipient whose frombox holds your postage. Accepts a wallet address or an alias, which is resolved to its wallet.--keypair <keypair>(short-k) — the sender’s keypair. This is the wallet the frombox is keyed on and the wallet the postage is returned to; defaults to the CLI’s configured key.--skip-preflight(short-s) — skip the RPC pre-flight simulation.
Examples
Reclaim your unspent postage from the frombox a recipient holds for you:
sithbit frombox reclaim 7cVfgArCheMR6Cs29HXTFrpMg2XwYFhrCtdz3EgKPfHM
Reclaim as a specific wallet, naming the recipient by alias:
sithbit frombox reclaim jane_doe --keypair ~/.config/solana/id.json
Confirm the result — the stamp count reads zero and the balance is down to the account’s rent:
sithbit frombox get <your-wallet-address> jane_doe
On-chain effect
The ReclaimFromboxStamps instruction zeroes the frombox’s stamp count and
moves everything above the rent-exemption reserve back to the signer. It
carries no instruction data at all — the accounts and your signature say
everything the program needs.
Reclaiming is not the only way that balance can come back out. The
recipient’s frombox close still sweeps whatever
residual a sender leaves behind, so this is the sender’s proactive
recovery path rather than an exclusive claim on the escrow — see
frombox custody
in the threat model for why custody is arranged that way.
Related commands
- Add stamps — the purchase this reverses, including the slippage guard that keeps a purchase from overpaying in the first place.
- Looking up a frombox — check the stamp count and balance before and after.
- Closing accounts — the recipient’s side: closing the frombox and reclaiming its rent along with any residual.
Create an alias
See Aliases for what an alias
is and the automatic self-alias spoof guard that mailbox create already
gives you. This page covers the sithbit alias create command.
Creating an alias
sithbit alias create <alias> [--keypair <keypair>] [--skip-preflight]
For example:
sithbit alias create john_doe
This registers john_doe as an alias pointing at the address of the signing
keypair (defaulting to your configured default keypair — see
CLI Quickstart).
Registration fee
Registering a name pays a claim fee to the postoffice on top of the account rent, and the fee is length-tiered: names of 5 or more characters pay the flat fee (0.01 SOL default), while 1–4 character names carry a scarcity premium — by default 10 SOL (1 char), 1 SOL (2), 0.1 SOL (3), and 0.05 SOL (4). See Economics for the schedule, its caps, and how the postmaster tunes it.
Before submitting, the command prints what the run will pay — one line per premium short name plus a total, so a premium price is never charged silently:
Premium short name 'ab' (2 chars): 1000000000 lamports
Total registration fees: 1010000000 lamports
When the signing wallet is the postmaster delegate the preview prints
Registration fee: waived (delegate reservation) instead — reservations
are fee-free at every length (see
Reserve aliases in bulk).
Note:
sithbit mailbox createalready registers your wallet’s own address as a self-alias automatically — a namespace reservation, so nobody else can hold a name that reads as your wallet. Resolution is literal-first, so that claim is not what routes your mail (see Aliases). Usealias createfor additional friendly names beyond that automatic one.
To register many names at once (e.g. the postoffice delegate reserving names for resale), see Reserve aliases in bulk.
Allowed characters
Alias names are validated at registration — both client-side and by the on-chain program, so the rules hold even for hand-rolled transactions:
- Lowercase ASCII letters, digits, and the separators
.,_,-(uppercase input is accepted and lowercased before storage and PDA derivation). - Must start and end with a letter or digit.
- No consecutive dots.
- At most 64 bytes.
Non-ASCII names are refused outright: this shuts out homoglyph (e.g.
Cyrillic а), zero-width, and Unicode-normalization look-alike spoofing.
Reserve aliases in bulk
See Aliases for what an alias
is and the automatic self-alias namespace reservation that mailbox create
already gives you. This page covers the sithbit alias create command’s bulk form.
sithbit alias create [<alias>...] [--keypair <keypair>] [--skip-preflight]
Creates many aliases in one invocation, packing as many CreateAlias
instructions into each transaction as fit Solana’s transaction-size
budget (about a dozen typical names per transaction; longer lists are
split into successive transactions automatically):
sithbit alias create ceo sales support billing --keypair my_wallet.json
When no aliases are given as arguments, the list is read from stdin — whitespace-separated, so one name per line works — which suits piping in a prepared file:
sithbit alias create --keypair my_wallet.json < aliases.txt
Every created alias points at the signing wallet address, exactly as
alias create would — same validation, same
lowercasing, same per-alias fee (see Economics) —
and duplicates in the list are collapsed before submission. The delegate
waiver covers the length-tiered premium fees too: reserving a
1–4 character name costs the operator only rent, which is precisely how
premium short names are meant to reach the market — reserved fee-free,
then sold on the marketplace
or handed off at a chosen price.
Bulk-created names carry no special state: like any alias, they can
later be handed to another wallet with
alias transfer init — a zero-fee offer the buyer
accepts, fee-waived because the delegate is the holder — or closed at
any time to reclaim their rent.
Get an alias
See Aliases for what an alias
is and how it resolves to a wallet address. This page covers the
sithbit alias get command.
sithbit alias get <alias>
Resolves an alias to the wallet address it currently points at:
sithbit alias get john_doe
Note: lookups are case-insensitive by construction — the alias is lowercased before it’s hashed into the account’s address, the same as at creation, so
John_Doeandjohn_doeresolve identically.
Resolution is domain-blind: any @domain suffix is parsed off and
ignored, so john_doe, john_doe@sithbit.com, and john_doe@anything.example
all resolve to the one global john_doe. No domain authority can claim a
local part it does not hold — see Aliases.
See Closing accounts for how to close an alias and reclaim its rent.
Discovering a recipient’s encryption key: the cert keyserver
Before anyone can seal mail to a SithBit address they need the recipient’s
published X25519 key. The
account API exposes a public lookup for
exactly that, with the recipient identity in the ?email= query parameter:
GET /v1/chain/cert?email=alice@acme.com
It is public and unauthenticated — deliberately, in the spirit of PGP
keyservers (HKP) and Web Key Directory (WKD). Everything it returns is
already readable on-chain by anyone, so gating it behind a login would add
friction without adding privacy. Give it any recipient identity — a wallet
address or an alias, with or without a domain suffix — and it resolves that
identity to a wallet (composing the same domain-blind resolution as
alias get above), then returns that wallet’s published encryption key.
Two response cases matter:
- Empty key,
200 OK. The recipient exists but has published no delegated key. This is not an error — a sender seals straight to the wallet address itself (see Mailbox Keys and sealed-box encryption). - Unknown recipient,
404. No wallet resolves for that identity at all — there is nobody to seal to.
Because it is a convenient public surface over on-chain data, it carries a threat-model note; see the discovery keyserver is a public enumeration surface.
Transfer an alias
See Aliases for what an alias
is and why a transfer is always a two-party consent ceremony rather than a
unilateral push. This page covers the sithbit alias transfer command tree.
sithbit alias transfer init <alias> <recipient> \
[--fee <lamports>] [--expires-in <seconds>] \
[--keypair <keypair>] \
[--skip-preflight]
Offers to move an existing alias to a new wallet address. Every transfer is a two-party ceremony: the alias’s current holder signs to stage the offer, and the alias changes hands only when the named recipient signs to accept it. Both consents are structural — an alias can never be taken from its owner without their key, and it can never be planted on a wallet that didn’t ask for it.
--fee is the price, in lamports, the recipient pays the holder on
acceptance. It defaults to 0 — a free hand-off:
sithbit alias transfer init john_doe maiLtdkxym8CCmo9TwDuXywqd9DXaK3tB6toKFVeBFR
The alias keeps resolving to the current holder until the recipient
runs alias transfer accept. A free hand-off
pays the flat alias-transfer fee to
the postoffice at accept — waived when the offer’s holder is the
standing delegate, so operator reservation hand-offs stay fee-free —
while a priced offer pays the 90/10 split instead (see
Economics).
See Closing accounts for how to close an alias instead of transferring it.
The consent guarantee. Before v0.7.0 the
TransferAliasinstruction repointed an alias at any address with only the holder’s signature — a name could be attached to a wallet unilaterally. That instruction now refuses with custom error 85 (UnilateralTransferDisabled); its discriminant remains decodable so pre-cutover history replays cleanly. See the change history.
Selling an alias: escrowed transfer for a fee
With a positive --fee, the same command stages the offer as a sale:
the alias changes hands only when the named recipient accepts the offer
and pays the fee. Until then the alias keeps resolving to the current
holder, exactly as before.
sithbit alias transfer init john_doe <RECIPIENT_PUBKEY> --fee 50000000
The offer names one specific recipient — only that wallet can accept it — and each alias can carry at most one outstanding offer (the offer lives in a dedicated escrow account derived from the alias name, so a second simultaneous offer is structurally impossible). The holder fronts the escrow account’s rent when staging the offer and gets it back when the offer resolves, whichever way it resolves.
The binding window
For its first 5 minutes an offer is binding on the holder: it can be neither cancelled nor replaced. This protects a recipient who sees the offer and pays promptly from having it retracted out from under them mid-purchase.
Replacing an offer
To change the fee, the recipient, or the expiry, just issue a new
alias transfer init for the same alias — a new offer replaces the
standing one in place (no cancel-first needed, and no extra rent). The
replacement re-arms the 5-minute binding window.
Expiry
An offer expires 30 days after it is staged, unless you pass a different lifetime in seconds:
sithbit alias transfer init john_doe <RECIPIENT_PUBKEY> --fee 50000000 --expires-in 86400
Enforcement is lazy — nothing sweeps expired offers. An accept at or before the expiry instant succeeds; an accept after it is refused. The escrow account of an expired offer sits until the holder cancels it (see below) or stages a replacement.
Accepting an offer
sithbit alias transfer accept <alias> \
[--payer-keypair <keypair>] \
[--keypair <keypair>] \
[--skip-preflight]
The offer’s named recipient signs — the signature is the consent, so an alias can never be planted on a wallet that didn’t ask for it. In one atomic instruction the fee leg settles, the alias repoints at the recipient, and the escrow closes with its rent refunded to the previous holder. The fee leg depends on the offer’s price:
- Priced offer — the fee leaves the paying wallet and splits between the current holder and the postoffice (90/10 — see Economics for the exact money flow). No flat fee rides on top.
- Free hand-off (fee 0) — the paying wallet pays the flat alias-transfer fee (default 0.001 SOL, postmaster-tunable) to the postoffice, waived when the offer’s holder is the standing delegate. The CLI prints the fee (or the waiver) before signing.
sithbit alias transfer accept john_doe --keypair recipient.json
By default the recipient’s own wallet pays. A sponsor may pay instead
with --payer-keypair — the sponsor funds the fee and the transaction
fee and co-signs alongside the recipient. (Simply funding the
recipient’s wallet beforehand works too.)
Cancelling an offer
sithbit alias transfer cancel <alias> [--keypair <keypair>] [--skip-preflight]
The holder cancels an outstanding offer (once its binding window has elapsed), closing the escrow and reclaiming its rent:
sithbit alias transfer cancel john_doe
This is also how you reclaim the escrow rent of an expired offer — an expired offer can no longer be accepted, but its account stays open until the holder cancels it.
While an offer is pending
- The alias resolves to the current holder until the moment the offer is accepted; mail keeps working unchanged.
sithbit alias closerefuses while a transfer offer is outstanding — cancel the offer first, then close (otherwise the escrow’s rent would be stranded).
List an alias for sale
See Aliases for what an alias
is and how a listing differs from an escrowed
transfer.
This page covers the sithbit alias sell/buy command tree.
sithbit alias sell <alias> --price <lamports> \
[--expires-in <seconds>] \
[--keypair <keypair>] \
[--skip-preflight]
Puts an alias up for sale on the open marketplace: a fixed-price
listing that any buyer may take, first come, first served. Where an
escrowed transfer offer
names one specific recipient who alone can accept, a listing names nobody —
whoever signs alias buy and pays the price
becomes the alias’s holder.
For an ascending-bid sale instead of a fixed price — bidders escrow lamports on-chain and the high bidder wins at a deadline — see Auction an alias. A listing is one mode or the other, chosen when it is staged.
sithbit alias sell john_doe --price 50000000
Listed alias 'john_doe' for sale at 50000000 lamports
https://explorer.solana.com/tx/…
The alias’s current holder signs. The price is in lamports and must be
positive — for a free hand-off, stage a zero-fee
alias transfer init offer. The listing lives in a dedicated
account derived from the alias name’s blake3 hash,
so each alias can carry at most one listing at a time (a second
simultaneous listing is structurally impossible). The holder fronts that
account’s rent when staging the listing and gets it back when the listing
resolves — bought, cancelled, or reclaimed after expiry.
Until the moment a buyer pays, the alias keeps resolving to the current holder, exactly as before; mail keeps working unchanged.
The binding window
For its first 5 minutes a listing is binding on the holder: it can be neither cancelled nor replaced. This protects a buyer who sees the listing and pays promptly from having it retracted out from under them mid-purchase. Buying itself is never window-gated — a listing is buyable the moment it is staged.
Replacing a listing
To change the price or the expiry, just issue a new alias sell
for the same alias — once the binding window has elapsed, the new listing
replaces the standing one in place (no cancel-first needed, and no
extra rent). The replacement re-arms the 5-minute binding window.
Expiry
A listing lapses 30 days after it is staged, unless you pass a different lifetime in seconds:
sithbit alias sell john_doe --price 50000000 --expires-in 86400
Enforcement is lazy — nothing sweeps expired listings. A buy at or before the expiry instant succeeds; a buy after it is refused. The listing account of an expired listing sits until the holder cancels it or stages a replacement.
Buying a listed alias
sithbit alias buy <alias> \
[--payer-keypair <keypair>] \
[--keypair <keypair>] \
[--skip-preflight]
Any wallet may buy — the buyer signs, and paying the price doubles as consent, so an alias can never be planted on a wallet that didn’t ask for it. In one atomic instruction the price leaves the paying wallet, splits between the current holder and the postoffice (90/10 — see Economics for the exact money flow), the alias repoints at the buyer, and the listing closes with its rent refunded to the previous holder.
sithbit alias buy john_doe --keypair buyer.json
Bought alias 'john_doe' for maiLtdkxym8CCmo9TwDuXywqd9DXaK3tB6toKFVeBFR
https://explorer.solana.com/tx/…
By default the buyer’s own wallet pays the price. A sponsor may pay
instead with --payer-keypair — the sponsor funds the price and the
transaction fee (two signatures) and co-signs alongside the buyer.
(Simply funding the buyer’s wallet beforehand works too.)
A bought alias shows up in the gRPC gateway’s alias index as an ordinary transfer event — the index records the change of holder, not how it was paid for.
Cancelling a listing
sithbit alias sell <alias> --cancel [--keypair <keypair>] [--skip-preflight]
The holder cancels an open listing (once its binding window has elapsed), closing the listing account and reclaiming its rent:
sithbit alias sell john_doe --cancel
Cancelled the listing on alias 'john_doe' and reclaimed its rent
https://explorer.solana.com/tx/…
This is also how you reclaim the rent of an expired listing — an expired listing can no longer be bought, but its account stays open until the holder cancels it.
One disposal path at a time
An alias cannot carry both a private
transfer offer
and an open listing — the two would race to sell the same name. The
refusal works in both directions: alias sell is refused while
a transfer offer is outstanding, and alias transfer init (any fee,
including a zero-fee hand-off) is refused
while a listing is open. Resolve one path (cancel, accept, or buy) before
starting the other.
The guard matters because repointing the alias under a live listing would leave a stale holder recorded as the listing’s seller, able to collect the sale price the moment a buyer paid. Cancel the listing first, then transfer. The refusal is deliberate rather than an auto-cancel: a silent retraction could land inside the listing’s binding window, doing exactly what the window exists to prevent. (Buying re-checks the seller for the same reason: a stale listing that predates a change of holder can never sell the new holder’s alias at the old holder’s price.)
Relatedly, sithbit alias close refuses while a listing is open — cancel
the listing first, then close (otherwise the listing account’s rent would
be stranded). See Appendix: Closing
accounts.
Aliases have no deactivation concept, so there is no alias twin of the domain marketplace’s deactivation interplay — an open alias listing only ever resolves by buy, cancel, or expiry.
Errors
| Message on stderr | Meaning |
|---|---|
A listing's price must be positive; zero-price hand-offs use the transfer paths | Stage a zero-fee alias transfer init offer for a free hand-off. |
A listing's expiry must be in the future | --expires-in produced an expiry at or before now. |
The listing is still in its binding window | Cancel or replace attempted within the first 5 minutes. |
No listing is open for this alias | Buy or cancel on an alias with no staged listing. |
The listing has expired | Buy attempted after the expiry instant; the holder can reclaim the rent with sell --cancel. |
The alias has a pending transfer offer; cancel it before closing | Listing refused; cancel the escrowed offer first. |
The alias has an open listing; cancel it before closing or transferring | alias close or alias transfer init (any fee) refused while listed; cancel first. |
The listing holder no longer owns the listed name | Buy refused: the listing predates a change of holder, so its recorded seller is no longer the wallet the alias resolves to. |
Related
- Auction an alias — the ascending-bid alternative to a fixed price.
- Transfer an alias — the escrowed hand-off or sale to one named recipient (free when the fee is 0).
- Economics — the exact money flow: who pays, who collects, and when.
- List a domain for sale — the same marketplace for domains.
- Closing accounts — reclaiming rent, and why a listed alias can’t close.
Auction an alias
See Aliases for what an alias
is and how an auction differs from a fixed-price listing. This page covers
the sithbit alias sell --auction/bid/settle-auction command tree.
sithbit alias sell <alias> --auction --reserve <lamports> \
[--ends-in <seconds> | --ends-at <unix-timestamp>] \
[--antisnipe <seconds>] \
[--keypair <keypair>] \
[--skip-preflight]
An auction is the second way to sell an alias on the open marketplace,
alongside the fixed-price alias sell --price listing.
Where a fixed-price listing sets one number any buyer may take first come,
first served, an auction opens an ascending-bid contest: bidders escrow
lamports on-chain, each bid must top the last by a minimum increment, and
after the clock runs out anyone settles the auction and the alias repoints
at the high bidder.
The two modes are mutually exclusive — a listing is either fixed-price
or an auction, chosen when it is staged. --auction requires --reserve
(the floor the first bid must meet) and rejects --price; a plain
--price listing rejects the auction flags.
sithbit alias sell john_doe --auction --reserve 50000000
Opened an auction on alias 'john_doe' (reserve 50000000 lamports, ends in 7d)
https://explorer.solana.com/tx/…
The alias’s current holder signs to open the auction. As with a fixed-price listing, the auction lives in a dedicated account derived from the alias name’s blake3 hash, so each alias carries at most one listing of either kind at a time. The holder fronts that account’s rent when opening the auction and gets it back at settlement (or on cancel — see Strict commitment for when a cancel is still allowed).
Until settlement the alias keeps resolving to the current holder, exactly as before; mail keeps working unchanged while bids come in.
Timing: when it ends, and anti-snipe
The end time is set at open and clamped on-chain:
--ends-in <seconds>— a duration from now, clamped to[now + 1 hour, now + 7 days](the minimum and maximum auction duration). A value below the floor is raised to one hour; above the ceiling it is capped at seven days.--ends-at <unix-timestamp>— an absolute end instant, clamped to the same one-hour-to-seven-day band around now.
The default duration
Omitting both defaults the auction to run 7 days from the moment it is opened. The default and the ceiling above are separate on-chain settings that hold the same length today: an auction naming no end instant gets the default, not the maximum.
Anti-snipe extension
To blunt last-second sniping, a bid that lands inside the final
anti-snipe window pushes the end time out, giving other bidders a
chance to respond. The window is set with --antisnipe <seconds>, clamped
to [0, 24 hours] and defaulting to 24 hours; passing 0 disables the
extension entirely (a hard deadline).
Concretely: a bid arriving after expires_at - window moves expires_at
out to min(now + window, created_at + 7 days). Two properties fall out of
that formula:
- Each qualifying late bid re-arms roughly a full window of remaining time, so an auction only ever ends once a full anti-snipe window passes with no further bids.
- The extension can never carry the auction past seven days from when it
was opened — the
created_at + 7dcap is a hard backstop, so no amount of sniping keeps an auction alive indefinitely.
Bidding
sithbit alias bid <alias> --amount <lamports> \
[--payer-keypair <keypair>] \
[--keypair <keypair>] \
[--skip-preflight]
Any wallet may bid. The amount is in lamports and is escrowed on-chain in the auction account the moment the bid lands — the bidder is not merely promising to pay, the funds are held by the program until the bid is either outbid (refunded) or settled (won).
sithbit alias bid john_doe --amount 50000000 --keypair bidder.json
Bid 50000000 lamports on alias 'john_doe'
https://explorer.solana.com/tx/…
The minimum next bid
-
The first bid must be at least the reserve (
--reserveat open). -
Every later bid must clear the standing high bid by a minimum increment:
minimum next bid = high_bid + max(5% of high_bid, 1_000_000 lamports)
The max(...) means a flat 1,000,000-lamport floor dominates while the
high bid is small, and the 5% term takes over once the high bid exceeds
20,000,000 lamports (5% of 20,000,000 is exactly the flat floor). The
increment exists so a bidder can’t inch past the leader one lamport at a
time, and — together with the reserve and the escrow-rent cost below — it
is the auction’s friction against spam bidding.
By default the bidder’s own wallet escrows the amount; a sponsor may fund
it with --payer-keypair, co-signing alongside the bidder (or simply fund
the bidder’s wallet beforehand).
Settlement
sithbit alias settle-auction <alias> \
[--keypair <keypair>] \
[--skip-preflight]
Once the end time has passed, anyone may crank settlement — the seller,
the winner, or an unrelated third party. Settlement is fully deterministic:
the alias repoints at the recorded high bidder, and the escrowed high bid
splits 90% to the seller (the prior holder) and 10% to the
postoffice (OPERATOR_SHARE_BPS = 1000 basis points), the same split as
every other marketplace path — see
Economics for the exact money
flow.
sithbit alias settle-auction john_doe
Settled the auction on alias 'john_doe'; new holder maiLtdkxym8CCmo9TwDuXywqd9DXaK3tB6toKFVeBFR
https://explorer.solana.com/tx/…
An auction that reaches its end with no bids settles as a no-op: the alias stays with the holder and the listing closes with its rent returned, just like a cancelled fixed-price listing.
A settled auction shows up in the gRPC gateway’s alias index as an ordinary transfer event — the index records the change of holder, not how it was won.
Strict commitment: no take-backs
An auction with a live high bid is binding on the seller. Unlike a fixed-price listing — which the holder may cancel or re-price at will once its binding window elapses — an auction that has taken even one bid can be neither cancelled nor replaced. The seller has, in effect, committed to sell to the highest bidder at the deadline. Bidders likewise cannot cancel a bid: a bid is a firm, escrowed commitment that is only ever undone by being outbid.
A bidless auction is still the seller’s to cancel (alias sell --cancel, closing the auction and reclaiming its rent) or to let expire
and settle as a no-op. It is only the arrival of the first bid that locks
the auction in.
alias buy is rejected on an auction listing — buying is the
fixed-price path only. An auction changes hands solely through
bid-then-settle-auction.
The escrow-rent flow (read this before you bid)
This is the one genuinely surprising piece of the auction’s money flow, so it is worth spelling out.
The auction’s bid escrow — the on-chain account that holds the current
high bid — must itself be rent-exempt, so it holds rent + high_bid. That
rent is funded once, by the first bidder, on top of their bid amount.
When a bid is outbid, only the bid amount is refunded to the outbid bidder — the account rent stays behind in the escrow, carried forward under the new high bid. The rent is not re-funded by each successive bidder; it is paid once and then travels with the escrow.
At settlement, that rent is reclaimed by the winner — the final high bidder — not by whoever originally funded it. So:
- An early bidder who is later outbid is net out the escrow rent (plus their transaction fee): they get their full bid amount back, but the rent they fronted stays in the escrow and is ultimately recovered by someone else.
- The eventual winner recovers that rent at settlement, regardless of whether they were the one who first funded it.
- The listing account’s own rent always returns to the seller, on every resolution path — this is separate from the bid escrow’s rent.
The practical upshot: being the first bidder in an auction you don’t go on to win costs you a small, non-refundable amount (the escrow rent). That is deliberate — it is a further disincentive against throwaway spam bids, on top of the reserve and the minimum increment.
Errors
| Message on stderr | Meaning |
|---|---|
An auction requires a reserve; use --reserve | --auction was passed without --reserve. |
A fixed-price listing and an auction are mutually exclusive | --price and --auction (or the auction timing flags) were combined. |
The first bid must meet the reserve | alias bid below the reserve on an auction with no bids yet. |
The bid does not clear the minimum increment | alias bid at or below high_bid + max(5%, 1_000_000). |
The auction has not ended yet | settle-auction before the end instant. |
This alias is being auctioned; bid instead of buying | alias buy attempted on an auction listing. |
An auction with bids cannot be cancelled | alias sell --cancel (or a replacement) after the first bid landed. |
No auction is open for this alias | bid or settle-auction on an alias with no open auction. |
Related
- List an alias for sale — the fixed-price alternative, and the marketplace rules the two modes share (the one-listing-per-alias lock, the transfer/close interplay).
- Transfer an alias — the escrowed hand-off or sale to one named recipient (free when the fee is 0).
- Economics — the 90/10 split and where each lamport goes.
- Compute-unit consumption — the measured cost of opening an auction, bidding, and settling.
- Trust assumptions and threat model — escrow custody, the anti-snipe mitigation, and crankable settlement.
Looking up a domain
See Domains for what a domain
is and what the active/inactive status means. This page covers the
sithbit domain get command.
sithbit domain get <domain>
domain get is a read-only query — it derives the domain’s on-chain
account, reads it, and prints its status. It signs nothing, spends nothing,
and, unlike the admin subcommands below, needs no special CLI feature: any
mailbox owner can check whether a domain exists and is active before
pointing their mailbox at it.
Arguments
<domain>(required) — either the domain name itself (e.g.sithbit.com, case-insensitive; domain names are stored lowercased) or the domain account’s on-chain address. A value that parses as a base58 pubkey is read as an account address directly; anything else is treated as a domain name and hashed into its PDA.
What it prints
For a registered domain the command prints one line: the domain name, its derived account address, the domain authority that controls it (the address that can transfer it), and whether it is currently active or inactive — see Active and inactive domains for what that status means.
If the domain has never been registered — or its account address does not exist — the command reports that the domain does not exist on Solana and exits successfully.
Examples
Look up a domain by name:
sithbit domain get sithbit.com
Look up the same domain by its on-chain account address instead:
sithbit domain get 7Np41oeYqPefeNQEHSv1UDhYrehxin3NStELsSKCT4K2
Note:
domain create,domain transfer, anddomain deactivaterequire the CLI’sdomainfeature (enabled by default) and always require the delegate’s signature — domain administration is not something an ordinary mailbox owner can do unilaterally.domain get, by contrast, is unauthenticated and always available.
Create a domain
See Domains for what a domain
is and the authority/delegate/payer role model behind domain creation. This
page covers the sithbit domain create command.
sithbit domain create <domain> \
[--authority-keypair <path>] \
[--keypair <delegate keypair>] \
[--payer-keypair <path>] \
[--skip-preflight]
Arguments
<domain>(required) — the domain name to register, e.g.sithbit.com(case-insensitive; domain names are stored lowercased).--authority-keypair <path>— the key that becomes this domain’s authority. Defaults to your configured default keypair when omitted.--keypair <delegate keypair>— the delegate’s signing keypair. Required on every invocation: a signer that isn’t the postoffice’s standing delegate is refused with error 66,NotDelegate.--payer-keypair <path>— funds the new domain account’s rent. Defaults to the delegate keypair when not given separately.--skip-preflight(optional) — submits the transaction without a local simulation pass first.
Examples
Register a domain with a dedicated authority key, signed by the delegate:
sithbit domain create sithbit.com --authority-keypair ./authority.json
One authority key may hold any number of domains: domain accounts are
keyed by the domain name alone, so a mail-server deployment that serves
several domains registers each of them with the same authority key
(its gateway’s signing keypair) and lists them all in
[smtp] local_domains. Nothing else changes — the send path checks each
recipient’s own domain account, and per-domain
DKIM signers keep outbound
signatures aligned.
Domain creation charges a fixed protocol fee — see Economics for the exact amount.
Note: a domain operator doesn’t have to ask the delegate holder to run this command by hand. See DNS setup and the
domain-sithbitservice for a self-service flow: prove ownership of a domain via a DNS TXT record, and the service submits this authorization on your behalf. A delegate-free, proof-carrying path —domain authorize— also exists on chain: its DNSSEC verifier mints the domain straight from a proof, the CLI stages and submits the witness end-to-end, and both routes charge the same authorization fee.
Authorize a domain by proof
See Authorize a domain by proof for what proving domain ownership buys you and why DNS is the root of trust behind it. This page is the full technical reference: the on-chain DNSSEC verifier, the witness-staging protocol, the sithbit domain authorize command tree, and measured compute costs.
sithbit domain authorize <domain> \
( --witness-file <path> | --witness-hex <hex> ) \
[--payer-keypair <path>] \
[--skip-preflight]
This is the proof-carrying sibling of
domain create. Where domain create authorizes a
domain because the delegate signed for it (that delegate-initiated,
delegate-signed path remains as-is), domain authorize
authorizes a domain because the transaction carries a witness — the DNSSEC
RRSIG chain proving the domain’s _solana.authority delegation — that the
on-chain program verifies for itself. No delegate signature is required,
and the submitter need not be anyone special: anyone may submit a proof and
pay for it, because the proof, not the signer, is the authority. Both paths
charge the same authorization fee.
The design rationale — why moving from a signed token to a verifiable proof takes the admin key out of the loop entirely — is the Proving behaviour to the chain design note.
Status: the on-chain verifier is live
The on-chain DNSSEC verifier is implemented and enforced. Given a staged witness buffer,
AuthorizeDomainByProofwalks the real chain-of-trust from the postoffice’s root KSK down to the leaf_solana.authority.<domain>TXT and mints the domain with the proven ed25519 authority — proven end-to-end in thedomain_programtest suite. It verifies all three DNSSEC signature algorithms a real ICANN-anchored chain uses: RSA-2048/SHA-256 (algorithm 8), ECDSA-P256/SHA-256 (algorithm 13), and Ed25519 (algorithm 15) — see How it works.Because a real witness (~2.7–3.1 KiB) exceeds Solana’s ~1232-byte legacy/v0 transaction packet (transaction v1, SIMD-0385, raises the envelope to 4096 bytes, but the CLI still emits legacy transactions, so the chunking stays sized to 1232 until a v1 emit path lands), it is staged into a program-owned buffer PDA first via chunked
WriteProofWitnessinstructions, and the buffer is closed and its rent refunded afterward withCloseProofWitness(both detailed below).
How it works
The witness is far too large to ride inside a single instruction, so authorization is a two-step flow against a program-owned buffer, with a third instruction to reclaim the buffer’s rent afterward:
- Stage the witness. The full DNSSEC chain is written into a program-owned
buffer PDA seeded on
[PROOF_WITNESS_SEED, payer, blake3(domain)](see How blake3 hashing works) — a fixed header plus the contiguous witness bytes, capped at 8 KiB — via a series of chunkedWriteProofWitnessinstructions (discriminant 27). Each carries an offset and a chunk of up toMAX_PROOF_WITNESS_CHUNK(900) bytes; the first write allocates the buffer to its declaredtotal_len, and later writes fill it in untilwritten_len == total_len. - Authorize. A single
AuthorizeDomainByProoftransaction then reads the fully-staged buffer and runs the verifier. Because the chain walk is compute-heavy (306–312k CU measured for an all-RSA chain, well past the 200k default — see Measured compute cost), this transaction must prepend aComputeBudgetset-compute-unit-limit instruction. - Reclaim the rent. Once authorization has landed (or the attempt is
abandoned),
CloseProofWitness(discriminant 28) closes the buffer PDA and refunds its full rent to the payer. Only the original payer may close it — the buffer PDA is seeded on the payer, so a different signer derives a different address and cannot reach it — and a partially-written buffer refunds just the same. The CLI emits this automatically after a successfuldomain authorize; for a failed or abandoned attempt, rundomain authorize --close-witnessyourself.
The authorization fee
A verified proof pays the same delegate-tuned domain-authorization fee
domain create charges — DOMAIN_AUTHORIZATION_FEE_LAMPORTS, 0.01 SOL by
default, tunable via SetDomainFee (see
Economics) — debited from the payer
and credited to the postoffice once, at the authorize step, after the chain
walk succeeds. The two authorization paths cost the same, so nobody routes
around the fee by choosing one path over the other. A failed proof charges
nothing beyond transaction fees — the domain is not created and no fee moves
(the staged buffer’s rent stays reclaimable via
domain authorize --close-witness).
The chain-of-trust walk
The verifier (program_common::dnssec::walk_chain_with) anchors at the
postoffice’s root_ksk fingerprint: a DS-style SHA-256 digest of
owner ‖ DNSKEY (RFC 4509) for some key in the root DNSKEY set, which must
self-sign that set. From there it walks each delegation down the tree — for
every link it canonicalizes the RRSIG (RFC 4034 §6), matches the DS digest
root → TLD → registrable zone, and checks the signature’s validity window
against the cluster clock — and finally verifies the leaf
_solana.authority.<domain> TXT RRSIG. The ed25519 public key that TXT
publishes (base58) becomes the new domain’s authority.
Three signature algorithms
A real ICANN-anchored chain mixes signature algorithms: the root and TLD zones sign with RSA-2048/SHA-256 (DNSSEC algorithm 8), while the registrable leaf zone is today typically ECDSA-P256/SHA-256 (algorithm 13 — used by Cloudflare, Route 53, and Google Cloud DNS); Ed25519 (algorithm 15) is valid but rare. The verifier handles all three, by two different routes:
- RSA (alg 8) is checked inline. Its RSASSA-PKCS1-v1_5 modular exponentiation
runs in-program through the allocator-free
sol_big_mod_expsyscall. An all-RSA chain needs nothing beyond the buffer. - ECDSA-P256 (alg 13) and Ed25519 (alg 15) ride the native precompiles.
Solana has no in-program P-256 or Ed25519 curve op, only the
Secp256r1SigVerifyandEd25519SigVerifyprecompiles. So for each non-RSARRSIGin the chain, the transaction includes one matching precompile instruction and passes the Instructions sysvar as an optional 6th account toAuthorizeDomainByProof. The program then introspects that sysvar to confirm a precompile in this transaction verified the exact(public key, message, signature)the DNSSEC link requires. (An all-RSA chain omits the sysvar account entirely.) The CLI derives and emits all of this automatically — see the walkthrough.
The one wrinkle between the two delegated algorithms: ECDSA-P256 signs a SHA-256 pre-hash of the canonical RRset, whereas Ed25519 signs the canonical octets raw and hashes them itself with SHA-512 — the verifier hands each precompile the message shape its algorithm expects.
Because the verifier calls sol_big_mod_exp, the target cluster must have the
enable_big_mod_exp_syscall feature active. It is active on mainnet; a
local surfpool validator needs --features-all.
Prerequisite: publish the root KSK
The proof chains back to a root key-signing-key (KSK) fingerprint stored
on the PostOffice account — a mail-program account the
domain program reads
cross-program. The delegate publishes it once (and rotates it
when ICANN rolls the root key):
sithbit postmaster ksk set <BASE58_32B> \
[--keypair <delegate keypair>] \
[--skip-preflight]
Read the fingerprint currently anchored on the PostOffice with the read-only
sithbit postmaster ksk get (prints the base58 value, or unset).
The fingerprint is a 32-byte value in base58 (the same encoding wallet
addresses use). Concretely it is the DS-style SHA-256 digest of
owner ‖ DNSKEY (RFC 4509) for the ICANN root KSK — the same digest a DS
record carries — which is what the verifier’s anchor step matches. The all-zero
value —
11111111111111111111111111111111 — is the “unset” sentinel and clears it;
while it is unset, domain authorize fails with RootKskUnset before it
looks at the witness at all.
You do not compute that digest by hand. IANA publishes it as the SHA-256
KeyDigest in its root trust anchor
(root-anchors.xml), and postmaster ksk iana turns that file into
the base58 value — a pure offline conversion that neither signs nor touches the
chain:
# from a downloaded anchor file (recommended — verify its signature first)
sithbit postmaster ksk iana --anchors-file root-anchors.xml
# or fetch it straight from data.iana.org (unverified — dev/preview only)
sithbit postmaster ksk iana
It prints each anchor’s key tag, digest, and base58 fingerprint, and — when
exactly one is active (no validUntil) — the ready-to-run ksk set line.
The trust decision stays with you: the CLI does not fetch the anchor inside
ksk set itself, because the whole security model rests on the delegate
deliberately vouching for the anchor (verified out-of-band), not on trusting
whatever a network lookup returns. It is a rare, governance-paced step — you
re-run it only when ICANN rolls the root key.
Rollovers: more than one active anchor
During a root-KSK rollover ICANN publishes two active anchors (neither
carries a validUntil) for the overlap period — as it is doing now for the
KSK-2024 introduction alongside KSK-2017. When it sees more than one active
anchor, ksk iana does not guess: it prints a warning, recommends the
newest by validFrom (with that anchor’s ready-to-run ksk set line), and
exits non-zero so the choice stays deliberate. Note that “newest” is a
convenience, not gospel — during the introduction phase the incoming key may be
published before it is signing. Confirm which key tag is operational against
ICANN’s rollover announcement, then pin exactly that one:
sithbit postmaster ksk iana --key-tag 20326
--key-tag selects that anchor’s ksk set line directly (and exits zero), so
once you have decided, the command is scriptable again. Because the root DNSKEY
RRset carries both keys throughout the overlap, a witness verifies against
either active anchor while both are present; pinning the key that will remain
avoids a re-ksk set when the old one is finally revoked.
Downloading and verifying the anchor
IANA signs root-anchors.xml with a detached S/MIME (CMS) signature
(root-anchors.p7s) — not PGP. --fetch-anchors <DIR> downloads the anchor,
that signature, and ICANN’s CA bundle, then prints the exact openssl command
to verify them and the follow-up derive step. It deliberately does not
derive a fingerprint itself — you verify first:
sithbit postmaster ksk iana --fetch-anchors ./anchors
then run the printed command:
openssl cms -verify -CAfile ./anchors/icannbundle.pem -inform DER \
-in ./anchors/root-anchors.p7s -content ./anchors/root-anchors.xml -binary
The -binary flag is required: without it openssl canonicalizes the detached
content as text (CRLF translation) and the digest fails to match. On older
OpenSSL use openssl smime -verify -inform der ... instead.
Verification successful means the XML is authentic; now derive from the file
you just verified:
sithbit postmaster ksk iana --anchors-file ./anchors/root-anchors.xml
Getting ICANN’s CA independently
There is a bootstrap trap here: icannbundle.pem was fetched from
data.iana.org over the same channel as the anchor it vouches for. On its
own it only proves the three files are internally consistent — a network
attacker who can serve you a forged root-anchors.xml can serve a matching
forged .p7s and icannbundle.pem too. Verifying downloaded content against a
downloaded signature using a downloaded CA proves nothing unless the CA reaches
you through a channel independent of the data. ICANN’s DNSSEC CA is a
private CA — it is not in your browser/system web-PKI trust store — so you
cannot lean on the usual roots. Practical ways to obtain it out-of-band, in
rough order of effort:
- Cross-check the digest itself, not the CA (simplest, and usually enough).
The root KSK’s DS digest is a widely-replicated public constant. Confirm the
hex
postmaster ksk ianaprints —E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8Dfor the current KSK-2017 (tag 20326) — against several independent, authenticated sources: your distro’s DNSSEC root-anchor package (below), ICANN’s site over web-PKI TLS, and RFC 7958. If independent sources agree on the digest, the S/MIME dance is belt-and-suspenders. - Use a copy shipped through your distro’s signed package channel. Packages
like Debian/Ubuntu
dns-root-data(/usr/share/dns/root.ds) andunbound(whoseunbound-anchorships a built-in copy of ICANN’s CA / the 2017 KSK to bootstraproot.key) reach you via APT/DNF’s GPG-signed repositories — a genuinely independent, cryptographically-verified channel. Pointopenssl -CAfileat unbound’s bundled cert, or just compare digests withunbound-anchor -v. - Fetch the CA over multiple independent network paths and compare. Download
icannbundle.pemfrom a different ISP, a cloud VM in another region, and/or over Tor, and compare the file’s SHA-256. A non-global adversary cannot MITM all paths at once, so matching hashes raise confidence. - Check the CA certificate’s own fingerprint against ICANN’s out-of-band publications (its DNSSEC practice statement and announcements).
For a one-time devnet/preview setup the bare ksk iana fetch is fine; for a
mainnet delegate, verify through at least one independent channel above before
you ksk set.
Note:
ksk setis a delegate-only governance action (error 66,NotDelegate, for any other signer), gated behind the CLI’spostmasterfeature. See The Postmaster.
Supplying the witness
The witness is the opaque RRSIG-chain bytes — the staged DNSSEC proof the
verifier walks. Provide it from exactly one of two sources (the CLI enforces
the choice):
--witness-file <path>— a file holding the raw witness bytes.--witness-hex <hex>— the witness as a hex string (an optional0xprefix is accepted).
The CLI bounds the witness client-side at MAX_PROOF_WITNESS_LEN (8 KiB — the
on-chain buffer’s cap) and refuses an empty one, both before it sends any
transaction, so a mis-sized witness never costs a fee. A real chain runs
~2.7–3.1 KiB, comfortably inside that bound.
Building the witness with gather-witness
Not in the default build.
gather-witnesslinks a DNS resolver stack, so — likediscover— it is gated behind the CLI’s opt-ingatherfeature and is absent from a stocksithbitbinary. Build one that has it withcargo build -p mail-client --features gather(or--all-features) before the commands below will resolve.
You do not assemble those bytes by hand. domain gather-witness collects them
from live DNS for you:
sithbit domain gather-witness <domain> \
[--resolver <ip>] \
[--out <path>]
It queries a recursive resolver with the DNSSEC DO bit set — so every answer
carries its RRSIG — walks the delegation from the root to your zone (finding
each cut by its DS record), and collects exactly the records the on-chain
verifier needs: the root DNSKEY self-signature, each zone’s parent-signed DS
and self-signed DNSKEY, and your leaf _solana.authority.<domain> TXT. It
serializes them into the witness buffer and — before writing anything — re-walks
the assembled chain locally with the very same program_common verifier the
program runs (RSA inline; ECDSA-P256/Ed25519 via host curve checks), so a witness
that would fail on-chain is caught for free, not paid for. On success it prints
the root KSK fingerprint the chain anchors to (which must match the one set
on-chain with ksk set) and the
proven authority key.
# write the witness to a file, then authorize with it
sithbit domain gather-witness sithbit.com --out proof.bin
sithbit domain authorize sithbit.com --witness-file proof.bin
# or without --out, it prints the hex for --witness-hex
sithbit domain gather-witness sithbit.com
--resolver defaults to 1.1.1.1; override it if your network blocks it or the
default does not return RRSIGs. (As noted above, gather-witness needs a CLI
built with --features gather.)
Publishing the DNS records the proof needs
gather-witness (and the on-chain verifier) require your domain to be a
DNSSEC-signed zone apex publishing a single authority TXT. Three one-time
setup steps get you there:
- Enable DNSSEC at your DNS host. Your host signs the zone and shows you a DS record (key tag, algorithm, digest type, digest). On Cloudflare: DNS → Settings → Enable DNSSEC; it signs with ECDSA-P256 (algorithm 13), which the verifier handles.
- Install that DS at your registrar. The DS must live in the parent
zone (e.g.
.com), and only your registrar — the company you bought the domain from — can write there. Copy the DS from your DNS host into the registrar’s DNSSEC panel; the registrar relays it to the TLD registry, which completes the signed delegation. Special case: if you registered the domain through Cloudflare Registrar (Cloudflare is both registrar and DNS host), enabling DNSSEC submits the DS for you — nothing to copy. The two-step copy only applies when your domain is registered elsewhere and merely uses Cloudflare for DNS. - Publish the authority TXT. Add exactly one TXT record at
_solana.authority.<domain>whose value is your wallet’s authority key, base58-encoded (the same convention the off-chaindomain-sithbitflow reads). More than one TXT at that name is rejected — the verifier requires exactly one.
Once the DS is live at the parent and the TXT is published, gather-witness
can collect a complete, verifiable chain.
The --payer-keypair funds the staging and authorize transactions, the new
domain account’s rent, and the authorization fee; it
defaults to the CLI’s configured keypair, and it is the only required
signer — no delegate signature is involved.
End-to-end walkthrough
Two commands take a domain from a witness to authorized. First the delegate
publishes the root KSK once (see Prerequisite: publish the root
KSK); then anyone holding the witness
runs domain authorize:
# once, by the delegate — anchors every proof to the ICANN root
sithbit postmaster ksk set <BASE58_32B>
# permissionless — anyone with the witness may submit and pay
sithbit domain authorize example.com --witness-file ./proof.bin
That second command runs the whole submission for you — you never stage the
buffer, build a precompile instruction, attach a ComputeBudget instruction,
or reclaim the buffer’s rent by hand:
- It stages the witness automatically, splitting it into ≤900-byte chunks
and writing each with a confirmed
WriteProofWitnesstransaction, printingStaged witness chunk N/Nas it goes (the buffer protocol is How it works). - It derives any precompile instructions the chain needs — for each
non-RSA (ECDSA-P256 or Ed25519)
RRSIGit re-walks the witness client-side (the sameprogram_commonchain walk the program runs, with a collecting verifier plugged into the same seam the on-chain precompile introspection uses) and builds one self-containedSecp256r1SigVerify/Ed25519SigVerifyinstruction over the exact(public key, canonical message, signature)tuple the on-chain walk will demand. An all-RSA chain needs none, and the transaction is unchanged from its historical form. - It then submits
AuthorizeDomainByProofwith aComputeBudgetset-compute-unit-limit instruction prepended automatically (400k, sized for the measured ~306–312k-CU all-RSA chain walk with headroom — see Measured compute cost), the derived precompile instructions alongside it, and — only when precompiles ride — the Instructions sysvar as the authorize instruction’s 6th account, and prints the submitted-proof transaction URL. - On a successful authorization it closes the spent witness buffer
automatically with
CloseProofWitness, refunding the buffer’s rent to the payer and printingClosed witness buffer; reclaimed N lamports. A failed authorize deliberately skips this step: the staged buffer stays put for a retry or inspection, reclaimable any time withdomain authorize --close-witness.
This is proven end-to-end in the client integration suite for both a genuine
all-RSA-2048 chain (authorize_by_proof_cli_with_staged_witness_succeeds) and
an ECDSA-P256-leaf chain — the shape most Cloudflare, Route 53, and Google
Cloud DNS zones have today — with the CLI-emitted precompiles
(authorize_by_proof_cli_with_ecdsa_leaf_succeeds).
One honest wrinkle: the on-chain matcher compares each precompile’s signature bytes against the
RRSIG’s raw bytes, and the secp256r1 precompile itself rejects high-S ECDSA signatures — so an alg-13RRSIGwhose signature is high-S cannot be proven on-chain at all. Real DNSSEC signers emit low-S in practice; the CLI normalizes to low-S on emission, which is the identity for those.
Reclaiming an abandoned witness buffer
sithbit domain authorize <domain> --close-witness \
[--payer-keypair <path>] \
[--skip-preflight]
A successful domain authorize reclaims the staged buffer’s rent
automatically, but a failed or abandoned attempt leaves the buffer behind
on purpose — the staged witness stays available for a retry or for inspection.
When you are done with it, domain authorize --close-witness submits
CloseProofWitness to close the buffer and refund its full rent, printing
the reclaimed lamports.
The --payer-keypair must be the same keypair that staged the witness: the
buffer PDA is seeded on the payer, so it is the only key that derives — and may
close — that buffer, and it is where the rent refund lands. A partially-staged
buffer (an attempt abandoned mid-write) closes and refunds just the same. If no
buffer exists for the (payer, domain) pair, the command refuses client-side
before sending any transaction.
Measured compute cost
The chain walk’s cost is measured through the deployed .so on a real
cluster (surfpool), driven by the two positive end-to-end tests. Both shapes
walk a three-zone chain (root → TLD → leaf):
- All-RSA chain (six RSA-2048 verifications, all inline via
sol_big_mod_exp): the authorize instruction consumed 305,704–311,868 CU across runs — the small spread tracks witness content (domain-name lengths and key values vary per run). - ECDSA-P256-leaf chain (four RSA-2048 verifications inline; the leaf’s two
P-256
RRSIGs proven bySecp256r1SigVerifyprecompile instructions): 229,845–231,181 CU. The precompile instructions themselves metered zero transaction-budget CU on this runtime, so the transaction-wide total was the program’s consumption plus 150 CU for theComputeBudgetinstruction itself.
The CLI’s 400,000-CU limit therefore keeps ~28% headroom over the worst
measured shape. Each additional all-RSA zone in a deeper chain costs roughly
+80k CU (two more RSA-2048 verifications at ~40k each), so a four-zone
chain approaches the limit and a deeper one would need it raised. The other
instructions are cheap and ride the 200k default: each WriteProofWitness
chunk measured ~9–11k CU and CloseProofWitness ~7–8k.
Reclaim a domain by proof
See Domains for what a domain
is and how it’s normally created, administered, and transferred. This page
covers reclaiming a domain’s on-chain authority by DNSSEC proof — the
sovereign-DNS counterpart to authorizing a new domain by proof — including
its timelock mechanics, its threat model, and the sithbit domain reclaim
command tree.
sithbit domain reclaim <domain> \
( --witness-file <path> | --witness-hex <hex> ) \
[--payer-keypair <path>] \
[--skip-preflight]
Reclaiming is the sovereign-DNS counterpart of
domain authorize. Where authorize mints a new
domain from a DNSSEC proof, reclaim targets a domain that already exists
and seizes its on-chain authority for the wallet address a fresh proof establishes —
even when that domain is held by someone else on chain. It is the same
proof-carrying, permissionless machinery: anyone may submit a current
DNSSEC proof and pay for it, because the proof, not the signer, is the
authority. The one difference from authorize is the guard rail — a reclaim
does not take effect immediately. It runs behind a 7-day timelock, so the
current on-chain authority has a window to notice and respond.
DNS is the root of trust, forever
This is the deliberate design intent, stated plainly: the DNS owner of a
domain can always reclaim its on-chain authority. On-chain possession of a
domain is never permanently sovereign against the DNS root. Whoever controls the
domain’s DNSSEC delegation today — and can therefore publish a current
_solana.authority.<domain> TXT and sign it down a chain that anchors to the
postoffice’s root KSK — can produce a proof that binds the domain to a wallet of
their choosing, and reclaim it.
That is not a loophole; it is the point. SithBit domains are DNS domains.
Ownership of the on-chain MailDomain account tracks ownership of the real
domain name, and the real domain name is governed by DNS, not by the chain. If a
domain changes hands at the registrar, or a stale/hostile party holds the
on-chain authority, the rightful DNS owner is never locked out: a fresh proof
reclaims the authority. The chain defers to the DNS root of trust as the final
arbiter of who owns a name.
The two-step timelock
Because a reclaim can seize a live domain’s authority, it cannot be instantaneous — that would let a fresh proof yank a domain out from under its current holder with no warning. So a reclaim is a request → wait → finalize flow, mirroring deactivation’s shape:
- Request.
domain reclaimstages the DNSSEC witness and, on a verified proof, opens the timelock: it creates a small transient PDA seeded on the domain, stamped with the current chain time and the proven incoming authority (the ed25519 key the leaf TXT published). TheMailDomainaccount itself is untouched — its authority does not change yet. The presence of that pending PDA is the “reclaim pending” flag. - Wait. A 7-day clock runs (
RECLAIM_TIMELOCK_SECS). During it the domain keeps its current authority and mail keeps flowing. - Finalize. Once the clock elapses,
domain reclaim --finalizeinstalls the recorded authority onto the domain and closes the pending PDA. The chain was walked once, at request time, so finalize trusts the recorded key and is permissionless — any fee-payer may crank it. The rent refund, however, does not follow the cranker: the pending PDA records the wallet that funded the request, and finalize pays the refund back to that wallet. A stranger turning the crank performs a service; they cannot capture the requester’s deposit by doing so.
Before the timelock elapses, the reclaim can be cancelled by either the domain’s current authority (the standing holder rejecting an unwanted reclaim) or the proven key itself; the canceller receives the pending PDA’s rent refund. Cancelling is authority-gated rather than permissionless, which is why it is the one path where the refund follows the signer.
Request the reclaim
sithbit domain reclaim <domain> \
( --witness-file <path> | --witness-hex <hex> ) \
[--payer-keypair <path>] \
[--skip-preflight]
The witness, its two input sources, the client-side size bound, the automatic
buffer staging + ComputeBudget bump + precompile emission for non-RSA chain
links, and the automatic buffer close on success are exactly as
domain authorize describes — a reclaim
reuses that whole pipeline. It also requires the same
prerequisite root KSK
and charges the same
authorization fee (0.01 SOL by
default) once the proof verifies. The --payer-keypair funds the pending PDA’s
rent plus that fee and is the only required signer.
sithbit domain reclaim example.com --witness-file ./proof.bin
Finalize after the timelock
sithbit domain reclaim --finalize <domain> \
[--payer-keypair <path>] \
[--skip-preflight]
Once at least 7 days have passed since the request, this swaps the domain’s
authority to the proven key and closes the pending PDA (rent to the fee-payer).
Run before the timelock has elapsed, it is rejected on-chain
(ReclaimTimelockNotElapsed). It is permissionless — the fee-payer need not
be anyone in particular.
sithbit domain reclaim --finalize example.com
Cancel a pending reclaim
sithbit domain reclaim --cancel <domain> \
[--keypair <path>] \
[--skip-preflight]
Aborts an in-flight reclaim before it finalizes, closing the pending PDA. The signer must be the current authority or the proven key; the domain stays with its current authority as if the request had never happened.
sithbit domain reclaim --cancel example.com
Seeing a pending reclaim
domain get surfaces any in-flight reclaim beneath the
domain’s authority line — the request timestamp, the proven incoming authority,
and whether the timelock has elapsed:
sithbit domain get example.com
Domain 'example.com' (…) has authority <current> and is active
Reclaim pending: requested at <ts> for authority <proven key>; timelock not yet elapsed (unlocks at <ts>)
A pending reclaim freezes the marketplace
While a reclaim is pending, the domain cannot be bought or listed. Both
domain buy and
domain sell are refused on-chain with DomainReclaimPending
(error 71). This closes an obvious
front-run: without it, a holder who saw an incoming reclaim could dump the domain
on the open marketplace — selling it out from under the
reclaimer — or a buyer could pay for a domain that is about to change owner. Once
the pending is cancelled or finalized (or never existed), listing and buying are
allowed again.
Threat model: the timelock is the defense window
The 7-day timelock is the whole security argument for reclaim. A reclaim proves DNS ownership as of proving time — it is a snapshot of the domain’s DNSSEC state when the witness was assembled. The timelock turns that snapshot into a notice-and-veto window: the current on-chain authority (or an operator watching for pending reclaims) sees the request, and can cancel it, migrate custody, or otherwise respond before the authority actually changes hands. Nothing is seized silently or instantly.
That window also bounds a subtler risk. A DNSSEC RRSIG is valid for its whole
signature window, so a proof reflects DNS state at the moment it was signed,
not the moment it is submitted — a proof captured while a party controlled the
domain stays cryptographically valid until its RRSIGs expire, even if control
has since moved on. The timelock caps the damage of such a stale-but-still-valid
(replayed) proof: because finalizing takes 7 more days after the request lands,
the true current owner always has time to notice a reclaim opened against an
out-of-date proof and cancel it. The defense is not “proofs never go stale” — it
is “a stale proof cannot complete a reclaim faster than the real owner can veto
it.” Keep signature windows short and rotate the delegated authority key when
custody changes to shrink the replay surface further.
For the broader admin-key and trust discussion, see the threat model.
Related
- Domains — what a domain is and its normal creation/administration lifecycle.
- Authorize a domain by proof — the sibling operation that mints a new domain from a DNSSEC proof, with no timelock.
- Looking up a domain — reading a domain’s current authority and any pending reclaim.
- List a domain for sale — the marketplace listings a pending reclaim freezes.
- Threat model — the broader admin-key and trust discussion this page’s threat-model section draws on.
Attest a verified sender
See Verified-sender attestation for what an attestation is and why a sending organization buys one. This page is the technical reference: the attest, lookup, and revoke commands, the postmaster fee setting, and the on-chain mechanics they ride.
sithbit domain attest-sender <MAIL_DOMAIN> [WALLET] \
( --witness-file <path> | --witness-hex <hex> ) \
[--payer-keypair <path>] \
[--skip-preflight]
This is the third member of the proof-carrying family, alongside
domain authorize and
domain reclaim. It submits the same DNSSEC
witness — the RRSIG chain proving the domain’s _solana.authority
delegation, which the on-chain program verifies for itself — but
instead of authorizing the domain, a verified proof records that the
domain vouches for a wallet as a legitimate sender. Like its siblings
it is permissionless: anyone may submit a proof and pay for it,
because the proof, not the signer, is the authority.
What a verified proof mints is the SenderAttestation account for the
(domain, wallet) pair, stamped with the chain clock. Three shape
differences from domain authorize are worth knowing:
- No domain account is involved. Attesting neither reads nor creates a
MailDomain: a domain that has never been registered as a mail domain can attest senders just the same. The attestation is a freestanding record, and it confers no serving rights over the domain. - The attested wallet rides the instruction payload. It defaults to the payer’s own address, but the proving claimant may attest any wallet — control of the domain’s DNS is the sole authorization, and the authority key the leaf TXT publishes is not required to match the attested wallet (the domain vouches for whoever it names).
- One record per (domain, wallet) pair, any number of pairs. A domain may attest as many wallets as it likes; each attestation is minted — and later revoked — independently.
The witness, its two input sources (--witness-file / --witness-hex),
the client-side size bound, gathering the witness from live
DNS, the
automatic buffer staging + ComputeBudget bump + precompile emission for
non-RSA chain links, and the automatic buffer close on success are
exactly as domain authorize
describes — an attest reuses that whole pipeline, including the
prerequisite root KSK.
The staged buffer is even the same (payer, domain) PDA, so
either command can reclaim it.
# attest your own wallet (the payer's address is the default)
sithbit domain attest-sender acme.com --witness-file ./proof.bin
# attest a different sending wallet
sithbit domain attest-sender acme.com mAiLiLdgjgGdWoCZkpW3cj7JLAC56qb4NErFyQFWNJg \
--witness-file ./proof.bin
The attestation fee
A verified proof pays a one-time flat fee to the postoffice —
DEFAULT_SENDER_ATTESTATION_FEE_LAMPORTS, 0.01 SOL by default —
debited from the payer once, at the attest step, after the chain walk
succeeds. A failed proof charges nothing beyond transaction fees, and
the staged buffer’s rent stays reclaimable. The CLI quotes the currently
tuned fee in its success output, and anyone can read it ahead of time —
no signature, ships in every build:
sithbit postoffice fee attestation
The delegate tunes the fee with:
sithbit postmaster fee attestation <LAMPORTS> \
[--keypair <delegate keypair>] \
[--skip-preflight]
The value is hard-capped on-chain at
MAX_SENDER_ATTESTATION_FEE_LAMPORTS (0.1 SOL, 10× the default) — an
over-cap value refuses with custom error 101
(SenderAttestationFeeAboveCap) — so even a compromised delegate key
cannot price attestation out of reach. Zero resets to the protocol
default: like the settlement rates, a zero stores the “unset” sentinel,
so the fee cannot be tuned to literal zero. A postoffice account that
predates the fee field reads back the default, and the setter grows the
legacy account in place (to the 184-byte layout) on its first run. See the
tunable-constants table.
Looking up an attestation
sithbit domain attestation <MAIL_DOMAIN> <WALLET>
Read-only, and — like domain get — it ships in every
build. It prints the attested wallet, the attestation account, and the
attested-at timestamp, or a not-found notice when the domain has not
attested that wallet (a missing record is the common negative answer, not
an error):
sithbit domain attestation acme.com mAiLiLdgjgGdWoCZkpW3cj7JLAC56qb4NErFyQFWNJg
Servers ask the same question over the
mail-grpc gateway: the
GetSenderAttestation call takes {domain, wallet} and answers
{attested, attested_at}. A clean on-chain absence answers
attested = false; a failed chain read is an UNAVAILABLE status, never
a false — absent and unknown stay distinguishable. The read is
finalized-commitment and uncached, so a fresh attestation (or revocation)
is visible on the next call.
Revoking an attestation
sithbit domain revoke-attestation <MAIL_DOMAIN> \
[--keypair <path>] \
[--skip-preflight]
Closes the (domain, wallet) attestation for the signing wallet and
refunds the record’s rent to it. Only the attested wallet itself can
revoke: the attestation’s address is re-derived from the signer, so any
other key — the domain’s included — derives a different address and never
reaches the record (the same derivation binding
CloseProofWitness uses for its
payer). The command refuses client-side when no attestation exists for the
signer under that domain.
sithbit domain revoke-attestation acme.com --keypair wallet.json
Reclaiming an abandoned witness buffer
sithbit domain attest-sender <MAIL_DOMAIN> --close-witness \
[--payer-keypair <path>] \
[--skip-preflight]
A successful attest reclaims the staged witness buffer’s rent
automatically; a failed or abandoned attempt leaves the buffer behind for
a retry or inspection, exactly as domain authorize does. When you are
done with it, --close-witness closes the buffer and refunds its full
rent to the same payer keypair that staged it — the buffer PDA is
seeded on the payer, so it is the only key that can. Because the buffer is
the shared (payer, domain) staging PDA,
domain authorize --close-witness
reclaims the identical buffer.
Measured compute cost
Measured through the deployed .so on a real cluster, like every row in
the compute-unit table: the attest
transaction consumed 322,474 CU (fenced at 345,000) — the DNSSEC chain
walk dominates, which is why the CLI prepends the same ComputeBudget
limit domain authorize sizes to the proof’s zone depth.
RevokeSenderAttestation measured 11,224 CU (fenced 34,000) and
SetSenderAttestationFee 6,546 CU (fenced 30,000), both comfortably on
the 200k default budget.
Related
- Verified-sender attestation — what the record means, who buys it, and what it does not confer.
- Reputation-scaled first-contact pricing — the economic effect an attestation buys: a stranger’s first contact priced at the discount floor immediately, no spend history required.
- Authorize a domain by proof — the shared
witness pipeline, root-KSK prerequisite, and
gather-witness. - Reclaim a domain by proof — the other proof-carrying sibling, for seizing an existing domain’s authority.
- Program & PDA reference — the
instruction discriminants and the
sender_attestationPDA seed.
Transfer a domain
See Domains
for what a transfer conveys — mail-serving control of the domain, not the
mailboxes that reference it — and why it’s a
delegate-only operation. This page
covers the sithbit domain transfer command.
sithbit domain transfer <domain> <new_authority> \
[--keypair <delegate keypair>] \
[--skip-preflight]
Arguments
<domain>(required) — the domain to transfer.<new_authority>(required) — a bare address (or alias/keypair path the CLI can resolve to one) of the wallet that becomes the domain’s new authority. It does not sign: the new operator need not be online, and no signature from the outgoing authority is required either. It may be any wallet, including one that has never held a domain before.--keypair <delegate keypair>— the delegate’s signing keypair. Required on every invocation: a signer that isn’t the postoffice’s standing delegate is refused with error 66,NotDelegate. Also pays the transaction fee. Defaults to your configured default keypair when omitted.--skip-preflight(optional) — submits the transaction without a local simulation pass first.
This command requires the CLI’s domain feature, which is enabled by
default.
What changes on-chain
Transfer rewrites a single field — authority — on the existing
MailDomain PDA.
Its is_active flag, rent_payer, and domain name are all left
untouched, and no account is created or closed. Consequences:
- The domain keeps serving mail without interruption; only the key that
may sign
SendMailfor it changes. - Because
rent_payeris unchanged, a laterdomain closestill refunds the account rent to whoever originally funded it — transferring authority does not move the rent claim. - A domain must already exist and be program-owned to be transferred; transferring a domain that was never created is rejected.
Refused while listed
A domain with an open marketplace listing
(domain list) cannot be transferred. Repointing the
authority under a live listing would leave a stale holder recorded as
the listing’s seller — able to collect the sale proceeds the moment a
buyer paid for a domain they no longer own. The instruction carries the
domain’s listing account read-only and refuses while a listing stands,
with The domain has an open listing; cancel it before closing, transferring, or deactivating on stderr. The authority must cancel the
listing first; then the delegate transfers.
Deactivation is the asymmetric case: requesting a deactivation proceeds while a listing is open — the delegate’s safety brake on a rogue domain is never blocked by a sale — and it is the purchase that then refuses while the deactivation timelock is pending. See Deactivate a domain.
Example
sithbit domain transfer sithbit.net maiLtdkxym8CCmo9TwDuXywqd9DXaK3tB6toKFVeBFR
After it lands, SendMail for sithbit.net must be signed by
maiLtdkxym8…VeBFR, and that key earns the operator settlement share
(see Economics). Confirm the new value any time
with domain get, which prints the domain’s current
authority.
Errors
NotDelegate(error 66) — the signing--keypairisn’t the postoffice’s recorded standing delegate. Transfer is a delegate-only operation, same as create and deactivate.- Transferring a domain that was never created, or is not program-owned, is rejected.
- Transferring a domain with an open marketplace listing is refused; see Refused while listed above.
Note: don’t confuse this with handing over the postoffice itself — see Delegate and postmaster administration.
domain transferchanges who administers one domain; installing a new postmaster or repointing the standing delegate changes who administers every domain in the deployment.
See also
- Create a domain
- Deactivate a domain
- List a domain for sale — the marketplace sale the open-listing guard protects, and what an authority swap conveys.
- The marketplace sells protocol authority; DNS remains separately owned — the threat model of repointing authority away from whoever controls the domain’s DNS.
- Closing accounts for retiring
a domain outright with
domain close.
List a domain for sale
See Domains
for what a domain sale conveys — and doesn’t — and Trading names: aliases &
domains for how a domain
listing fits alongside the rest of the marketplace. This page covers the
sithbit domain sell/buy command tree.
sithbit domain sell <domain> --price <lamports> \
[--expires-in <seconds>] \
[--keypair <authority keypair>] \
[--skip-preflight]
Puts a domain up for sale on the open marketplace: a fixed-price
listing that any buyer may take, first come, first served. Whoever signs
domain buy and pays the price becomes the
domain’s authority — the
mail server’s signing key, and the wallet that collects the domain’s
operator share of settlement.
sithbit domain sell sithbit.net --price 5000000000
Listed mail domain 'sithbit.net' for sale at 5000000000 lamports
https://explorer.solana.com/tx/…
Who signs
Unlike every other domain mutation — create, transfer, deactivate,
close are all delegate operations
— listing a domain is signed by the domain’s current authority
(--keypair, defaulting to the CLI’s configured keypair): selling is the
owner’s own decision, not an administrative one. The delegate is not
involved at any step of a marketplace sale.
The price is in lamports and must be positive — a
free hand-off uses domain transfer instead, a
delegate operation. The listing lives in a dedicated account derived from
the domain name’s blake3 hash, so each
domain can carry at most one listing at a time. The authority fronts that
account’s rent when staging the listing and gets it back when the listing
resolves — bought, cancelled, or reclaimed after expiry.
Listing changes nothing about how the domain serves mail: the current
authority keeps signing SendMail and collecting the operator share until
the moment a buyer pays.
The binding window, replacing, and expiry
Listings share the alias marketplace’s semantics exactly:
- For its first 5 minutes a listing is binding on the authority — it can be neither cancelled nor replaced, so a buyer paying promptly can’t be front-run by a retraction. Buying is never window-gated.
- A new
domain sellfor the same domain replaces the standing listing in place (same account, no extra rent) once the window has elapsed — and re-arms the 5-minute window. - A listing lapses 30 days after staging unless
--expires-in <seconds>sets a different lifetime. A buy at or before the expiry instant succeeds; after it, onlysell --cancel(reclaiming the rent) remains.
Deactivation interplay
A domain with an in-flight deactivation
timelock cannot be listed — the domain’s state
would change under the buyer between listing and purchase; cancel the
deactivation first. The same guard sits on the purchase itself: domain buy is refused while a deactivation is pending. That matters because
requesting a deactivation while listed is allowed — the delegate’s
safety brake on a rogue domain is never blocked by a sale — so a timelock
started after the listing was staged would otherwise hand a buyer a
domain that flips inactive moments after they paid.
An already-inactive domain, though, is listable: like domain transfer, a sale swaps the authority regardless of the active flag, and
the is_active field is plainly readable on-chain for any buyer doing
their diligence (domain get prints it).
Buying a listed domain
sithbit domain buy <domain> \
[--payer-keypair <keypair>] \
[--keypair <keypair>] \
[--skip-preflight]
Any wallet may buy — the buyer signs, and paying the price doubles as
consent. In one atomic instruction the price leaves the paying wallet,
splits between the current authority and the postoffice (90/10 — see
Economics for the exact
money flow), the domain’s authority field repoints at the buyer, and the
listing closes with its rent refunded to the selling authority.
sithbit domain buy sithbit.net --keypair buyer.json
Bought mail domain 'sithbit.net'; its new authority is maiLtdkxym8CCmo9TwDuXywqd9DXaK3tB6toKFVeBFR
https://explorer.solana.com/tx/…
Exactly as with domain transfer, only the
authority field changes: is_active, rent_payer, and the domain name
are preserved, no mailbox is touched, and a later domain close still refunds the domain
rent to whoever originally funded it. See what a purchase does and does
not convey
for what stays off-chain regardless.
By default the buyer’s own wallet pays the price. A sponsor may pay
instead with --payer-keypair — the sponsor funds the price and the
transaction fee (two signatures) and co-signs alongside the buyer.
Domain listings and completed domain sales appear alongside alias activity
in the gRPC gateway’s marketplace surface
— its listing scans and sale-history walk follow the
domain program, which
owns every listing account. Read the authoritative current authority any
time with domain get.
Cancelling a listing
sithbit domain sell <domain> --cancel [--keypair <keypair>] [--skip-preflight]
The authority cancels an open listing (once its binding window has elapsed), closing the listing account and reclaiming its rent:
sithbit domain sell sithbit.net --cancel
Cancelled the listing on mail domain 'sithbit.net' and reclaimed its rent
https://explorer.solana.com/tx/…
This is also how the rent of an expired listing comes back.
While a listing is open, domain close and domain transfer are refused — cancel
the listing first (closing would strand the listing account’s rent;
transferring would leave a stale holder able to collect the sale
proceeds).
Errors
| Message on stderr | Meaning |
|---|---|
A listing's price must be positive; zero-price hand-offs use the transfer paths | A free hand-over is domain transfer, a delegate operation. |
A listing's expiry must be in the future | --expires-in produced an expiry at or before now. |
The listing is still in its binding window | Cancel or replace attempted within the first 5 minutes. |
No listing is open for this domain | Buy or cancel on a domain with no staged listing. |
The listing has expired | Buy attempted after the expiry instant; the authority can reclaim the rent with sell --cancel. |
A deactivation is already pending for this domain | Listing or buying refused while the deactivation timelock is in flight. |
The domain has an open listing; cancel it before closing, transferring, or deactivating | domain close, domain transfer, or domain deactivate refused while listed; cancel first. |
The listing holder no longer owns the listed name | Buy refused: the listing predates an authority change, so its recorded seller no longer owns the domain — a stale listing never sells the new owner’s domain at the old owner’s price. |
Related
- Domains — what a sale conveys, and what it doesn’t.
- Transfer a domain — the delegate-signed administrative hand-over, and what an authority swap does (and doesn’t) change.
- Economics — the exact money flow: who pays, who collects, and when.
- List an alias for sale — the same marketplace for aliases.
- Deactivate a domain — the timelock that blocks a listing.
Deactivate a domain
See Domains
for why deactivation is timelocked and what a pending deactivation means.
This page covers the sithbit domain deactivate command’s four modes:
request, --finalize, --cancel, and --false (reactivate).
sithbit domain deactivate <domain> \
[--finalize] \
[--cancel] \
[--false] \
[--keypair <delegate keypair>] \
[--skip-preflight]
Arguments
<domain>(required) — the domain to act on.--finalize(optional) — flips the domain inactive once the timelock has elapsed since the request. Run before the timelock has elapsed, it is rejected on-chain. Mutually exclusive with--cancel.--cancel(optional) — aborts an in-flight deactivation request before it finalizes, leaving the domain active as if the request had never happened. Mutually exclusive with--finalize.--false(optional) — reactivates the domain. This is the one instant mode of the command; it is not subject to the timelock.--keypair <delegate keypair>— the delegate’s signing keypair. Required on every invocation: a signer that isn’t the postoffice’s standing delegate is refused with error 66,NotDelegate. Defaults to your configured default keypair when omitted.--skip-preflight(optional) — submits the transaction without a local simulation pass first.
With none of --finalize, --cancel, or --false given, the command
opens a new timelock: it stamps the request with the current chain time
and creates a small transient PDA that tracks it. The domain remains
active — mail keeps flowing — until the request is finalized; its
presence is what marks the domain as having a pending deactivation.
The timelock duration
The wait between a deactivation request and the earliest it can be finalized is a fixed 7 days. There is no flag to shorten or extend it.
Examples
Request deactivation of a domain:
sithbit domain deactivate badactor_domain.com
Finalize a request once at least 7 days have passed:
sithbit domain deactivate --finalize badactor_domain.com
Cancel a pending request, refunding the transient PDA’s rent to the delegate and leaving the domain active:
sithbit domain deactivate --cancel badactor_domain.com
Reactivate a domain immediately, with no waiting period:
sithbit domain deactivate --false recovered_domain.com
Errors
NotDelegate(error 66) — the signing--keypairisn’t the postoffice’s recorded standing delegate. All four modes are delegate-only.- Finalizing before the 7-day deactivation timelock has elapsed is rejected on-chain.
--finalizeand--cancelcannot be combined.
Note: when a domain is retired for good rather than temporarily suspended, see Closing accounts for
domain closeinstead.
Sending mail
For the concept behind sending mail — the split on-chain/off-chain model,
what SendMail records, and how postage gates a send — see
Sending mail. This
page is the full command reference: syntax, flags, preconditions, and
examples.
sithbit mail send <to> \
[--from <address>] \
(--cid <cid> | --path <file>) \
[--keypair <keypair>] \
[--skip-preflight] \
[--bounty <lamports>] [--bounty-window <seconds>] [--reply-to <address>]
Arguments and options
<to>— the recipient, as an alias, wallet address, or keypair path. Required. The recipient must already have a mailbox; the message account is a PDA seeded on that wallet and the next message id.--from <address>(short-f) — theFrom:address, which selects which frombox is charged. Defaults to the sending keypair’s own address.--cid <cid>(short-c) — the IPFS CID of a body that is already pinned somewhere. You supply the known CID directly.--path <file>(short-p) — a local file whose CID is computed from its bytes. Exactly one of--cidor--pathis required, and they are mutually exclusive.--keypair <keypair>(short-k) — the sender’s signing keypair. Defaults to the Solana CLI’s configured wallet (~/.config/solana/cli/config.yml). This wallet pays the stamp and signs the transaction.--skip-preflight(short-s) — submit without the RPC pre-flight simulation. Off by default; see the appendix for when skipping helps and what it costs.--bounty <lamports>— escrows a reply bounty on the message; see Reply bounties for how claiming and refunding work. Omit for a plain send.--bounty-window <seconds>— how long the recipient has to reply and claim; only meaningful with--bounty. See Reply bounties.--reply-to <address>— links this send as a reply to an earlier message (the bountied message’s account address), making it eligible to claim that message’s bounty. See Reply bounties.
Important:
--pathonly computes the local file’s IPFS content identifier (CID) from its bytes — it does not upload or pin the content anywhere. Before the recipient can actually fetch and decrypt the message, the file must be separately pinned to IPFS (for example, through the embedded node in your ownsithbitd, or a sharedsithbit-ipfsd).--cidis for content that’s already pinned somewhere — you supply its known CID directly instead of a local file.
Examples
Send a locally-prepared .eml file, computing its CID on the way:
sithbit mail send jane_doe@sithbit.com \
--from john_doe@sithbit.com \
--path ./message.eml
Send a body you have already pinned, referencing it by CID:
sithbit mail send jane_doe@sithbit.com \
--cid bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi
On success the command prints the assigned message id, the CID, and the
From/To pair, followed by the transaction URL:
Sent message #7 bafybei… <From:john_doe@sithbit.com To:jane_doe@sithbit.com>
https://explorer.solana.com/tx/…
Before you send
Three preconditions must hold — the first two are enforced on-chain, the third by the CLI before it builds the transaction:
- The frombox must exist and hold at least one stamp. The
<from> → <to>frombox is charged one stamp per send (see Add stamps for what a stamp is worth and how to top one up). A missing or empty frombox fails the send; new/unknown fromboxes also inherit a deliberately high default price to price out spam. - The recipient must have a mailbox. The message account seeds on the recipient wallet, and its id is the mailbox’s current message count plus one — so the mailbox must exist for the id to be assigned.
- The recipient must not have opted out of IPFS. If the recipient’s mailbox
has the
no_ipfsopt-out set,mail sendrefuses before touching the CID: this command only content-addresses the body and commits that identifier on chain — it neither uploads nor pins anything (see the note on--pathabove), and no operator store stands behind it, so the body could only become readable by being published to public IPFS. It cannot honor the opt-out the way SMTP delivery does. Deliver through an ordinary mail client (SMTP) instead, and the recipient’s operator stores the copy privately.
What reaches the chain
Neither address string reaches the chain: the instruction carries only
blake3 hash of the normalized --from
address (the frombox seed), and the recipient is identified by the wallet the
message account’s PDA seeds on. The readable From:/To: headers travel
solely inside the sealed body
— see the
threat model
for exactly what an observer can and cannot learn.
Related
- Getting mail — the receive side; fetch and decrypt what was sent here.
- Pinning mail — keep a body retrievable on infrastructure you control.
- Delete mail — settle a delivered message’s postage once it has been read.
- Reply bounties — attach an escrowed reward to a send and claim it on reply.
Getting mail
For the concept behind reading mail — how mail get reads the on-chain
envelope and fetches the sealed body from IPFS — see
Reading mail. This
page is the full command reference: syntax, flags, the on-chain listing
format, decrypting a fetched body, and examples.
sithbit mail get [to_address] \
[--message-id <id>]... \
[--range] \
[--directory <path>] \
[--keypair <path>] \
[--delegated-key-path <path>] \
[--gateway <url>]
Arguments and options
[to_address]— the mailbox to read, as an alias, wallet address, or keypair path. Defaults to your own configured address.--message-id <id>(short-m), repeatable — fetch these specific message ids. Ids past the mailbox’s latest are silently dropped. With none given, only the latest message is fetched.--range(short-r) — treat the given ids (or all known ids, if none are given) as an inclusive span and fetch every message in it. With a single id and--range, the span runs from that id to the latest.--directory <path>(short-d) — write each fetched body to<cid>.emlunder this directory instead of only listing it. The directory is created if it does not exist.--keypair <path>(short-k) — a Solana wallet keypair file used to decrypt sealed bodies (the default path: your own configured wallet).--delegated-key-path <path>(short-x) — a delegated X25519 secret key file, for mail sealed to a delegated key instead of your wallet directly.--gateway <url>(short-g) — the IPFS gateway body-fetches go through. Defaults tohttps://ipfs.sithbit.com/ipfs/; point it at your own node or asithbit-gatewayto avoid a third party.
Note: the decryption flags (
--keypair,--delegated-key-path) require the CLI’srandfeature (enabled by default). Without any key flag, a fetched body is written as-retrieved — still sealed.
The on-chain listing
Every message the command finds is printed from its message account before any body is fetched:
-
Email #7: 9xQ…PDA
Date: 2026-07-08 14:02:11
From-hash: 3n7…
Sender: John…
cid: bafybei…
The chain stores only the blake3 hash of the from
address (From-hash:),
never the string; Sender: is the wallet that paid the postage. The readable
From:/Subject: headers live inside the sealed body and appear only once it
is fetched and decrypted. A message id that has been
deleted prints DELETED (…) in place of its listing and is
skipped, never fatal.
A message whose recipient
opted out of IPFS
carries a local-only marker (b3:…) rather than a fetchable CID. mail get
recognizes the marker and skips the gateway fetch, reporting that the body is
not on public IPFS and lives only in the operator’s store — read it through the
operator’s IMAP/POP service instead.
A message carrying a reply bounty or reply linkage appends one line per set field; a plain send prints exactly the four lines above:
-
Email #8: 4vN…PDA
Date: 2026-07-09 09:15:40
From-hash: 3n7…
Sender: John…
cid: bafybei…
Reply-to-hash: Ckt…
Bounty: 5000000 lamports
Bounty-expires: 2026-07-16 09:15:40
Reply-to-hash: is the blake3 hash of the message account this one replies
to, Bounty: the escrowed lamports, and Bounty-expires: the claim deadline
— see Reply bounties for how they are attached and settled.
Examples
List the latest message in your own mailbox:
sithbit mail get
Fetch and decrypt messages 3 and 5 of another mailbox into a directory, using your wallet key:
sithbit mail get jane_doe@sithbit.com \
--message-id 3 --message-id 5 \
--directory ./inbox \
--keypair ~/.config/solana/id.json
Fetch everything from message 1 onward as a range, through your own gateway:
sithbit mail get \
--message-id 1 --range \
--gateway http://127.0.0.1:8080/ipfs/ \
--directory ./inbox
Decrypting separately
If you already have a fetched .eml file (or one saved without a key flag)
and want to decrypt it as a standalone step, use sithbit mail decrypt:
sithbit mail decrypt ./inbox/bafybei….eml \
--keypair ~/.config/solana/id.json
It takes the same key flags (--keypair, --delegated-key-path), and with
--directory writes the plaintext beside the input name; with none it prints
the decrypted message to stdout.
Related
- Sending mail — the write side that creates these message accounts.
- Pinning mail — re-pin fetched bodies to storage you control so they stay retrievable.
- Mailbox keys — how sealed-to-wallet vs. delegated-key mail is addressed.
Pinning mail
New to the term? What a pin is — asking a computer on the IPFS network to hold a copy of a message body so it stays fetchable — is explained for non-technical readers in What does pinning mean? For the concept behind pinning mail — why a message body’s availability depends on an operator, and how re-pinning to a provider you control fixes that — see Pinning to IPFS. This page is the full command reference: provider choices, message selection, keeping mail pinned continuously, the verify guarantee, and examples.
sithbit mail pin [to_address] \
[--message-id <id>]... \
[--range] \
[--gateway <url>] \
--provider (pinata | filebase | remote) \
[provider credential flags] \
[--watch <seconds>]
to_address defaults to your own address; give an alias, wallet address, or
keypair path to pin someone else’s mailbox instead (you still need the
provider credentials, since the pin lands on your provider).
Re-pinning puts a copy on infrastructure you run. To instead pay the recipient’s operator to keep their pin past the default retention window, see pinning leases — the two compose.
Choosing messages
Message selection mirrors sithbit mail get exactly:
- No
--message-id— pins the latest message only. --message-id <id>(short-m), repeatable — pins those specific ids.--range(short-r) — treats the given ids (or all known ids, if none are given) as an inclusive range and pins every message in the span.
A message that has been deleted or is otherwise unavailable is reported and
skipped, never fatal — the rest of the batch still pins. A message whose
recipient opted out of IPFS
carries a local-only marker (b3:…) instead of a fetchable CID: there is
nothing on a gateway to fetch, hash-verify, or re-pin, so mail pin reports it
and skips it rather than failing on a gateway 404.
Choosing a provider
--provider selects where the verified bytes are stored. Because the pin
lands on infrastructure the recipient controls, the credentials are passed
as flags rather than read from a config file.
remote (the default)
A self-hosted sithbit-ipfsd daemon spoken to
over HTTP. This is the minimal path: with no flags at all, --provider remote targets a loopback daemon and needs no credentials, so mail pin
works out of the box against a node you run yourself.
--remote-endpoint <url>— daemon endpoint (defaulthttp://127.0.0.1:8182).--remote-token <token>— bearer token, only if the daemon requires one.
pinata
Pinata’s hosted pinning API, authenticated with a bearer JWT.
--pinata-jwt <jwt>— Pinata bearer JWT (required).--pinata-gateway <url>— gateway base URL for provider-side fetches (defaulthttps://gateway.pinata.cloud).
filebase
Filebase’s S3-compatible IPFS pinning endpoint.
--filebase-access-key <key>— S3 access key (required).--filebase-secret-key <key>— S3 secret key (required).--filebase-bucket <bucket>— target bucket (required).--filebase-endpoint <url>— S3 endpoint (defaulthttps://s3.filebase.com).
Keeping mail pinned: --watch
By default mail pin runs a single pass and exits. Pass
--watch <seconds> to keep it running: after the first pass it re-enumerates
the mailbox and re-pins it every N seconds, so newly-arrived messages stay
pinned without a manual re-run. Each tick prints a one-line summary of how
many bodies were pinned and how many errored; a transient failure (an RPC
blip, say) is reported and the loop continues.
Examples
Pin your latest message to a loopback sithbit-ipfsd — the zero-config path:
sithbit mail pin
Pin messages 1 through 10 of your own mailbox to Pinata:
sithbit mail pin \
--message-id 1 --message-id 10 --range \
--provider pinata \
--pinata-jwt "$PINATA_JWT"
Continuously mirror an entire mailbox to Filebase, re-checking every five minutes:
sithbit mail pin \
--range \
--provider filebase \
--filebase-access-key "$FILEBASE_KEY" \
--filebase-secret-key "$FILEBASE_SECRET" \
--filebase-bucket my-sithbit-mail \
--watch 300
The verify guarantee
Before anything is pinned, the fetched bytes are hashed and compared against the CID recorded on-chain for that message. Only bytes that hash to the expected CID are pinned; a mismatch is refused. A gateway that serves corrupted or substituted content therefore cannot get bad data into your provider — the worst it can do is fail to serve the body, in which case that message is skipped.
Hashing uses the one UnixFS import
profile SithBit
content-addresses with everywhere: CIDv1, sha2-256, raw leaves. A message
whose on-chain CID is a legacy CIDv0 (Qm…) is refused by name rather
than reported as a mismatch, and is not pinned. This is deliberate: the
retired importer that produced those addresses is gone, so the command
cannot recompute a CIDv0 and therefore cannot tell substituted content from
a body that is merely addressed under the old profile. Reporting a mismatch
would blame the gateway for something it did not do; refusing outright fails
closed either way. Such a message must be re-sent to gain a verifiable
address.
Pinning leases
sithbit mail pin keeps a message body available by
re-pinning it to infrastructure you run. A pinning lease solves the
same problem from the other side: it pays the recipient’s operator to keep
their pin past the default retention window — the
auto-settle sweeper
normally releases a delivered copy’s pin about 30 days after delivery, and
a live lease tells it not to.
A lease is a small on-chain account keyed on the message’s CID and your wallet. It escrows a reclaimable deposit (at least 0.01 SOL) and charges a one-time creation fee (default 0.001 SOL, split between the recipient’s mail operator and the postoffice). There is no expiry and no renewal: the pin stays protected exactly as long as the lease account exists, and closing it returns the deposit and the account rent in full. The fee is the only money spent.
sithbit mail lease create [to_address] [--message-id <id>] [--deposit <lamports>]
sithbit mail lease show [to_address] [--message-id <id> | --cid <cid>] [--holder <address>]
sithbit mail lease close [to_address] [--message-id <id> | --cid <cid>]
to_address defaults to your own address — leasing a message in your own
mailbox — but any wallet may lease any message’s CID: a sender who
wants their attachment to outlive the recipient’s retention window can
lease it too. One lease exists per (CID, holder) pair, so your lease never
collides with anyone else’s on the same message.
Creating a lease
sithbit mail lease create --message-id 3
sithbit mail lease create --message-id 3 --deposit 20000000
--message-id(short-m) defaults to the latest message, matchingmail pin.--deposit(short-d) defaults to the protocol minimum (PIN_LEASE_MIN_DEPOSIT_LAMPORTS, 0.01 SOL). The deposit rides on the lease account and comes back in full at close — the floor keeps a lease from being a near-free way to demand indefinite operator storage.- The command quotes the current creation fee before sending (also
readable anytime with
sithbit postoffice fee pin-lease).
The lease must be created while the message account still exists
on-chain — the program reads the CID off the message itself and verifies
it cryptographically (the lease address derives from the CID’s hash, so a
wrong CID simply cannot create the account). A message whose recipient
opted out of IPFS
carries a b3: local-only marker instead of a CID; there is no pinned
body to retain, so leasing it is refused.
What the lease guarantees — and what it doesn’t
Operators running the stock
sithbitd consult the chain before
releasing a pin at settlement: a copy whose CID carries any live lease
keeps its pin, and an operator that cannot prove the CID unleased (chain
unreachable) keeps the pin too and retries later — the check fails safe.
Settlement itself still happens: the recipient’s stamp value is still
reclaimed on schedule; only the storage outlives it.
Two honest limits:
- A lease is an instruction to cooperating software, not a physical
guarantee — an operator running modified software can drop any pin. For
bodies you must keep regardless of the operator,
mail pinto your own provider remains the trustless option (and composes with a lease). - Closing a lease does not retroactively unpin a copy that already settled under it — the operator reclaims that storage through its own garbage collection, on its own schedule.
Inspecting and closing
sithbit mail lease show --cid QmTestCid
sithbit mail lease close --cid QmTestCid
show prints the holder, creation time, and escrowed deposit. close
drains the account — deposit plus rent — back to the holder’s wallet;
only the holder’s own signature can reach their lease (the account
address derives from the holder, so there is nothing a stranger can even
name). Both accept --message-id while the message record still exists;
after the message settles and its on-chain record deallocates, pass
--cid — the lease outlives the message on purpose.
The fee, for postmasters
The creation fee is a postoffice tunable with the standard shape: read it
with sithbit postoffice fee pin-lease, tune it with sithbit postmaster fee pin-lease <LAMPORTS> (delegate-signed, capped at 10× the default;
zero resets to the default rather than disabling the fee). Like every
purchase-side fee it splits with the recipient’s registered domain
authority at the tuned operator share — see
the economics page.
Deleting mail
For the concept behind deleting mail — why deletion is the settlement trigger for a message’s prepaid postage — see Deleting mail. This page is the full command reference: syntax, flags, who may delete, the exact settlement order, examples, and delete vs. refund.
sithbit mail delete <message_id> \
[to_address] \
[--keypair <keypair>] \
[--skip-preflight]
<message_id>— the numeric id of the message to delete (the same id shown bysithbit mail get). Required.to_address— the recipient whose mailbox the message belongs to. Defaults to your own address; pass an alias, wallet address, or keypair path to name someone else’s mailbox (the message PDA is seeded on the recipient, so this selects which message #<id>is meant).--keypair <keypair>(short-k) — the signing keypair, defaulting to the CLI’s configured wallet.--skip-preflight(short-s) — skip the RPC pre-flight simulation and submit the transaction directly.
Who may delete
Either party to the message may delete it: the on-chain program requires the signer to be either the sender or the recipient of the message, and rejects anyone else. In normal operation it is the recipient (or the MX server acting on their behalf) who deletes, because deletion is what pays the postage into the recipient’s wallet.
Example
sithbit mail delete 3
deletes message #3 from your own mailbox and prints the resolved sender plus the settling transaction:
Found sender address 7Xh…q4M
Deleted message #3 for 9aF…2kD
https://explorer.solana.com/tx/5Jm…8sT
To delete a message in someone else’s mailbox (for instance an operator settling on a user’s behalf), name the recipient:
sithbit mail delete 3 jane_doe@sithbit.com --keypair operator.json
What happens on-chain
The command builds a DeleteMail instruction carrying nine accounts: the
signing payer, the message’s sender and recipient, the system program, the
message account itself, and the recipient’s settlement accounts (their
mailbox, its domain, that domain’s authority, and the postoffice). The
program then drains the message account’s whole balance and closes it, in
this order:
- Sender refund. The sender recovers the message account’s rent plus
the fee for their original
SendMailsignature (both were prefunded by the stamp surcharge the sender paid at purchase). - Deleter refund. The signer submitting this delete recovers this transaction’s signature fee — also prefunded — so settling costs the deleter nothing net.
- Operator share. If the recipient’s mailbox names an active domain, that domain’s authority (the MX operator) collects 10% of the remaining postage. The share lapses to zero when the chain legitimately doesn’t resolve — no mailbox, no domain set, or the domain is closed or inactive — but a named, active domain must be presented with the correct authority account or the instruction is rejected, so a deleter can’t cheat the operator out of its cut with filler accounts.
- Recipient postage. The recipient collects everything left, rounded down to the protocol’s settlement quantum (1000 lamports). This is the actual pay-for-attention the postage model exists to deliver.
- Rounding residue. The sub-quantum remainder accrues to the postoffice (the postmaster’s operating revenue at scale).
Once the balance reaches zero the message account is reaped — the id is
gone and a later get for it returns nothing. See
Economics for the full postage/settlement model and the
exact split.
Delete vs. refund. Deletion settles postage to the recipient. There is a sibling command,
sithbit mail refund <message_id>, for the opposite outcome: the recipient (and only the recipient) refuses the mail and returns the postage to the sender instead of keeping it. Refund reuses the same nine-account shape and likewise reaps the message account and refunds the prepaid fees, but pays no postage to the recipient and no operator share. Usedeleteto accept and settle; userefundto reject.
Reply bounties
For the concept behind reply bounties — why they exist and how the escrow rides the message account — see Reply bounties. This page is the full command reference: attaching a bounty, linking a reply, claiming, and reclaiming an unanswered bounty.
sithbit mail send <to> … --bounty <lamports> [--bounty-window <seconds>]
sithbit mail send <to> … --reply-to <message account address>
sithbit mail claim-bounty <message_id> <reply_message_id> <SENDER_ADDRESS>
sithbit mail refund-bounty <message_id> <RECIPIENT_ADDRESS>
End users normally never type these: a mail client sending through a
sithbitd daemon gets the reply linkage for free
(see Replying to a bountied message).
The commands are the low-level primitives, exactly as
mail send is for delivery.
Attaching a bounty
Two extra flags on sithbit mail send:
--bounty <lamports>— the amount to escrow on the message account.0(the default) is a plain send, byte-identical to a send without the flag.--bounty-window <seconds>— how long the recipient has to reply and claim, counted from now. Defaults to 7 days (604 800 seconds); only meaningful together with--bounty. The window must end in the future — a bounty that would be born expired is rejected on-chain.
sithbit mail send jane_doe@sithbit.com \
--path ./question.eml \
--bounty 5000007
The sender fronts the bounty at send time, on top of the message rent: the
message account is created holding rent + postage + bounty. Note the send’s
output — Sent message #7 … — because that message id is what a later
refund-bounty needs.
The trustless webmail compose
authors the same two fields client-side — a SOL amount and a claim
window in days behind its Attach a reply bounty control, with the
same 7-day default — and its reader’s Reply on-chain action carries
the --reply-to linkage automatically. Bounty authoring is a
direct-signed surface: the CLI and the trustless compose write it;
sends composed through a mail server stay bounty-less.
Replying to a bountied message
To make a reply claimable, the reply’s send must carry an on-chain link back to the bountied message:
sithbit mail send john_doe@sithbit.com \
--path ./answer.eml \
--reply-to <message account address>
--reply-totakes the bountied message’s account address — the base58 addresssithbit mail getprints next to each id (Email #7: <address>), not the numeric id.- The linkage is privacy-preserving: what reaches the chain is only the blake3 hash of that account address — no sender or recipient address appears, consistent with what a plain send reveals.
--reply-toworks without a bounty too, if you want the threading link on-chain for its own sake.
Through a mail server, this is automatic. When the reply travels
through a sithbitd daemon (the normal path for anyone using an ordinary
mail client), the spooler resolves the reply’s In-Reply-To header to the
original message’s chain coordinates and sets the linkage itself — just
reply in your mail client and the claim evidence takes care of itself.
Claiming the bounty
Once the reply is on-chain, the recipient of the bountied message collects:
sithbit mail claim-bounty <message_id> <reply_message_id> <SENDER_ADDRESS> \
[--keypair <keypair>] \
[--skip-preflight]
<message_id>— the bountied message’s id in your own mailbox.<reply_message_id>— your reply’s id in the original sender’s mailbox (a reply is itself a message, delivered to them).<SENDER_ADDRESS>— the original sender’s wallet address (or keypair path); their mailbox is where the reply lives.--keypair(short-k) — the claimant’s signing keypair; only the bountied message’s recipient may claim.
sithbit mail claim-bounty 7 12 7Xh…q4M
Claimed the bounty on message #7 with reply #12 from 7Xh…q4M
https://explorer.solana.com/tx/…
The claim pays out 90/10: for a 5 000 007-lamport bounty, 4 500 007 lamports go to you, and the 500 000 operator share goes to your domain’s authority (the operator running your mail host) — or to the postoffice, if your mailbox doesn’t name an active domain — see Economics for the exact split rules. A claim is legal through the exact expiry instant; at the deadline itself, the claim still wins.
Reclaiming an unanswered bounty
If the window closes with no claim, the sender takes the bounty back:
sithbit mail refund-bounty <message_id> <RECIPIENT_ADDRESS> \
[--keypair <keypair>] \
[--skip-preflight]
<message_id>— the bountied message’s id in the recipient’s mailbox (the idmail sendprinted).<RECIPIENT_ADDRESS>— the recipient’s wallet address (or keypair path), which the message account is derived from.--keypair(short-k) — the sender’s signing keypair; only the original sender may reclaim.
sithbit mail refund-bounty 7 9aF…2kD
Refunded the expired bounty on message #7 to 9aF…2kD
https://explorer.solana.com/tx/…
The refund is strictly after expiry — one second past the deadline, not at it — and returns the bounty in full: unlike a claim, a refund pays no share to anyone. The message itself survives; only the bounty moves.
What deleting the message does
Deleting a message with a still-riding bounty returns the bounty to the sender, folded into the sender-refund leg of the normal settlement. No reply, no payout: an unclaimed bounty is the sender’s money and never converts into recipient postage. This holds for the auto-settle worker’s deletes too — a recipient who ignores a bountied message until the settle window sweeps it earns the postage but not the bounty.
What you can and can’t trust
- The escrow is the message account. The bounty sits on the same on-chain account as the message’s rent and postage from the moment of the send — there is no third party holding it and no separate account to audit.
- Only a real reply claims. The claim must present a message that (a) sits in the original sender’s mailbox, (b) was sent by the claimant, and (c) names the bountied message via the on-chain reply linkage. A routine unrelated message from the recipient satisfies (a) and (b) but not (c) — it cannot claim.
- One payout, ever. Claiming zeroes the bounty on the message account, so the same reply — or any other — cannot claim twice, and a claimed bounty cannot also be refunded (and vice versa).
- The deadline is exact. Claims win up to and including
expires_at; refunds open strictly after it. The two windows cannot overlap.
Related
- Sending mail — the send primitive these flags extend.
- Getting mail — where to read a message’s id and account address.
- Deleting mail — settlement, and the delete path a riding bounty takes.
- Economics — the exact money flow: who pays, who collects, and when.
The sithbit campaign CLI (participant beacons & campaigns)
The sithbit campaign command tree is the chain-direct authoring surface for
the participant-pool marketplace: a wallet
publishes an on-chain participant beacon advertising the tags it is willing
to be reached on, and a campaign wallet discovers those participants by tag,
prices a bountied send to the matched set, and sends to the prepaid, top-up
and first-contact matches the quote keeps — the unmailable, IPFS opt-out and
over---max-postage classes are
quoted but never sent to.
Two roles use this tree:
- A participant runs
create,updateandcloseto opt in, edit and opt out. Opting in is setting a mailbox price: the advertised participation price is the wallet’s mailboxdefault_postage, so a beacon can only be published for a wallet that already has a mailbox. - A campaign wallet runs
search,quoteandsendto reach the pool.sendis direct-signed only — the funded campaign wallet signs every delivery locally; there is no mail-server path for a campaign.
sithbit campaign create [--tag <TAG>]… [--detail-cid <CID> | --profile-file <PATH>] [--keypair <KEY>]
sithbit campaign update [--tag <TAG>]… [--detail-cid <CID> | --profile-file <PATH>] [--keypair <KEY>]
sithbit campaign close [--keypair <KEY>]
sithbit campaign search --tag <TAG>… [--limit <N>]
sithbit campaign quote --tag <TAG>… --bounty <LAMPORTS> [--sender <ADDRESS>] \
[--max-postage <LAMPORTS>] [--limit <N>]
sithbit campaign send --tag <TAG>… --subject <TEXT> --bounty <LAMPORTS> \
[--body-file <PATH>] [--max-postage <LAMPORTS>] [--limit <N>] \
[--yes] [--keypair <KEY>]
Tags
Every beacon carries a fixed-vocabulary tag bitmap. A --tag is one name from
that vocabulary — an interest.*, skill.*, age.*, region.*,
language.* or role.* name, e.g. interest.technology, skill.software,
age.25_34, region.apac, language.es, role.founder. Regions cover both
the three broad bands (region.americas, region.emea, region.apac) and
finer subregions (region.latin_america, region.southeast_asia, …);
languages are ISO 639-1 codes (language.en, language.ja); roles are coarse
occupation stages (role.student, role.creator). Names are
case-insensitive; an unknown name is rejected with the full valid list, so the
quickest way to print every tag is to pass a bogus one. --tag
is repeatable, and search/quote/send treat multiple tags as a logical AND —
a beacon must carry every requested tag to match. The vocabulary is derived
mechanically from the mail_model TAG_* constants
(TAG_INTEREST_TECHNOLOGY → interest.technology); see the
participant-marketplace note
for the bitmap layout.
sithbit campaign create
Publishes the signing wallet’s participant beacon, opting it in on the given
tags. The wallet’s mailbox must already exist — the advertised price is its
default_postage.
sithbit campaign create \
--tag interest.technology \
--tag skill.software \
--tag region.apac
Flags:
--tag <TAG>— a tag to advertise on (repeatable).--detail-cid <CID>— attach an existing IPFS CID as the beacon’s off-chain detail profile. Mutually exclusive with--profile-file.--profile-file <PATH>— seal a local profile file under a fresh key, pin it to IPFS, and attach the resulting CID. Needs the CLI’srandfeature. See--ipfs-endpoint/--ipfs-tokenbelow.--ipfs-endpoint <URL>— thesithbit-ipfsdendpoint a--profile-fileis pinned to (defaulthttp://127.0.0.1:8182).--ipfs-token <TOKEN>— bearer token for that daemon, when it requires one.--keypair <KEY_PATH>(short-k) — the wallet publishing the beacon (defaults to the Solana CLI keypair).--skip-preflight(short-s) — skip the transaction pre-flight simulation.
sithbit campaign update
Rewrites the beacon wholesale: the given tags and detail CID replace its
current contents in full — this is a replace, not a merge, so omitting --tag
clears all tags and omitting a CID clears the attached profile.
sithbit campaign update --tag interest.finance --tag region.emea
Takes the same flags as create.
sithbit campaign close
Closes the signing wallet’s beacon — the opt-out. It drops out of every tag search and its rent is refunded to the wallet.
sithbit campaign close
Flags: --keypair <KEY_PATH> (short -k), --skip-preflight (short -s).
sithbit campaign search
Runs a trustless getProgramAccounts scan of the mail program for beacons
carrying every requested tag, and lists each match’s sendable wallet, its tag
names, and whether it advertises a detail profile. The wallet is recovered from
the beacon’s on-chain owner field (a beacon PDA can’t be inverted to it), so
the results are directly mailable.
sithbit campaign search --tag interest.technology --tag skill.software
2 matching participant beacon(s):
mAiLiLdgjgGdWoCZkpW3cj7JLAC56qb4NErFyQFWNJg [interest.technology, skill.software] detail: yes
CaMUbt4zNKeZb2AUaa4icv33CQEvBeJ8EsWKFsDvKWrT [interest.technology, skill.software, region.apac] detail: no
Flags:
--tag <TAG>— a tag a beacon must carry to match (repeatable, required, logical AND).--limit <N>— cap the number of matches returned (default 100). The newest beacons are kept first.
sithbit campaign quote
Prices a campaign against live chain state before any funds are committed.
It selects recipients by the same trustless scan search runs, fetches every
matched wallet’s real accounts in one batched read, and classifies each
recipient by what the campaign will actually pay for them — no flat
per-recipient assumption:
- prepaid — the sender’s frombox toward this recipient has stamps standing: the send just burns one, so the recipient costs only the send leg (message account rent + the escrowed bounty + one signature fee).
- top-up — the frombox exists but is out of stamps: one stamp is prepaid
at its stored
required_postage— the price the recipient may have lowered for this sender — plus the send leg. - first contact — no frombox yet: the prepay prices at the
reputation-scaled first-contact rule
(the recipient’s mailbox
default_postage, stepped down by the sender’s cumulative stamp-spend tier) and adds a new frombox’s rent, plus the send leg. - unmailable — the wallet has no mailbox, so nothing can be delivered: reported, and excluded from the total.
- ipfs opt-out — the recipient’s mailbox carries the
no_ipfsopt-out. A campaign composes its message in the clear and commits the client-computed content address on chain, with no operator store behind the body the way SMTP delivery has, so the send would publish exactly what the owner opted out of: reported, and excluded from the total — whatever the frombox says, including a recipient with stamps already standing. - over max-postage — the postage leg exceeds
--max-postage: reported, and excluded from the total.
Every prepay leg also carries the per-stamp settlement surcharge, the hybrid stamp-purchase protocol fee on the recipient’s postage, and its own signature fee — an unprepaid recipient pays two signature fees in total, one on the prepay transaction and one on the send. Two more things the quote handles for you: first-contact escrow is folded into the sender’s simulated spend in selection order, so a batch that crosses a reputation spend tier mid-campaign quotes later recipients at the discounted rate; and when the sender has no sender-reputation account yet (this would be their first first-contact prepay), its one-time rent is added once, campaign-wide — never per recipient.
sithbit campaign quote --tag interest.gaming --bounty 1000000 \
--sender ~/.config/solana/id.json
Campaign quote for 5 recipient(s):
mAiLiLdgjgGdWoCZkpW3cj7JLAC56qb4NErFyQFWNJg prepaid (2 stamp(s) standing)
CaMUbt4zNKeZb2AUaa4icv33CQEvBeJ8EsWKFsDvKWrT top-up at 250000 lamports postage
maiLtdkxym8CCmo9TwDuXywqd9DXaK3tB6toKFVeBFR first contact at 1000000000 lamports postage
7Np41oeYqPefeNQEHSv1UDhYrehxin3NStELsSKCT4K2 unmailable (no mailbox) — excluded
7cVfgArCheMR6Cs29HXTFrpMg2XwYFhrCtdz3EgKPfHM opted out of IPFS body storage — excluded
prepaid 1 recipient(s) 3169560 lamports
top-up 1 recipient(s) 3534560 lamports
first contact 1 recipient(s) 1014158960 lamports
unmailable 1 recipient(s) 0 lamports
ipfs opt-out 1 recipient(s) 0 lamports
over max-postage 0 recipient(s) 0 lamports
Per send (prepaid, top-up and first-contact recipients):
message rent 2164560 lamports
bounty 1000000 lamports
signature fee 5000 lamports
Per prepay (top-up and first-contact recipients), plus postage:
stamp surcharge 10000 lamports
signature fee 5000 lamports
frombox rent 974400 lamports (first contact only)
+ the stamp-purchase protocol fee on each recipient's postage
sender reputation account rent (first campaign): 1169280 lamports, charged once
total 1022032360 lamports (1.02203236 SOL)
Flags:
--tag <TAG>— recipient tag filter (repeatable, required, logical AND).--bounty <LAMPORTS>— the per-recipient bounty being offered.--sender <ADDRESS>— the sending wallet, as an address or a keypair path (only the public key is read; the quote never signs anything). With it the quote prices each recipient against that wallet’s real fromboxes and recorded reputation spend. Without it no frombox is read, so no recipient can classify as prepaid or top-up: each one whose mailbox exists and accepts IPFS bodies prices as a first contact against zero recorded spend — the simulated-spend fold described above still applies, and--max-postagestill moves an over-cap one to the excluded class. The quote prints a note saying so, and the note is careful about what that zero means: it is an assumption, not an upper bound. Pricing is not monotone in recorded spend, because the fold and the tier ladder interact — a higher recorded spend earns a cheaper first contact, which folds a smaller escrow into the simulated spend, which can leave a later recipient below a tier boundary the zero-spend run had already crossed. So a sender with real recorded spend can be quoted more than the senderless quote shows, both for individual recipients and for the campaign total. Quote with--senderbefore committing funds.--max-postage <LAMPORTS>— exclude recipients whose postage leg exceeds this many lamports. This is targeting, not slippage protection: it trims too-expensive recipients out of the campaign, while the purchase-time slippage guard is--max-priceat the prepay step.--limit <N>— cap the quote at this many recipients (default: the full match set).
sithbit campaign send
Sends one bountied message to each prepaid, top-up and first-contact
recipient matching the tag filter. It selects recipients by the same
trustless scan, prints the
quote — the same real, per-recipient quote, priced
with the sending keypair’s wallet so its fromboxes and reputation are the
actual ones — gates on a confirmation (unless --yes), then delivers the
batch — one direct-signed SendMail per recipient, each
escrowing the per-recipient reply bounty
with the campaign message as the parent a recipient replies to. The loop
continues past a per-recipient failure so one bad address can’t strand the
rest of a paid campaign, and prints a sent/failed summary at the end.
Three quote classes are excluded from the batch: unmailable wallets (no
mailbox — the send could only fail), recipients whose mailbox carries the
no_ipfs opt-out
(a campaign publishes a content address for a body no operator holds
privately, so the exclusion stands even with a stamp prepaid), and recipients
priced over --max-postage. And because SendMail burns a stamp but never
buys one, the quote is followed by a warning when kept recipients have no
prepaid stamp standing:
WARNING: 2 recipient(s) have no prepaid stamp — those sends will fail until `sithbit frombox stamp` prepays them.
Prepay those fromboxes with sithbit frombox stamp
first, then re-run the send. Postage is locked in at that prepay step — the
purchase’s --max-price slippage guard is the protection against a price
moving between quote and buy. By the time SendMail runs, the stamp is
already bought, so the send itself has nothing to slippage-guard.
sithbit campaign send \
--tag interest.gaming \
--subject 'Paid gaming survey' \
--bounty 1000000 \
--body-file ./invite.txt \
--yes
Campaign quote for 5 recipient(s):
…
sent to mAiLiLdgjgGdWoCZkpW3cj7JLAC56qb4NErFyQFWNJg https://explorer.solana.com/tx/…
sent to CaMUbt4zNKeZb2AUaa4icv33CQEvBeJ8EsWKFsDvKWrT https://explorer.solana.com/tx/…
sent to maiLtdkxym8CCmo9TwDuXywqd9DXaK3tB6toKFVeBFR https://explorer.solana.com/tx/…
Campaign complete: 3 sent, 0 failed.
Flags:
--tag <TAG>— recipient tag filter (repeatable, required, logical AND).--subject <TEXT>— the message subject line.--body-file <PATH>— the message body; read from stdin when omitted.--bounty <LAMPORTS>— the per-recipient bounty escrowed for each recipient to claim. The claim window is the standard 7-day bounty default.--limit <N>— cap the send at this many recipients (default: the full match set).--max-postage <LAMPORTS>— exclude recipients whose postage leg exceeds this many lamports from the quote and the batch (seequote— targeting, not slippage protection).--yes(short-y) — skip the quote-then-confirm prompt and send immediately.--keypair <KEY_PATH>(short-k) — the sending (and funding) wallet.--skip-preflight(short-s) — skip the transaction pre-flight simulation.
Recipients collect a bounty by replying before the window closes; anything unclaimed can be refunded to the campaign wallet after it expires.
Revenue snapshot: sithbit earnings
sithbit earnings \
[--owner <PUBKEY>] \
[--from <ADDRESS>]...
A read-only holdings and revenue snapshot for one wallet — the bookend to
sithbit setup: setup gets a brand-new user configured,
earnings shows an existing recipient what their wallet holds and has taken
in, and it only ever reads. It prints, in order:
- Wallet balance — the wallet’s native SOL balance.
- Mailbox asking price — this wallet’s default postage (the price a new sender pays), or a note that the wallet has no mailbox yet.
- Postoffice balance (delegate) — the singleton postoffice balance. This line appears only when the queried wallet is the postoffice’s standing delegate, and is omitted entirely for everyone else.
- Prepaid stamps — for each sender named with
--from, the prepaid stamps that sender holds against this wallet plus the lamports held in that frombox, ornone prepaidwhen no frombox exists for that pair.
Arguments
--owner <PUBKEY>— the wallet to report on. Accepts a base58 pubkey or a keypair-file path; defaults to your configured signing keypair.--from <ADDRESS>— a sender address to report prepaid stamps for. Repeatable; each occurrence adds onePrepaid from …line.
--from is the only per-sender lookup — by design
earnings reads only the handful of accounts your wallet’s PDAs point at
directly: its balance, its mailbox, the singleton postoffice, and the specific
fromboxes you name. It deliberately does no chain-wide scan, so there is no
wallet-wide enumeration of the senders who have prepaid you. To see a sender’s
prepaid balance you must name that sender explicitly with --from — one flag
per sender. Passing no --from prints a one-line hint instead of a per-sender
list.
USD figures are best-effort
Every SOL figure is annotated with an approximate USD value (e.g.
1.5 SOL (~$210.00)) when a live SOL→USD rate is available. The rate fetch is
fail-soft: any hiccup — no network, a slow or erroring price API — simply
drops the dollar annotations and prints the bare SOL amounts. A pricing outage
never blocks or fails the report.
The same best-effort SOL→USD annotation rides
sithbit frombox get automatically — the
asking price and held balance gain dollar tails whenever a rate is
available. On sithbit mailbox get it is
opt-in: pass --usd to annotate the asking price; without the flag it
prints bare lamports exactly as before. The fetch is fail-soft the same way
— a pricing outage just drops the dollar tails.
Example
A recipient checking their own snapshot and one known sender’s prepaid stamps, with a live rate available:
$ sithbit earnings --from alice@example.com
Earnings summary for 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q0
Wallet balance: 1.5 SOL (~$210.00)
Mailbox asking price: 0.5 SOL (~$70.00) per email
Prepaid from alice@example.com: 7 stamps (1 SOL (~$140.00) held)
The same wallet queried without --from, and with no rate available (note the
missing dollar tails and the per-sender hint):
$ sithbit earnings
Earnings summary for 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q0
Wallet balance: 1.5 SOL
Mailbox asking price: 0.5 SOL per email
Prepaid stamps: pass --from <address> to show a named sender's balance
A sender you name who has never prepaid you reports none prepaid rather than
erroring — asking about an unknown sender is expected:
$ sithbit earnings --from stranger@example.com
...
Prepaid from stranger@example.com: none prepaid
For the wider picture of what your inbox earned and why — who paid the postage, what share reached you, and where the rest went — see Economics.
Closing accounts
Every SithBit account is a Solana account, and creating any Solana account requires a one-time SOL deposit — rent-exemption — that scales with how many bytes the account stores. It isn’t a fee: it’s a refundable deposit that sits in the account for as long as it exists, and it has nothing to do with any price or value the account might represent (a mailbox’s rent, for example, is the same regardless of how much postage it charges). Closing an account you no longer need reclaims that rent back to you. See Solana’s account model docs for the full mechanics of how the minimum balance is calculated.
| Command | Signer | What’s reclaimed |
|---|---|---|
mailbox close | Mailbox owner | The mailbox account’s rent — only at --finalize, 7 days after the request (see below) |
mailbox key close | Mailbox owner | The delegated encryption key account’s rent |
frombox close | Recipient (mailbox owner) | The frombox’s rent plus its remaining stamp value |
frombox reclaim | Wallet-literal sender | The sender’s own unspent prepaid postage — not the rent, which keeps the frombox alive |
alias close | Current alias holder | The alias account’s rent (refused while a transfer offer is pending — cancel the offer first) |
domain close | Delegate | The domain account’s rent, refunded to whoever paid it originally |
One row in that table is not a close at all: frombox reclaim leaves the
account standing. It exists because a frombox holds two different people’s
money — the rent, deposited by whoever opened it, and the prepaid postage,
deposited by the sender. frombox close hands the whole balance to the
recipient; frombox reclaim lets the sender take back only the postage it
prepaid and never spent, leaving the account alive on its rent so the
recipient keeps the price it set. See
Reclaiming unspent stamps.
Closing a mailbox is timelocked
Every command in the table above reclaims its rent in one transaction —
except mailbox close, which is a two-step, 7-day flow:
sithbit mailbox closerequests the close. It stamps a small transient PDA with the chain time and starts the clock. The mailbox stays open and keeps receiving mail, and no rent comes back yet — the request in fact costs the owner the transient account’s own rent until the flow resolves.sithbit mailbox close --finalize, run once at least 7 days have passed, actually closes the mailbox and refunds both rents — the mailbox’s and the transient account’s — to the owner.sithbit mailbox close --cancel, run any time before finalize, aborts the request: the transient account’s rent comes back and the mailbox is untouched.
The delay exists because an instant refund made discarding a burned
identity free, which is a spammer’s economics, not an honest owner’s. The
owner pays in time on a rare action, never in money — the rent is returned
in full. The retired one-step CloseMailbox instruction is refused
on-chain with error 94, InstantCloseDisabled. See
Close a mailbox.
mailbox key close is not timelocked and stays in the table above as
an instant close: it revokes a compromised delegated encryption key, and a
week-long window there would keep MX servers sealing to the compromised
key — protecting the attacker rather than the owner.
Note: closing drains an account’s lamports to zero, at which point Solana’s runtime deallocates it — so a closed account’s address can generally be recreated later with the matching
createcommand. The one caveat: recreating a mailbox restarts its message-id counter at zero, so any of its old, still-open message accounts (which persist independently of the mailbox itself, settled separately bymail delete) can collide with new sends until they’re cleared out. This is an availability nuisance, not a loss of funds — closing a mailbox that still has undelivered mail is worth avoiding rather than treating as harmless.
Running a mail server
Running a SithBit server is the third profitable role in the protocol: your domain’s authority wallet collects 10% of the postage on every message settled under it (plus a share of stamp fees and reply bounties — see Economics), authorization is a DNS record and 0.01 SOL once with no gatekeeper to ask, and mail between SithBit mailboxes settles on-chain and is stored on IPFS, so your outbound to other SithBit users never touches an IP blocklist. What used to be a hosting cost becomes a revenue line.
A SithBit deployment is at most six services, all built from this workspace:
| Service | Binary | Role | Needed when |
|---|---|---|---|
sithbitd | mail-spooler | SMTP MX + submission, IMAP, POP, the spooler workers — and optionally the embedded IPFS node — in one process | always (the embedded IPFS node only with [ipfs] kind = "embedded" — a fleet points at sithbit-ipfsd or a pinning service instead) |
account-api | account-api | wallet-challenge login → JWT; mail passwords, timezone, DND schedules | users manage their accounts |
mail-grpc | mail-grpc | gRPC gateway to the Solana programs (postage checks, SendMail, aliases) | mail should reach the chain |
domain-sithbit | domain-sithbit | DNS-based domain verification and on-chain domain authorization | you operate the domain registry |
sithbit-ipfsd | sithbit-ipfsd | the self-hosted IPFS node as a standalone daemon: HTTP pin API + optional public swarm | a fleet shares one node via [ipfs] kind = "remote" (a single sithbitd can embed the node instead) |
sithbit-gateway | sithbit-gateway | read-only IPFS HTTP path gateway over the same block/pin bucket (deserialized, raw, CAR) | pinned mail blobs should be fetchable/verifiable over plain HTTP |
Everything else is an external dependency you point config at: a
Solana RPC endpoint, TLS certificates,
and DNS records (see
DNS setup). Mail bodies pin to IPFS through the embedded
node, the shared sithbit-ipfsd, or a third-party service
(Filebase/Pinata) — the [ipfs] reference
covers the selection.
The sections below climb from a zero-config dev run to the production compose topology. Each rung is runnable on its own; pick the highest one you need.
Note: if you notice
smtp-server,imap-server, orpop-serverbinaries elsewhere in the workspace, see Appendix: Development and pilot servers — they’re dev/pilot artifacts, not part of this deployment.
Your stack, not a vendor’s
SithBit is a protocol, and the server is built to be run anywhere — a laptop, a single VM, or a cloud fleet — with no tie to any one platform, cloud, or storage product. That portability isn’t a promise bolted on; it’s how the code is structured. Every place the server touches infrastructure sits behind a trait, with more than one backend already implemented, so changing where your data lives is a config edit, not a rewrite:
- Storage is one storage kernel behind swappable
[store]backends:sqlite(a single file — the zero-config default),postgres,aws(DynamoDB + SQS),azure,turso, orcloudflare. Start on SQLite on your laptop and move to a cloud store later without touching application code. (Google Cloud needs no backend of its own —postgresagainst Cloud SQL is the GCP shape; see Hosting on Google Cloud.) - Mail bodies pin through a
blob store that is either
localdisk or a cloud object store — S3-compatible (AWS S3, Google Cloud Storage via its S3-interop endpoint, MinIO, and the like) or Azure — and reach IPFS through the embedded node, a sharedsithbit-ipfsd, or a third-party pinning service (Filebase, Pinata) — your choice, same trait. - Secrets (signing keypairs) load from a plain file or a cloud secret manager — Azure Key Vault, AWS Secrets Manager, or Google Secret Manager — through one key source, so no key material has to live in your config. (Cloudflare is the deliberate omission: its secrets products are write-only over the API, so nothing can fetch a value back out.)
- The chain is any Solana RPC endpoint — your own validator, a provider, or a public cluster.
Infrastructure-as-code ships for all three major clouds — Terraform for AWS
and Google Cloud, Bicep for Azure, under iac/ — because the point is that none
of them is required.
Nothing here reaches for a proprietary API you can’t swap out; the backends are
peers behind a trait, and adding another is a matter of implementing that trait.
See Scaling out for how the same seams take a single-process
dev stack to a horizontally-scaled fleet.
Bare binaries (zero config)
Every binary runs with no config file at all and lands on loopback dev ports — see the configuration reference for the defaults and how to override them:
cargo run -p mail-spooler --bin sithbitd # SMTP :2525, IMAP :1430, POP :1100
cargo run -p account-api # HTTP :8180
cargo run -p domain-sithbit # HTTP :8181
cargo run -p mail-grpc # gRPC :50051 (reads .env)
cargo run -p ipfs-daemon # HTTP :8182 (pin API)
cargo run -p ipfs-gateway # HTTP :8183 (read-only gateway)
Without a [grpc] + [ipfs] section, sithbitd disables the
chain pipeline: mail is accepted, delivered to mailboxes, and readable
over IMAP/POP, but delivered copies stay in chain state received.
That is the expected dev shape, not an error.
For long-running processes, build with the max-performance profile
instead of cargo run’s dev profile:
cargo build --profile server -p mail-spooler -p account-api -p mail-grpc -p domain-sithbit -p ipfs-daemon -p ipfs-gateway
ls target/server/ # sithbitd, account-api, mail-grpc, domain-sithbit, sithbit-ipfsd, sithbit-gateway
Slim-build features
Two feature families let a binary compile out the cloud SDKs it never
uses. The first — and largest — is the storage backends: cargo features
of mail_store, forwarded under
the same names by every store-consuming binary (mail-spooler,
account-api, ipfs-daemon, ipfs-gateway, mail-console;
sithbit-migrate always builds them all — it exists to move data
between backends):
- Default =
sqlite— a plaincargo buildcompiles only the SQLite backend: the zero-config dev shape, with none of the cloud SDKs in the dependency tree. --features <crate>/all— every backend (SQLite, Postgres, DynamoDB+SQS, Azure Tables/Queues, Turso/libSQL, Cloudflare) plus the S3 blob store. This is what the container images build: one image carries all backends, and the compose files pick one at runtime via[store] kind— the aws/azure/split stacks all run the same image.- Individual features (
--features postgres,aws,azure,turso,cloudflare,s3-blobs) exist for slimmer custom builds.
Selection stays a runtime concern: every [store] config parses in
every build, and pointing a binary at a backend it wasn’t compiled with
fails at startup with a purposeful error naming the fix:
store backend `postgres` is not compiled into this binary — rebuild with `--features postgres`
The other cloud SDK trees are per-binary features on the same pattern.
Every binary forwards, under the same names, the credential-sealing key
source’s cloud secret managers — akv (Azure Key Vault), asm
(AWS Secrets Manager), gsm (Google Secret Manager) — and, for the
TOML-config binaries, the cloud app-config sources — awsconf (AWS
AppConfig), azconf (Azure App Configuration). Defaults keep every
cloud on, so a plain cargo build compiles exactly what it always did;
slimming is strictly opt-in via --no-default-features plus only the
features you need:
# AWS-only sithbitd: SQLite store, ASM key source, AWS AppConfig —
# no Azure or Google SDK code in the binary
cargo build --profile server -p mail-spooler --no-default-features --features sqlite,asm,awsconf
# Azure-only gRPC gateway
cargo build --profile server -p mail-grpc --no-default-features --features akv,azconf
The runtime contract matches the store backends: a config that names a
compiled-out cloud still parses in every build, and loading it fails
at startup with a purposeful error (KeySourceError::NotCompiled, or
the app-config loader’s NotCompiled) naming the cargo feature to
rebuild with.
What the split buys, measured 2026-07-10:
| Measurement | default (sqlite) | --features all |
|---|---|---|
mail-store dep-tree crates (484 before the split) | 268 | 485 |
mail-spooler dep-tree crates (812 before the split) | 655 | essentially the pre-split tree |
sithbitd binary, --profile server (stripped by the profile) | 24.1 MB | 43.2 MB |
sithbitd server-profile rebuild, warm dep cache¹ | 2 m 59 s | 5 m 55 s |
¹ Wall time of cargo build --profile server -p mail-spooler after
touching mail_store/src/lib.rs — i.e. a rebuild of mail_store and
its dependents over an already-warm dependency cache, not a
from-scratch build (from-scratch numbers weren’t taken; a cold image
build is dominated by the cargo-chef cook layer regardless).
Dep-tree counts were measured 2026-07-10 with
cargo tree -p <crate> -e normal --prefix none | sort -u | wc -l
(unique lines, duplicate-marked (*) entries deduplicated by the sort).
Container images
One multi-stage Dockerfile (docker/Dockerfile) builds all six
services as separate targets:
docker build -f docker/Dockerfile --target sithbitd -t sithbit/sithbitd .
docker build -f docker/Dockerfile --target account-api -t sithbit/account-api .
docker build -f docker/Dockerfile --target mail-grpc -t sithbit/mail-grpc .
docker build -f docker/Dockerfile --target domain-sithbit -t sithbit/domain-sithbit .
docker build -f docker/Dockerfile --target sithbit-ipfsd -t sithbit/sithbit-ipfsd .
docker build -f docker/Dockerfile --target sithbit-gateway -t sithbit/sithbit-gateway .
The images carry no configuration — TOML files and environment come
from the compose layer or your orchestrator. Settings can also come from
AWS AppConfig or Azure App Configuration instead of a mounted file: set
the binary’s {PREFIX}_AWSAPPCONFIG or {PREFIX}_AZAPPCONFIG env var
(see Cloud app-config
sources).
Two properties of the
runtime image (distroless cc-debian12) matter to an operator:
- There is no shell in the image.
docker execinto a running service is impossible; usedocker logs,docker cp, and the monitoring surfaces instead. When copying a live SQLite database out withdocker cp, take the-waland-shmsidecar files too, or the copy will read as empty. - CA certificates are baked in, so outbound TLS (RPC providers, Filebase, smarthosts) works without extra mounts.
Published images (GHCR)
You don’t have to build the images yourself. CI publishes all six to the
GitHub Container Registry (GHCR). The
.github/workflows/docker-publish.yml pipeline builds every target and
smoke-tests the compose stack on every push, but it only publishes on a
release tag (v*) or a manual workflow_dispatch run — plain
development pushes and pull requests build and smoke the images without
pushing anything.
Each --target stage ships as its own repository under the workspace
owner, named sithbit-<target>:
ghcr.io/<owner>/sithbit-sithbitd
ghcr.io/<owner>/sithbit-account-api
ghcr.io/<owner>/sithbit-mail-grpc
ghcr.io/<owner>/sithbit-domain-sithbit
ghcr.io/<owner>/sithbit-sithbit-ipfsd
ghcr.io/<owner>/sithbit-sithbit-gateway
A release tag pushes semver tags (1.2.3, 1.2) plus a moving latest; a
manual dispatch pushes branch- and commit-sha tags instead. Pull a released
image directly:
docker pull ghcr.io/<owner>/sithbit-sithbitd:latest
To run the published images instead of building locally, point the compose
services at their GHCR refs with image: (dropping the build: stanza, or
overriding it in a compose override file). The production example
(docker-compose.prod.example.yml) already expects a registry — set it to
ghcr.io/<owner> and pin a released tag rather than tracking latest:
services:
sithbitd:
image: ghcr.io/<owner>/sithbit-sithbitd:1.2.3
account-api:
image: ghcr.io/<owner>/sithbit-account-api:1.2.3
# …domain-sithbit, sithbit-sithbit-ipfsd, sithbit-sithbit-gateway,
# and (chain profile) sithbit-mail-grpc likewise
GHCR packages default to private: make the ones you want public in the
owner’s package settings, or docker login ghcr.io with a token that has
the read:packages scope before pulling.
The compose dev stack
docker-compose.yml at the workspace root boots sithbitd,
account-api, domain-sithbit, sithbit-ipfsd, and sithbit-gateway
(sharing the ipfsd block volume) with empty (all-default) configs,
publishing the dev ports on loopback only:
docker compose up -d --build
docker/smoke.sh # or: probe by hand; KEEP=1 leaves the stack up
docker/smoke.sh proves each service actually answers its protocol —
SMTP/IMAP/POP banners, HTTP from the two web services, a pin→fetch
byte-for-byte roundtrip through sithbit-ipfsd’s pin API, and the same
CID re-fetched through sithbit-gateway’s read-only surface. The script
rebuilds the images itself (up -d --build) before probing, so a
standalone docker/smoke.sh run cannot pass against stale local
images. It also
lifts the chain profile (exporting COMPOSE_PROFILES=chain for every
compose call it makes, teardown included), so mail-grpc boots live
and must report healthy alongside the rest — no host validator required,
because the gateway’s readiness gates only on its own gRPC listener
coming up, never on chain connectivity. A broken image (the historical
exec-on-start regression class) therefore fails the healthy-wait instead
of slipping through a config-only parse. On success the script tears the
stack down unless KEEP=1. State lives in named volumes and survives
down; docker compose down -v resets it.
The chain pipeline is disabled in this stack, exactly like the bare zero-config run.
Adding the chain: the chain profile
docker compose --profile chain up -d
The profile adds mail-grpc pointed at a surfpool validator running
on the host (boot one by running the mail_client integration suite,
which deploys and seeds the programs). Two things to know:
- The service uses
network_mode: hostdeliberately: a loopback-bound surfpool is not reachable through Docker’shost-gatewayfrom a bridge network, somail-grpcshares the host network and serves on127.0.0.1:50051exactly like a native run. - Configuration rides
MAIL_GRPC_*env overrides over the gateway’s in-code defaults (configuration) — the dev stack mounts nomail_grpc.toml. Two host-side variables feed them:SITHBIT_CHAIN_RPCpoints the profile at a remote cluster instead of the host surfpool, andSITHBIT_CHAIN_KEYPAIRnames the signing/fee-payer keypair file path on the host (absolute or./-prefixed — never the keypair JSON content), mounted read-only into the container. Its default is the checked-in devnet test keymail_client/tests/mail-key2.json— fine against surfpool, never against a real cluster.
Cloud-store overlays
Two overlay files swap the SQLite store for the cloud backends, backed by local emulators — the same code paths a scaled-out production deployment uses:
docker compose -f docker-compose.yml -f docker-compose.aws.yml up -d # DynamoDB Local + ElasticMQ
docker compose -f docker-compose.yml -f docker-compose.azure.yml up -d # Azurite (tables, queues, blobs)
The emulators publish no host ports (the stack reaches them over the
compose network) and their state is ephemeral. Store-backed services
fail fast if their backend isn’t accepting connections yet; compose’s
restart: on-failure brings them up as soon as it is.
Against real cloud backends, both stores encrypt their data at rest with
provider-managed keys and no configuration: the AWS store requests SSE
on the DynamoDB tables it creates (AWS-owned key) and SSE-SQS on its
queues, and Azure Storage/Tables and Cosmos are always encrypted at rest
by the platform. To use a customer-managed KMS key instead, set
kms_master_key_id under [store.aws] — a key ID, alias, or ARN. With
it set, the store creates the DynamoDB table with KMS-backed SSE under
that key and the SQS queues with SSE-KMS instead of SSE-SQS; unset
(the default) keeps provider-managed SSE. Azure has no
customer-managed-key option yet (Key Vault CMK is future work).
Provisioning with IaC: the iac/ directory at the workspace root
carries templates that create the same cloud-store footprint up front —
Terraform for AWS (table, queues, optional KMS key and blob bucket),
Bicep for Azure (storage account, table, queues, container). They are
optional: the runtime creates everything idempotently at startup either
way. Both templates also carry an opt-in mail-grpc unit
(deploy_mail_grpc / deployMailGrpc, default off): the gateway
container on a private subnet you bring — ECS Fargate on AWS, a
VNet-integrated ACI container group on Azure — with no public ingress
path, matching the
private-network posture
the topology appendix requires. See iac/README.md for the parameter ↔
config mapping, including each cloud’s keypair delivery. The
account-api unit and its opt-in shared summary cache are covered per
cloud under Hosting on AWS, Hosting on
Azure and Hosting on Google
Cloud below.
IPFS cluster demo
docker-compose.cluster.yml is a standalone file (not an overlay):
two sithbit-ipfsd nodes with [cluster] enabled over one minio
bucket — the shared-bucket cluster
shape. docker/cluster-smoke.sh is its chaos probe: pin through node
1, stop node 1, fetch the same CID through node 2.
Outbound mail and port 25
Many hosting providers — most cloud VPS platforms, and virtually all
consumer ISPs — block outbound connections on port 25 by default to
curb spam relayed from compromised or careless hosts. If sithbitd’s
relay worker sees connection timeouts or refusals handing mail to a
recipient’s MX, this is almost always the cause rather than a bug in
the relay logic; confirm with a manual connection test from the box
sithbitd runs on (nc -zv <mx-host> 25).
Two ways to unblock it, in order of preference:
-
Ask the provider to lift the block. Most cloud providers (AWS, Azure, DigitalOcean, …) will do this for a verified account in good standing on request. It’s the only path that keeps outbound delivery under your own PTR/DKIM identity end to end. (Google Cloud is the exception: GCE’s outbound-25 block is unconditional — see Hosting on Google Cloud.)
-
Route through a third-party gateway as a smarthost. If the block can’t be lifted — shared hosting, some residential/VPS plans, or while a request is pending — point
[spooler.smarthost]at a provider like SendGrid or Amazon SES. These accept mail over authenticated submission on 587/465, so an outbound port 25 block doesn’t affect them, and the gateway does the actual MX delivery from IPs with established sending reputation:[spooler.smarthost] host = "smtp.sendgrid.net" # or email-smtp.<region>.amazonaws.com for SES port = 587 user = "apikey" # SendGrid: literal string "apikey"; SES: your SMTP username password = "<api-key-or-smtp-password>" require_tls = true
Either way, DNS setup still applies: publish SPF that
include:s the gateway (SendGrid: include:sendgrid.net; SES:
include:amazonses.com), and DKIM — most gateways can sign on your
behalf too, but [spooler.dkim] keeps signing under your control if
you’d rather sign locally before handing off to the smarthost.
Direct-to-MX delivery (no smarthost) also honors each recipient
domain’s published MTA-STS
policy by default
(RFC 8461):
an enforce-mode policy means verified TLS to a policy-matching MX, or a
deferral on the normal retry schedule — never a plaintext fallback. A
recurring “no MX matches the MTA-STS policy” deferral in the logs means
the recipient’s MX records disagree with its own published policy, not
a local misconfiguration. Smarthost deployments are unaffected — the
gateway does the actual MX delivery, so its MTA-STS handling applies,
not sithbitd’s (see the
[spooler] reference).
This only affects outbound relay. Inbound MX on port 25 (the rest of the world delivering mail to you) is rarely blocked by hosting providers; if it is, that’s a harder blocker to work around and usually means the provider isn’t suited to running a mail server at all.
Hosting on AWS
AWS is the reference cloud for the two managed stores: [store] kind = "aws" (DynamoDB + SQS), blobs on S3, and every keypair loadable from
Secrets Manager with kind = "asm". The iac/aws Terraform module
provisions that footprint — the table, queues, optional KMS key and blob
bucket always; the mail-grpc and account-api units as default-off
opt-ins on a private subnet you bring (ECS Fargate, no public ingress).
iac/README.md carries the variable ↔ config mapping tables. The same
rules as everywhere apply to the mail-port tier: run sithbitd on
instances behind a passthrough network load balancer, never behind a
proxy that hides the client’s source address.
The account-api summary cache: ElastiCache
A fleet of account-api tasks behind a load balancer shares one
plaintext summary cache — the shared
backend of
[cache] kind = "redis" — or each task serves a different view of the
same mailbox. A single task is served by its in-process cache and needs
none of this, so the cache is opt-in and off by default:
create_account_api_cache = true adds an ElastiCache Valkey
replication group of one cache.t4g.micro node, TLS-only in transit
and encrypted at rest (under the module’s KMS key when it has one),
placed in the same private subnets as the task behind a security-group
pair — the cache admits port 6379 from the account-api security group
only, and the account-api group (which has no default egress) gains the
matching egress rule. The wiring into the task is three env entries:
ACCOUNT_API_CACHE__KIND=redis, ACCOUNT_API_CACHE__REDIS_URL
(rediss://<primary endpoint>:6379/, host-only) and an asm key-source
selector under ACCOUNT_API_CACHE__REDIS_AUTH__*; the token itself
never appears in an env value.
The operator step is that the token lives in two places by design.
ElastiCache needs it at creation (account_api_cache_auth_token,
sensitive, and — like every ElastiCache token — in the state file), and
the API fetches it at load from a Secrets Manager secret you create
yourself and name in account_api_cache_auth_asm ({ secret_id, region }, the jwt_key_asm shape; the task role gets GetSecretValue on
exactly that secret). Store the same token in both; the module never
reads or writes the secret. Changing the variable later rotates in
place (ROTATE keeps the previous token valid until the next update).
No live end-to-end deployment of this cache has been done yet — the
project is alpha; the module is validated statically by the gate’s
iac:terraform-aws leg, whose terraform test suite fences the cache’s
preconditions and env wiring under a mock provider.
Hosting on Azure
Azure mirrors the AWS shape with [store] kind = "azure" (Tables +
Queues), blobs in a container on the same storage account, and keypairs
from Key Vault with kind = "akv". The iac/azure Bicep template
provisions the storage account with its table, queues and container,
plus the mail-grpc and account-api units as default-off opt-ins: each a
VNet-integrated ACI container group on a delegated subnet you bring.
The standing rule for the mail-port tier is VM scale sets, never ACI —
the listeners need the client’s real source address. iac/README.md
carries the parameter ↔ config mapping tables.
The account-api summary cache: Azure Cache for Redis
The same opt-in as on AWS, for the same reason (one shared plaintext
summary cache per fleet; a single replica needs none): deployRedisCache = true adds an Azure Cache for Redis Basic C0 instance with TLS 1.2
minimum, the non-SSL port disabled and public network access off, so
its only path is a private endpoint the template creates — on
redisPrivateEndpointSubnetId, a second, undelegated subnet on the
account-api VNet you bring (a subnet delegated to ACI can host nothing
else), resolved through a privatelink.redis.cache.windows.net private
DNS zone linked to that VNet. The container group’s env gains
ACCOUNT_API_CACHE__KIND=redis, ACCOUNT_API_CACHE__REDIS_URL
(rediss://<name>.redis.cache.windows.net:6380/, host-only) and an
akv selector under ACCOUNT_API_CACHE__REDIS_AUTH__* pointing at
accountApiKeyVaultUri; the access key never appears in an env value,
and the template never reads it (listKeys() would put it there).
The operator step, after the deploy: copy the cache’s primary
access key into that vault under the name accountApiCacheSecretName
(default account-api-cache-auth) — az keyvault secret set --vault-name <vault> --name <accountApiCacheSecretName> --value "$(az redis list-keys --name <redisCacheName> --resource-group <rg> --query primaryKey -o tsv)". The group’s managed
identity already holds secret read on the whole vault (the
one-vault-many-secrets pattern the JWT and credential keys use), so no
new grant is needed. accountApiKeyVaultUri must be set: Bicep has no
precondition, and an empty vault URI leaves the selector broken until
the binary refuses to start. No live end-to-end deployment of this
cache has been done yet — the project is alpha; the template is
validated by the gate’s iac:bicep-azure leg.
Hosting on Google Cloud
Google Cloud is the proof of the vendor-independence claim above: a full SithBit deployment runs there with zero GCP-specific application code — every piece rides a backend that already exists.
-
Blobs = Google Cloud Storage over its S3-interop endpoint. GCS speaks the S3 XML API against HMAC credentials, and the blob store’s S3 backend sends exactly the path-style requests that endpoint expects — so a GCS bucket is just an
[store.blobs]edit:[store.blobs] kind = "s3" endpoint = "https://storage.googleapis.com" region = "auto" bucket = "sithbit-mail" # GCS bucket names are globally unique access_key = "<HMAC access id>" secret_key = "<HMAC secret>" -
Tables, leases, and the job queue = Cloud SQL. There is deliberately no
gcpstore kind:[store] kind = "postgres"carries all three in one database, and a Cloud SQL Postgres instance is a plainurlaway. One instance, one database — the schema migrates itself at startup. -
Secrets = Google Secret Manager. Every signing keypair (key sources) can load with
kind = "gsm"; auth is ambient (a service account / workload identity withsecretAccessoron the secret), so no credential material appears in config or env at all. -
Outbound port 25 is hard-blocked on GCE — plan on a smarthost. Unlike AWS/Azure, Google does not lift the block on request;
[spooler.smarthost]through a gateway that accepts authenticated submission on 587/465 is the supported shape. Inbound MX on port 25 is unaffected — the rest of the world can still deliver to you directly. -
The mail-port tier belongs on GCE, not serverless. The SMTP/IMAP/POP listeners need the connecting client’s real source IP (DNSBL, SPF, rate limits), so run
sithbitdon a managed instance group behind an external passthrough Network Load Balancer — the passthrough part is what preserves source addresses. This is the GCP analog of the standing Azure rule (VM scale sets, never ACI, for mail ports). Cloud Run and the global HTTP(S) load balancers proxy connections and are unsuitable for the mail tier — containers behind an L4 balancer that speaks PROXY protocol are the exception (see Hosting on a generic VM). -
The mail-grpc gateway can be serverless. It’s a private gRPC broker with no source-IP requirement — an internal-ingress-only Cloud Run v2 service fits, with the
gsmkey source delivering the fee-payer keypair.
The account-api summary cache: Memorystore
The same opt-in as on the other two clouds: a fleet of Cloud Run
account-api replicas shares one plaintext summary cache, a single
replica needs none, so deploy_redis = true adds a Memorystore for
Redis BASIC 1 GB instance with auth_enabled and TLS
(SERVER_AUTHENTICATION), attached over private-services-access to
account_api_network — the same VPC the unit’s Direct VPC egress rides,
which must already carry a servicenetworking connection and reserved
range (account-level plumbing the module leaves to you, as it does for
Cloud SQL). The service’s env gains ACCOUNT_API_CACHE__KIND=redis,
ACCOUNT_API_CACHE__REDIS_URL (rediss://<host>:<port>/, host-only)
and a gsm selector under ACCOUNT_API_CACHE__REDIS_AUTH__*; the auth
string never appears in an env value.
Two operator steps. First, the instance generates its own auth
string and the module never copies it into Secret Manager (a secret
version fed from state would put the token in state): after apply, copy
terraform output -raw redis_auth_string into the pre-existing Secret
Manager secret you name in account_api_cache_auth_gsm ({ secret, project }, the jwt_key_gsm shape; the service account is granted
secretAccessor on exactly that secret). Second, none of yours:
Memorystore’s SERVER_AUTHENTICATION mode presents a certificate from
an instance-specific Google-managed CA (the instance’s
server_ca_certs) that no platform root store holds, so the module
writes that CA — public material, already in state as an instance
attribute, not the token the no-vault stance protects — into a Secret
Manager secret it creates (sithbit-account-api-cache-ca, output
redis_ca_secret), mounts it into the service as a secret volume at
/etc/sithbit/redis-ca/ca.pem, grants the service account
secretAccessor on it, and sets ACCOUNT_API_CACHE__REDIS_CA to that
path — the
[cache] redis_ca
file selector. A Google CA rotation adds a certificate to the list; the
next terraform apply re-renders the version and rolls the service.
Until the CA is trusted the API runs fail-open (every request re-reads
the store) rather than failing, which is why the selector refuses a
certificate-less value at startup instead. No live end-to-end
deployment of this cache has been done yet — the project is
alpha; the module is validated statically by the gate’s
iac:terraform-gcp leg, whose terraform test suite fences the cache’s
preconditions and env wiring under a mock provider.
The iac/gcp Terraform module provisions this footprint — the GCS
bucket + HMAC pair always; Cloud SQL, the Cloud Run mail-grpc and
account-api units and the Memorystore cache as default-off opt-ins —
with outputs shaped to paste into the config sections above. See
iac/README.md for the variable ↔ config mapping tables and validation
gates.
Hosting on a generic VM: Postgres + any S3-compatible storage
The Google Cloud recipe above generalizes. Because every infrastructure touchpoint sits behind a trait (Your stack, not a vendor’s), any provider that rents you a VM, a Postgres database, and S3-compatible object storage runs the full stack — no provider SDK, no provider-specific store kind, no code change. The recipe is two config edits:
-
Tables, leases, and the job queue = any Postgres.
[store] kind = "postgres"carries all three in one database. Create the database first — managed or self-hosted, the server never issuesCREATE DATABASE— and the schema migrates itself idempotently at startup, so the connection URL is the only setting:[store] kind = "postgres" [store.postgres] url = "postgres://sithbit:<password>@<host>:5432/sithbit" -
Blobs = the provider’s S3-compatible object storage. The same
[store.blobs]shape as the GCS snippet above, pointed at the provider’s endpoint with its HMAC-style key pair:[store.blobs] kind = "s3" endpoint = "https://<provider's object-storage endpoint>" region = "<provider region>" bucket = "sithbit-mail" access_key = "<access key>" secret_key = "<secret key>"The blob store sends path-style requests (
endpoint/bucket/key). Every provider listed below accepts them, but where a provider’s docs standardize on virtual-hosted addressing (Hetzner’s do), smoke-test a put/get against your actual bucket at onboarding rather than at first delivery. -
IPFS blocks ride the same trait.
[ipfs.blobs]takes the samekind = "s3"shape — the same bucket or a second one, your choice — and a shared bucket is already the cluster shape when the fleet grows past one node.
Four operational caveats stand in for what a big cloud would otherwise absorb:
- Build features. A default
cargo buildcompiles only the SQLite backend; a source build of this recipe needs--features postgres,s3-blobs(orall) on the store-consuming binaries — see Slim-build features. The published GHCR images buildall, so container deployments skip the concern entirely. - TLS. Certificate sources are PEM files (or cloud-secret PEMs) —
there is no built-in ACME client, and the TLS acceptor loads
certificates once at startup. Run
certbot (webroot or DNS-01 for the mail
hostnames) with a
--deploy-hookthat restartssithbitd, or renewals will sit unused on disk while the listeners keep serving the old certificate. - Secrets. No cloud secret manager is required: file-based key
sources
are the default everywhere. Provision the key files with tight
permissions and back them up —
credential.key, the JWT key, and any DKIM key are the unrecoverable pieces (Monitoring and backups). - Outbound port 25 is where commodity providers differ most — see
Outbound mail and port 25. A blocked
port is not a disqualifier:
[spooler.smarthost]is the fallback, and the list below ranks each provider’s posture.
Containers behind a load balancer are viable for the mail tier — when
the balancer speaks PROXY protocol. The “VMs, not serverless” rule in
the Google Cloud section is about source-IP fidelity, not containers as
such: the listeners already accept a PROXY
protocol preamble
(proxy_protocol = true in each
[*.server] section)
and recover the real client address for DNSBL, connection limits, and
SPF. A container platform fronted by an L4 balancer that injects the
preamble — an HAProxy/Traefik ingress, or an NLB with PROXY protocol v2
enabled — preserves everything the mail tier needs; what stays ruled
out is any HTTP proxy or balancer that rewrites sources without PROXY
protocol. Pair the switch with a proxy_trusted allowlist naming the
balancer’s CIDRs; startup refuses proxy_protocol = true without one.
Never enable the switch on a listener clients can reach directly — the
preamble is spoofable.
Choosing a commodity provider
Ordered by fit for this recipe. Port-25 posture and PTR/rDNS control weigh heaviest (they are what sender reputation hangs on), managed Postgres second; a blocked port 25 demotes a provider to a smarthost caveat, it does not disqualify.
- Hetzner — the best price/performance of the six, with a documented, routinely granted port-25 unblock (request it after one month and the first paid invoice — see the cloud server FAQ) and first-class self-service PTR records (console, API, and Terraform). Object Storage is S3-compatible at €5.99/mo including 1 TB. Two caveats: there is no managed Postgres — self-host it on a VM + volume or buy it from a third party — and its Object Storage docs standardize on virtual-hosted addressing, so run the path-style smoke test above at onboarding.
- OVHcloud — the only provider here with port 25 open by default in its classic regions, so it is the one that can deliver under its own identity on day one; managed Postgres, S3-compatible Object Storage, and self-service PTR complete the full recipe with no gaps. Caveats: keep the mail tier out of its Local Zones (port 25 blocked there, no unblock path), and its reactive anti-spam can block an instance’s IP mid-operation (recovery is self-service). Terraform spans two providers (OVH + OpenStack).
- Scaleway — the most deterministic unblock of the bunch: enabling SMTP is a console checkbox after identity verification, with no human review. Managed Postgres is the cheapest here (from roughly €11/mo), and Object Storage plus flexible-IP PTR complete the recipe. The constraint is geography: regions are EU-only.
- Linode (Akamai) — the full recipe is present (Aiven-powered managed Postgres, the cheapest object storage of the six at $5/mo including 250 GB, self-service PTR once forward DNS resolves), but the SMTP unblock is a human-reviewed support ticket — and 465/587 are blocked too until it resolves, so even the smarthost fallback needs the ticket first (or a gateway reached over an HTTPS API rather than SMTP submission).
- Vultr — the recipe applies mechanically: managed Postgres, object storage, self-service PTR, and 587 open from day one, so a smarthost works immediately. But the port-25 unblock is explicitly not guaranteed — plan on the smarthost semi-permanently — and its managed-Postgres floor (~$45/mo) is the priciest of the providers that have one.
- DigitalOcean — demoted, not disqualified: 25, 465, and 587 are all blocked for new accounts with no reliable unblock, and PTR control is indirect (the Droplet’s name becomes the PTR; Reserved IPs get none) — the weakest sender-reputation posture here. Everything else — managed Postgres from $15/mo, Spaces, the best-documented S3 compatibility, the best operator UX — is excellent for a smarthost-first deployment.
No SithBit code in any of this is provider-specific: the recipe is the GCS pattern above with a different endpoint, and the trait seams do the rest.
Production
docker-compose.prod.example.yml is the annotated production shape:
copy it, search for CHANGE, and fill in your registry, config files,
and keys. Sanity-check with docker compose -f <file> config before
up -d. The structural decisions it encodes:
- Real config lives in mounted TOML files, not a wall of env vars.
Start from
mail_spooler/sithbitd.example.tomlandaccount_api/account_api.toml; containers find the file viaSITHBITD_CONFIG/ACCOUNT_API_CONFIG. The third option is a cloud app-config source — AWS AppConfig or Azure App Configuration, bootstrapped by a single env var — when a mount is the awkward part of your orchestrator. For that path,iac/appconfig/ships ready-to-import production documents for all six services (AWS freeform TOML profiles whose comments survive verbatim in the store, and a generated Azure kvset file whose per-keydescriptiontags carry the same text), plus the store-creation and import runbooks iniac/README.md. - Implicit-TLS mail ports are the production primaries (RFC 8314):
the compose example publishes host 25→2525 (MX), 465→2465 (submission,
SMTPS), 993→2993 (IMAPS), and 995→2995 (POP3S) — the three
submission/access listeners run
implicit_tls = true, so the connection is born encrypted with no STARTTLS round trip, while MX on 25 stays plaintext-with-STARTTLS by nature. The binds move to0.0.0.0in the TOML; the images never need root or privileged ports. The legacy STARTTLS/STLS listeners on 587/143/110 are opt-in secondaries — commented out in both the compose file andsithbitd.example.toml; uncomment them (and their matching TOML listeners) only for old clients. - TLS for the mail protocols comes from the
[smtp.tls]/[submission.tls]/[imap.tls]/[pop.tls]sections over a mounted/certsdirectory (each a file or Key Vault key source), andrequire_tlsis on by default for the submission edge, IMAP, and POP — credentials are declined until the connection is protected (RFC 8314). The two HTTP services (account-api,domain-sithbit) stay loopback-published and belong behind a TLS-terminating reverse proxy. - SQLite allows exactly one
sithbitd. Never scale the service while[store] kind = "sqlite". For replicas, switch the TOML to theaws/azurestore and work through the Scaling out checklist. - The
storevolume is precious — it holdssithbit.db,credential.key,jwt.key, and the blob directory. Back it up; see Monitoring and backups for what is unrecoverable. Thealias-indexvolume is not precious: it re-syncs from chain history. - Secrets stay out of the compose file. The mail-grpc
fee-payer/signing keypair is a key source (
keypairinmail_grpc.toml— a mounted file path or a cloud secret manager: Key Vault, Secrets Manager, or GSM): the dev compose mounts the keypair file read-only viaSITHBIT_CHAIN_KEYPAIR, and the production example mountsmail_grpc.tomlplus a separate read-only keypair file — or drops that mount entirely for the secret-manager forms. (The non-secret rest ofmail_grpc.tomlcan likewise arrive from a cloud app-config source instead of a mount — carrying the key source’s coordinates, never the key.) Thedomain-sithbitdelegate keypair authorizes domains on-chain — an operational hot key that can never sweep postoffice funds, rotated on a schedule viapostmaster delegate(the service picks up a swapped key file without a restart). The ownership secrets are the offline key-ceremony seeds, which never touch a deployment host at all: see the postmaster key custody runbook.
After the stack is up, work through DNS setup so the world can find your MX, then Monitoring and backups for day-2 operation — including sizing the account API’s caches from the metrics it exports, once real traffic has warmed them.
Configuration reference
Every SithBit binary follows the same contract: an empty or missing config file is a runnable dev instance. Every setting has an in-code default — loopback binds, unprivileged ports, a local SQLite store — so configuration is only ever overriding a default, never satisfying a required field. The one exception is called out below (TLS certificate paths, which have no sensible default).
The annotated example files are the canonical per-key documentation and ship with every default shown commented out:
mail_spooler/sithbitd.example.tomlaccount_api/account_api.tomldomain_sithbit/domain_sithbit.example.tomlipfs_daemon/sithbit_ipfsd.example.tomlipfs_gateway/ipfs_gateway.example.tomlmail_grpc/mail_grpc.example.tomlpop_server/pop_server.tomlimap_server/imap_server.tomlsmtp_server/smtp_server.tomlmail_migrate/sithbit_migrate.example.tomlmail_console/sithbit_console.toml
The last three ship under the config file’s own name rather than an
.example copy, and are the one place a setting is left live instead
of commented out — see Standalone protocol
servers for the two dev-only exceptions
they make.
How a setting resolves
Eleven binaries take an annotated TOML file of their own — sithbitd,
account-api, domain-sithbit, sithbit-ipfsd, sithbit-gateway,
mail-grpc, the three standalone protocol
servers pop-server, imap-server and
smtp-server, the store-migration tool
sithbit-migrate, and the admin TUI
sithbit-console. Every one of them layers each
setting the same way, lowest to highest precedence:
- the in-code default,
- the TOML file (its own file name in the working directory, or the
path in its
*_CONFIGenvironment variable — both named in the table below), - an optional cloud app-config source — AWS AppConfig or Azure App Configuration, opted into per binary by a bootstrap env var; skipped entirely when neither var is set,
./.env,./.env.$APP_ENV(APP_ENVcomes from the environment or./.env),- the real environment.
Environment variables address individual settings as
{PREFIX}_{PATH}, with __ descending one TOML nesting level:
SITHBITD_STORE__KIND=aws # [store] kind
SITHBITD_SMTP__SERVER__BIND_ADDR=0.0.0.0:2525 # [smtp.server] bind_addr
SITHBITD_STORE__BLOBS__KIND=azure # [store.blobs] kind (tagged enum)
Each binary’s file, config-path variable, and prefix:
| Binary | Config file | Config-path variable | Env prefix |
|---|---|---|---|
sithbitd | sithbitd.toml | SITHBITD_CONFIG | SITHBITD |
account-api | account_api.toml | ACCOUNT_API_CONFIG | ACCOUNT_API |
domain-sithbit | domain_sithbit.toml | DOMAIN_SITHBIT_CONFIG | DOMAIN_SITHBIT |
sithbit-ipfsd | sithbit_ipfsd.toml | SITHBIT_IPFSD_CONFIG | SITHBIT_IPFSD |
sithbit-gateway | ipfs_gateway.toml | IPFS_GATEWAY_CONFIG | IPFS_GATEWAY |
mail-grpc | mail_grpc.toml | MAIL_GRPC_CONFIG | MAIL_GRPC |
pop-server | pop_server.toml | POP_SERVER_CONFIG | POP_SERVER |
imap-server | imap_server.toml | IMAP_SERVER_CONFIG | IMAP_SERVER |
smtp-server | smtp_server.toml | SMTP_SERVER_CONFIG | SMTP_SERVER |
sithbit-migrate | sithbit_migrate.toml | SITHBIT_MIGRATE_CONFIG | SITHBIT_MIGRATE |
sithbit-console | sithbit_console.toml | SITHBIT_CONSOLE_CONFIG | SITHBIT_CONSOLE |
Env files are read from the working directory only, and their values are exported to the process environment without overriding variables that are already set.
Key sources: files or cloud secret managers
Six file-loaded secrets take a key source rather than a bare path:
the account-api JWT signing key (jwt.key_file), the
DKIM signing key(s) ([spooler.dkim] /
[mail.dkim] key_file), the credential-sealing
key (credential_key_file), every
server’s TLS certificate and key
(certs / key, the account-api’s [tls] included — it shares the same section shape),
domain-sithbit’s delegate signing key
(delegate_key_file), and mail-grpc’s signing/fee-payer
keypair (keypair).
A key source is either a local file (the default) or a cloud secret
manager’s secret — Azure Key Vault (akv), AWS Secrets Manager (asm),
or Google Secret Manager (gsm) — in one of these TOML forms:
key_file = "jwt.key" # 1. bare path string -> a local file
key_file = { path = "jwt.key" } # 2. table, kind omitted -> a local file
key_file = { kind = "akv", # 3. an Azure Key Vault secret
vault_uri = "https://<vault>.vault.azure.net/",
secret_name = "jwt-signing-key" }
key_file = { kind = "asm", # 4. an AWS Secrets Manager secret
secret_id = "sithbit/jwt-signing-key" }
key_file = { kind = "gsm", # 5. a Google Secret Manager secret
project = "my-project",
secret = "jwt-signing-key" }
The representation defaults to a file, so a config that names a plain
path — or omits kind — keeps the historical, zero-config file behaviour
byte-for-byte; only an explicit cloud kind opts into a secret manager.
This holds for every key-source field whatever it is named (key_file,
credential_key_file, delegate_key_file, keypair, certs / key).
A cloud secret’s value holds exactly what the file would have — the raw
key bytes, or the PEM. The TLS pair therefore fetches two secrets —
the certificate-chain PEM and the private-key PEM — one per certs/key
entry. No secret material lives in the config
file itself; it carries only the secret’s coordinates. Per cloud:
akvnames thevault_uriandsecret_name. Authentication reuses the same managed-identity credential chain as the Azure storage backend (azure_identity’s ManagedIdentity): no new auth to configure.asmnames thesecret_id— a secret name or a full ARN — with optionalregion(defaults to the ambient AWS configuration’s) andendpoint_url(an emulator such as LocalStack, mirroring the AWS storage backend’s setting). Authentication is the ambient AWS credential chain (environment, profile, IAM role). A string secret’s UTF-8 bytes are used; a binary secret’s raw bytes serve as the fallback when no string value is present.gsmnames theprojectandsecret, with optionalversion(default"latest"). Authentication is Application Default Credentials (workload identity,GOOGLE_APPLICATION_CREDENTIALS, or a gcloud user login).
A table carries only its own kind’s fields. A field belonging to a
different kind — vault_uri under kind = "asm", project under
kind = "akv" — is refused by name when the config loads, rather
than parsed and silently discarded. A half-finished migration between
secret managers therefore fails loudly at startup instead of quietly
reading from the source you believed you had left behind. The likeliest
shape is a stale environment override: {PREFIX}_..._KIND switched to a
new kind while the old kind’s ..._VAULT_URI (or ..._SECRET_ID) is
still exported, since those layer on top of the TOML. Each kind’s own
optional fields — asm’s region and endpoint_url, gsm’s version
— are unaffected.
A key belonging to no kind is refused the same way, naming it: key = { kind = "asm", secret_id = "…", regoin = "us-east-1" } fails with
table has an unknown field `regoin` . That is the message a plain
typo produces, and it is worth knowing which mistakes it now catches
that previously passed silently — a misspelled optional field, as
above, which used to leave the setting at its default, and a stray key
alongside an otherwise-valid table. A typo in a required field
always failed, but blamed the absence (requires `path` ) rather than
the cause; it now names the typo instead.
Two mistakes that used to escape by name no longer do. A kind whose
value is unknown fails with kind = "avk" is not a known kind; expected one of "file", "akv", "asm", "gsm" — the list quoted in the
message is the same list that accepts a spelling, so the two cannot
drift apart. A field of the right name but the wrong type names both the
field and what it found: path = 5 fails with table field `path` must be a string, found integer. Spellings stay exact and case-sensitive,
so kind = "AKV" is refused rather than quietly accepted.
Both used to fail while the table was still being parsed, where the
untagged form retried its other shapes and reported only “data did not
match any variant”. Neither the config that is accepted nor the config
that is refused has changed — only which of them tells you why. One
gap remains: a key that is neither a string nor a table (key = 5)
still reports the untagged message, because it matches no shape at all.
Every kind parses in every build. The clouds themselves are cargo
features of the key-source crate (akv / asm / gsm, all on by
default); a build that compiles one out still accepts the config but
fails at load with an error naming the feature to enable, so a slimmed
operator build can drop the SDKs it never uses (see
Slim-build features for per-binary
slim build commands).
(Cloudflare has no equivalent backend by design: its Secrets Store / Workers secrets are write-only over the API — only a deployed Worker binding can read a value — so a fetch-style key source cannot exist.)
There is no local Key Vault emulator, so the live AKV fetch is
exercised only by an #[ignore]d probe pointed at a real vault; the ASM
and GSM twins have the same shape, except that an ASM probe can target
LocalStack instead of the real cloud. Each probe runs only when its
environment variables are set, and the roster of those variables lives
in key_source/README.md’s Tests section (outside this book) rather
than being restated here — a test in that crate scans its sources and
fails if a gate variable is missing from that section, so the crate’s
list is the one that cannot fall behind. The per-kind dispatch is
unit-tested against a fake fetcher, so CI covers the dispatch and a real
cloud covers the round trip.
Cloud app-config sources: AWS AppConfig or Azure App Configuration
When mounting a TOML file into every container or VM is the awkward part
of a deployment — orchestrated fleets, serverless units, config that
several instances must share — a binary can pull its settings from a
managed configuration store instead: AWS AppConfig or Azure App
Configuration. The cloud tier slots into the resolution chain
above directly after the TOML file: it
overrides the file, and is itself overridden by ./.env,
./.env.$APP_ENV, and the real environment — so a {PREFIX}_{PATH}
override still beats a cloud value, exactly as it beats the file. This
tier carries settings, not secrets: key material keeps going through
key sources, and a cloud
config value holds at most a secret’s coordinates, never the secret.
Opting in is pure environment — no TOML key, no code change. Each binary
derives six bootstrap variables from its prefix (SITHBITD,
ACCOUNT_API, DOMAIN_SITHBIT, MAIL_GRPC, SITHBIT_IPFSD,
IPFS_GATEWAY):
| Variable | Meaning |
|---|---|
{PREFIX}_AWSAPPCONFIG | Select AWS AppConfig: application/environment/profile (exactly three non-empty segments) |
{PREFIX}_AWSAPPCONFIG_REGION | Optional region override (default: the ambient AWS configuration’s) |
{PREFIX}_AWSAPPCONFIG_ENDPOINT | Optional endpoint URL, for emulators/local fakes |
{PREFIX}_AZAPPCONFIG | Select Azure App Configuration: the store’s https://<name>.azconfig.io endpoint |
{PREFIX}_AZAPPCONFIG_LABEL | Optional label filter; unset reads the NULL label only, never every label |
{PREFIX}_AZAPPCONFIG_PREFIX | Optional key prefix, server-filtered and stripped before mapping (e.g. sithbitd:) |
With neither primary variable set the tier is skipped entirely — the
zero-config contract is untouched. Setting both is a load error
(pick one provider per binary). The bootstrap variables are control
inputs, not settings, and may themselves come from a .env file.
The payload idiom differs per provider:
- AWS AppConfig holds one whole TOML document in a freeform
configuration profile. It deep-merges over the config file: nested
tables merge key-wise, so one cloud document can override a section
without erasing its siblings; scalars and arrays replace wholesale.
Each load opens a fresh AppConfigData session and makes a single
GetLatestConfigurationcall — configuration is read once at startup, so picking up a new deployment means restarting the binary. - Azure App Configuration holds per-key values:
:in a key descends one TOML nesting level (store:kindsetsstore.kind), and values get the same TOML-scalar parsing as env overrides. Keys are case-sensitive — spell them exactly like the TOML keys. A scalar-vs-table collision between keys fails the load rather than letting listing order decide.
Both providers authenticate ambiently — the AWS credential chain, the
Azure managed identity — the same posture as the key sources above. The
backends are cargo features of the app-config crate (awsconf /
azconf, both on by default); a build that compiles one out fails at
load with an error naming the feature to rebuild with when its provider
is selected, so a slimmed operator build can drop the SDK it never uses
(see Slim-build features for
per-binary slim build commands).
The mapping logic (bootstrap parsing, TOML merge, key nesting) is
unit-tested against injected fake fetches, so CI covers it without
credentials. The live round trips are #[ignore]d probes — the AWS one
needs ambient AWS credentials and can be aimed at an emulator instead
of the real service, the Azure one needs an ambient managed identity —
and each runs only when its environment variables are set. The roster
of those variables lives in app_config/README.md’s Tests section
(outside this book) rather than being restated here — a test in that
crate scans its sources and fails if a gate variable is missing from
that section, so the crate’s list is the one that cannot fall behind.
You do not have to author the store content from scratch: the
repository ships ready-to-import production documents for both
providers — a complete six-service deployment shape with dummy secret
coordinates — under iac/appconfig/, together with the az appconfig kv import / aws appconfig runbooks and the generator that keeps the
two flavors in lock-step. See iac/README.md and the
deployment chapter.
Going public: which settings must change
Every per-binary page below marks the rows an operator has to revisit
before a listener leaves loopback. The same three-way legend applies on
every page, in the example TOMLs, and in the iac/appconfig/ production
documents:
- REQUIRED (public) — a reachable deployment is unsafe or
non-functional until the operator sets this deliberately: a public
bind address, the TLS certificate behind it, the hostname clients and
peers are told, a store shared across instances, a secret, or the
gateway’s mutual-TLS table once the gateway enforces
[auth]. - RECOMMENDED (public) — the in-code default is safe, but a public deployment should choose the value consciously: rate limits and budgets, blocklists, sender-authentication policy, quotas, telemetry.
- unmarked — the default is right as shipped; going public changes nothing.
A marker is an operator obligation, not a startup check. Two
binaries couple a non-loopback bind to a credential and refuse to start
without it — mail-grpc to its [auth] section, sithbit-ipfsd to
its auth_token — and no other binary validates its configuration
against the address it binds. Where a row’s setting is
enforced at startup for some other reason (a missing field, an empty
allow-list, a mismatched scheme) the row says so — otherwise assume a
misconfigured public instance starts cleanly and serves.
[health] and [observability] — every binary
Two sections shared by every TOML binary — mail-grpc included, since
it moved onto the same layering:
| Key | Default | Meaning |
|---|---|---|
health.bind_addr | 127.0.0.1:<per-binary port> | The /healthz + /readyz listener; each binary defaults its own port (8190–8198, table in Monitoring) |
health.enabled | true | Disable to serve no health endpoints (--health-probe then exits 1) |
observability.otlp | (absent — no export) | Presence of the section enables OTLP push of traces + metrics |
observability.otlp.endpoint | "http://127.0.0.1:4317" | Collector gRPC endpoint. The standard OTEL_EXPORTER_OTLP_*ENDPOINT env vars silently override this — leave them unset |
observability.otlp.metrics_interval_seconds | 60 | Metric push cadence |
Every binary also accepts a --health-probe argument: load the same
config, GET the health listener’s /readyz, exit 0/1 — this is what
the compose healthcheck: entries run inside the distroless images.
See Monitoring and backups for the endpoints, the
metric list, and the docker-compose.otel.yml collector overlay.
What’s on each page
The settings above — resolution order, key sources, cloud app-config sources, and the two sections every binary shares — apply across the whole reference. Each binary’s own settings are on its own page:
- sithbitd: core & chain settings —
what the daemon is,
[store],[grpc]/[ipfs] - sithbitd: SMTP & submission settings
—
[smtp],[submission], sender authentication, outbound quotas - sithbitd: IMAP, POP & security settings
—
[imap],[pop], login-attempt budgets, TLS posture, client-certificate auth - sithbitd: spooler settings —
[spooler], DMARC/TLS-RPT reporting, IPFS offload - account-api settings — the account API, static mounts, rate limits, sithbit-console
- domain-sithbit settings — the domain
verification service,
[mail_hosts],[mta_sts] - IPFS services settings —
sithbit-ipfsd, sithbit-gateway,
[swarm] - mail-grpc settings — the chain gateway
- Standalone protocol servers — pop-server, imap-server, smtp-server
- Which services get a
.env— a cross-cutting summary
sithbitd: core & chain settings
Part of the configuration reference. sithbitd is
the combined mail daemon — this page covers what it is, its shared
[store] (also read by account-api), and the [grpc] /
[ipfs] sections that wire up chain access. Its listener protocols
(SMTP/submission, IMAP/POP), authentication and security settings, and the
outbound spooler each have their own page — see the sidebar.
sithbitd
The combined mail daemon: SMTP MX + submission, IMAP, POP, and the
spooler workers in one process. Run exactly one sithbitd per store
while [store] kind = "sqlite" — IMAP IDLE push and per-wallet
SendMail ordering are in-process. The postgres and cloud stores lift
that limit; see Scaling out.
Shared listener values — written once, inherited by every listener
hostname and local_domains are top level, above the first section
header (a bare key written after one reads as a key of that section).
Each fills in the listeners that named none of their own, so a deployment
states its identity once instead of five and three times over:
Row markers follow the going-public legend.
| Key | Default | Meaning |
|---|---|---|
hostname | (unset — each listener keeps its own default) | REQUIRED (public). The name every listener greets under, so the built-in "localhost" is wrong for anything reachable — set it to the public MX name this host answers to. Inherited by [smtp], [submission], [imap], [pop] and [spooler] while they are still on the built-in "localhost". A listener that names its own keeps it — which is how imap.<domain> and pop.<domain> survive a shared value. Applied before identity discovery, so an inherited hostname counts as configured there rather than being overwritten |
local_domains | [] (unset) | REQUIRED (public). The domains this deployment accepts mail for; leave it unset only where identity discovery supplies them. Inherited by [smtp], [submission] and [spooler] while their own lists are empty. Same override rule, and the same ordering against discovery |
enable_stored_passwords | (unset — each listener keeps its own true) | RECOMMENDED (public). Inherited by [smtp], [submission] and [pop] — the three listeners that advertise CRAM-MD5 — when they named none. [imap] has no such key: IMAP never offers the mechanism. Retiring stored mail passwords fleet-wide is still a two-document change: this covers the daemon’s listeners, and account-api’s key of the same name is what stops new passwords being stored. One without the other leaves either a mechanism advertised with no secret behind it, or secrets being minted for a mechanism nobody offers |
max_message_size | (unset — each listener keeps its own 25 MiB) | RECOMMENDED (public). Inherited by [smtp], [submission] and [imap] — the three that carry mail — when they named none. [pop] has no ceiling; it accepts no uploads. The SMTP and IMAP ceilings are deliberately the same number, so a message that arrived can always be uploaded back, and this is how a deployment says that once instead of three times and keeps it true. 0 is refused here, and only here — see below |
[health], [observability] | (the shared defaults) | RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table |
All four are additive defaults: a config file that never mentions them
behaves exactly as it did before they existed, and nothing an operator
already wrote changes meaning. The shared certificate — [tls] — follows
the same rule and is documented with the listener TLS
settings;
the shared [quota] table is documented with the outbound
quotas.
Why the last two are spelled Option in the code, and why it matters
here. hostname and local_domains each have a sentinel an operator
would never write on purpose — the built-in "localhost", an empty list —
so “wrote nothing” is recognisable at a glance. A lone bool and a lone
number have none: enable_stored_passwords = true and
max_message_size = 26214400 written on a section are byte-identical to
that section saying nothing at all. So both are optional on the listener
configs and read everywhere else through an accessor that supplies the
default, and the daemon’s inheritance pass is the only code that looks at
the raw field. That is what lets a shared value fill in the listeners that
were silent without overwriting one an operator deliberately wrote out.
max_message_size = 0 is refused at the top level. To the SMTP
listeners 0 means advertise SIZE with no fixed limit; [imap] feeds
the same number to its literal cap and advertises it as
APPENDLIMIT,
where 0 refuses every APPEND. One shared key cannot carry two opposite
meanings, so the daemon fails at startup naming the key rather than
delivering both. Per section it is untouched: [smtp] max_message_size = 0
still means what it always did.
[store] — shared with account-api
| Key | Default | Meaning |
|---|---|---|
kind | "sqlite" | REQUIRED (public). The SQLite default admits exactly one daemon per store, so a deployment running more than one instance names a backend several can share. Tables/queues/leases backend: "sqlite", "postgres" (PostgreSQL), "aws" (DynamoDB + SQS), "azure" (Tables + Queue Storage), "turso" (libSQL local file / embedded replica), or "cloudflare" (D1 + Queues + Workers KV + R2) |
database | "sithbit.db" | SQLite database path (kind = "sqlite"). A <database>.boot-lock sidecar file appears beside it: an advisory lock that serializes concurrent opens, so sithbitd and account-api can first-boot a shared fresh store in either order or at once. It is never written, releases with the process, and is harmless to leave in place |
credential_key_file | "credential.key" | REQUIRED (public). In production keep it off the mail host, in a secret manager rather than in a file beside the database. Seal key for stored mail passwords — unrecoverable if lost; back it up. A key source: a file path (default) or a cloud secret-manager secret |
REQUIRED (public). Whichever backend kind names, its own section
below carries the coordinates a public deployment has to set
deliberately — the bucket, table, queue prefix, URL or database id the
fleet shares. The shipped defaults point at a local directory and
loopback emulators, and nothing at startup notices that a reachable
instance kept them.
[store.blobs] selects the mail-body blob store: kind = "local"
(default, path = "blobs"), "s3" (endpoint, bucket, region,
access_key, secret_key), or "azure" (endpoint, container,
account, access_key).
[store.postgres] (for kind = "postgres"): url (default
"postgres://postgres:postgres@127.0.0.1:5432/sithbit"). The schema is
migrated idempotently at startup; the database itself must already
exist. The URL’s password is redacted from logs and Debug output.
[store.aws] (for kind = "aws"): region, table (one DynamoDB
table, default "sithbit"), queue_prefix (SQS queues named
<prefix>-*, default "sithbit"), endpoint_url /
sqs_endpoint_url (emulators), sqs_wait_time_seconds (SQS receive
long-poll seconds, default 10; 0 = short polling), access_key /
secret_key — omit the
keys to use the ambient AWS credential chain (env, profile, IAM role) —
and kms_master_key_id (a KMS key ID, alias, or ARN for at-rest
encryption of the table and queues; unset, the default, keeps
provider-managed SSE). The table and queues are created idempotently at
startup.
[store.azure] (for kind = "azure"): account, table,
queue_prefix, table_endpoint, queue_endpoint, access_key. The
defaults target a local Azurite emulator (run it with
--skipApiVersionCheck); a real account derives its endpoints and
needs access_key. Note the Azure blob store has no emulator key
fallback: Azurite blob use needs the published well-known key passed
explicitly.
[store.turso] (for kind = "turso"): database (local
libSQL file,
default "sithbit.db"), sync_url (libsql://… / https://… — set it
to run an embedded replica against a remote Turso/libSQL primary;
unset = a pure-local file, on-disk-identical to kind = "sqlite"),
auth_token (bearer secret for the remote; redacted from logs — unset
for local dev or an unauthenticated self-hosted sqld), and
sync_interval_secs (seconds between background replica→remote pulls;
unset defaults to 60s for a replica). Blobs still come from [store.blobs]
(local/s3) — libSQL has no object store, exactly like SQLite. The schema
is migrated idempotently at startup.
[store.cloudflare] (for kind = "cloudflare"): the daemon reaches
Cloudflare’s edge primitives over plain HTTP with one bearer API token —
it is not hosted on Workers. Three native services back the five
store traits: D1 (SQLite-over-HTTP) for accounts + mail + keyed
leases, Cloudflare Queues for the job queue, and R2
(S3-compatible) for blobs.
| Key | Meaning |
|---|---|
account_id | REQUIRED (public) when kind = "cloudflare". Cloudflare account id (the /accounts/<id>/… REST path segment, and the subdomain of the derived R2 endpoint) |
api_token | REQUIRED (public) when kind = "cloudflare". Bearer token authorizing the D1/Queues calls; redacted from logs and Debug output |
d1_database_id | REQUIRED (public) when kind = "cloudflare". D1 database id (the /d1/database/<id>/query segment) |
api_base | REST base URL override (mock servers / proxies); unset = https://api.cloudflare.com/client/v4 |
[store.cloudflare.queues] | REQUIRED (public) when kind = "cloudflare". The five queue ids — chain, relay, chain_delete, dsn, dead (Cloudflare assigns each queue its own id, so there is no name-prefix derivation as with SQS) |
[store.cloudflare.r2] | REQUIRED (public) when kind = "cloudflare". R2 blob store — an s3-shaped BlobConfig (endpoint, bucket, region, access_key, secret_key). Unlike the other backends, blobs come from this section, not [store.blobs], because R2’s endpoint is account-derived (https://<account_id>.r2.cloudflarestorage.com) unless you set endpoint explicitly |
Provisioning — auto vs prerequisite. The D1 schema (leases table
included) is provisioned automatically at startup: the shared
migration set is replayed over D1’s HTTP query API, idempotently (a
_d1_migrations tracking table makes a restart a no-op). The D1
database and the five Cloudflare Queues themselves are a manual
prerequisite — creating them is a Cloudflare management-API/dashboard
step with no local emulator, so the backend does not auto-create them. Selecting kind = "cloudflare"
without those ids configured fails fast at startup with a clear config
error rather than an opaque runtime 404.
Local-first testing. The full shared store-conformance suite — D1
(accounts, mail, DMARC, keyed leases) and Cloudflare Queues (the job
queue) — now runs with no Cloudflare account over the same
production wire path the daemon uses in the field: the real
ReqwestTransport and the http.rs/queue.rs request-builders and
response-parsers, driven against a loopback fake that speaks Cloudflare’s
public REST envelopes (D1 /query, Queues push/pull/ack/info) over a
real TCP socket. Because D1’s dialect is SQLite, the fake replays the
identical SQL against an in-process SQLite; queues run over stateful
in-memory state behind the REST surface. R2 blobs ride the same
minio-backed S3 path the other S3-shaped backends use.
This exercises the request-building and response-parsing that the earlier
in-process Transport fakes bypassed, and proves the trait semantics
survive a real socket. Be clear about what it does not prove: a
self-written fake only shows that http.rs/queue.rs are internally
consistent with the envelopes we assumed Cloudflare speaks — it cannot
confirm those assumptions against the real service. A live
real-Cloudflare conformance run therefore remains deferred: it needs a
paid account (R2 requires dashboard enablement, and Queues requires a
Workers Paid plan) and is gated behind real credentials.
[grpc] and [ipfs] — the chain pipeline
The two sections are independent halves of chain access. [grpc]
alone enables everything gateway-backed — SMTP recipient verification
at RCPT time (alias resolution + postage checks),
at-rest sealing, alias logins
— with the chain pipeline off: delivered copies stay in state
received, and boot logs the verification-only posture at info. This
is the MX posture: an edge that verifies postage but never writes the
chain needs no [ipfs] provider. Adding [ipfs] as well enables the
full pipeline (the encrypt → pin → SendMail workers), so mail
actually reaches the chain. [ipfs] without [grpc] does nothing —
no gateway means no chain to announce pins to — and logs a warning at
boot. With neither section, the chain pipeline is disabled entirely
(the dev default).
| Key | Default | Meaning |
|---|---|---|
grpc.endpoint | (unset) | REQUIRED (public). The mail-grpc gateway, e.g. "http://127.0.0.1:50051" — https:// once grpc.tls is set |
grpc.tls | (absent — plaintext) | REQUIRED when the gateway has [auth] configured (every gateway not on this host’s loopback): the daemon’s half of the mutual TLS, the three keys below — all or none. The endpoint must then be https://; http:// with this table, or https:// without it, refuses to start. Add the daemon’s key to the gateway’s authorized_keys |
grpc.tls.cert | (unset) | REQUIRED when the gateway has [auth] configured. The daemon’s PEM client certificate — a path or a key source. Its Ed25519 key is what the gateway allow-lists |
grpc.tls.key | (unset) | REQUIRED when the gateway has [auth] configured. Private key for cert, PEM. Same source forms |
grpc.tls.gateway_key | (unset) | REQUIRED when the gateway has [auth] configured. The base58 Ed25519 key in the gateway’s certificate, pinned — no CA, no hostname check; any other certificate fails the handshake |
ipfs.kind | (inferred) | RECOMMENDED (public). Name the provider rather than leaning on inference once the pipeline matters. "embedded", "remote", "filebase", or "pinata" (the pinning provider). Unset: a configured [ipfs.remote]/[ipfs.filebase]/[ipfs.pinata] section implies its kind (pre-selector configs keep working), otherwise the embedded node |
ipfs.blobs | local ipfs/ dir | RECOMMENDED (public). Embedded-node block/pin storage; same shape as [store.blobs] (local, s3, azure). Use one shared S3 bucket for the cluster model |
ipfs.remote.endpoint | "http://127.0.0.1:8182" | REQUIRED (public) when kind = "remote". A shared sithbit-ipfsd daemon’s pin API (kind = "remote") — the multi-instance shape: the fleet pins through one node instead of each embedding its own |
ipfs.remote.auth_token | (unset) | REQUIRED (public) when the daemon sets one. Bearer token, when the daemon’s auth_token is set |
ipfs.swarm | (unset) | Embedded-node libp2p swarm; omit the section and no swarm runs. An empty [ipfs.swarm] is an isolated swarm (loopback listeners, no bootstrap, no announcements) |
ipfs.swarm.listen | loopback TCP + QUIC, ephemeral ports | REQUIRED (public) when the swarm runs. Multiaddrs to listen on; public participation needs e.g. "/ip4/0.0.0.0/tcp/4001", "/ip4/0.0.0.0/udp/4001/quic-v1" |
ipfs.swarm.bootstrap | [] | RECOMMENDED (public). Bootstrap peers (/…/p2p/<PeerId> multiaddrs) that seed the DHT routing table, keyed by PeerId |
ipfs.swarm.provide | false | RECOMMENDED (public). Announce pinned mail-blob roots as DHT provider records (re-announced every reprovide_interval_secs, default 22 h) |
ipfs.swarm.reprovide_interval_secs | 79200 (22 h) | How often the node re-announces its provider records for every pinned block. DHT provider records expire (~24 h on the public network), so long-lived pins must be re-provided inside that window — keep it below the expiry with some slack, as the default does |
ipfs.swarm.kad_protocol | "/ipfs/kad/1.0.0" | The DHT protocol id. On a private network use "/ipfs/lan/kad/1.0.0" — Kubo keeps private-address peers out of the public DHT and discovers them via its LAN DHT instead |
ipfs.swarm.identity_file | (unset) | REQUIRED (public) when the swarm runs. Persisted ed25519 identity (32-byte JSON seed, created if missing). Without it the PeerId — and every provider record naming it — goes stale each restart |
ipfs.cluster | (unset — solo) | RECOMMENDED (public). Shared-bucket clustering for the embedded node (embedded kind only — a remote daemon clusters via its own [cluster]). An empty [ipfs.cluster] enables membership + partitioned reprovide (needs [ipfs.swarm] with provide = true) + the GC sweep. See Scaling out |
ipfs.cluster.heartbeat_interval_secs | 15 | Membership renewal + roster check cadence; a roster change triggers an immediate reprovide sweep, so this bounds how fast a dead node’s share reassigns |
ipfs.cluster.member_ttl_secs | 60 | Missed renewals this long mark a member dead; its share of the keyspace reassigns to the survivors |
ipfs.cluster.gc_interval_secs | 3600 | Cadence of the shared-bucket GC sweep (delete blocks no pin manifest references); 0 disables it. Concurrent sweeps from several nodes are safe, just redundant |
ipfs.cluster.gc_grace_secs | 3600 | Roughly the age at which an unreferenced block becomes deletable — must comfortably exceed the longest plausible pin upload (a pin writes blocks before its manifest). Not a hard floor: whole-second truncation costs up to a second, give or take any skew between the node’s clock and the shared bucket’s timestamps |
ipfs.repin.from | (unset — no migration) | "filebase" or "pinata": migrate legacy pins from that service (its credential section stays configured) onto the active provider, which must be explicitly "embedded" or "remote". See below |
ipfs.filebase.access_key / secret_key | (unset) | Filebase S3 credentials |
ipfs.filebase.bucket | (unset) | Pinning bucket, e.g. "sithbit-mail" |
ipfs.filebase.endpoint | "https://s3.filebase.com" | Override for testing |
An empty [ipfs] section (plus [grpc]) is a complete chain setup: the
embedded node imports mail blobs with the fixed CID
profile (CIDv1,
sha2-256, raw leaves, 256 KiB balanced dag-pb — byte-identical to
Kubo) and stores blocks/pin manifests in ipfs.blobs.
[ipfs.repin] runs the repin-and-verify migration: an hourly sweep
enumerates every fully chained copy and, per message, fetches the sealed
bytes back from the legacy service, re-pins them on the active
embedded/remote node, and releases the legacy pin only when the
re-imported root CID matches the recorded on-chain one. On a mismatch
(the legacy service imported with a different profile) the legacy pin is
kept — the on-chain CID is immutable and must stay resolvable — and the
sithbit.repin.outcomes{kind="mismatch"} counter grows. Watch that
counter; once it stabilizes the migration is done: remove [ipfs.repin]
(and, if nothing mismatched, the legacy credentials). Requires a store
backend with candidate enumeration (SQLite today); sithbitd refuses the
section otherwise at startup.
kind = "remote" delegates pin/unpin/fetch to a shared
sithbit-ipfsd daemon over HTTP instead of running a
node in-process — the multi-instance shape: the fleet pins through one
node (one swarm identity, one block store) rather than each daemon
embedding its own. [ipfs.blobs] and [ipfs.swarm] are ignored in
this mode; they are the daemon’s to configure.
With [ipfs.swarm] configured the node also joins the IPFS DHT: it
learns peers via identify, and with provide = true announces every
pinned root so stock Kubo peers can discover this node as the content’s
provider (pin manifests are the source of truth — a reprovide sweep
reconciles the DHT records against them on every tick, immediately at
startup). While the swarm runs, the node serves its pinned blocks over
bitswap (/ipfs/bitswap/1.2.0): any connected peer — or one that
found us via a provider record — can ipfs get the content straight
out of the configured blob store. Serving is one-way: the node answers
wantlists but never fetches foreign CIDs. For public retrievability the
listen addresses must be reachable from the internet (an open inbound
port); behind a closed firewall the DHT records carry addresses nobody
can dial.
[account_keys] — at-rest keys for stored-password accounts
Off by default. With no [account_keys] section (or no root inside
it) nothing changes: this is purely additive, and a config file that never
mentions it behaves exactly as it did before the section existed.
At-rest sealing covers password-less accounts, whose stored bodies are encrypted to the account’s own reading key. Accounts that have ever configured a stored mail password are deliberately left out: their CRAM-MD5 and APOP logins prove knowledge of a password, never possession of the X25519 secret a sealed body needs, so such a session could never open one. Their mail is therefore stored in the clear, protected only by whatever the storage backend encrypts at rest — table- or bucket-level encryption, which the storage provider can decrypt on its own.
This section closes that gap. The operator holds one root secret; every account’s at-rest key is derived from it by a domain-separated key-derivation function keyed on the wallet, so one root covers every account with nothing extra to store. The derivation is deterministic, so a restart, a redeploy, or a second replica reproduces the same keys.
| Key | Default | Meaning |
|---|---|---|
account_keys.root | (unset — the feature is off) | RECOMMENDED (public). The operator root secret. A key source: a file path (a bare string) or a kind-tagged cloud secret-manager table. Any material over 32 bytes is accepted — it is stretched through a KDF, not used directly |
What this protects, and what it cannot. A stored-password account’s mail must be readable by the server on demand — that is what reading mail with a password means. So no key arrangement, this one or a hardware key-management service, can stop a compromised running server from reading that mail. What the root buys is that the blob store and the database no longer hold plaintext: a stolen bucket, a leaked table export, or a restored backup yields ciphertext, because the root lives outside the store. Choose where it lives accordingly — a secret manager in production rather than a file on the mail host.
The root is unrecoverable. It is as sensitive as every stored body
combined, there is no escrow, and losing it makes sealed mail permanently
unreadable. Back it up exactly as carefully as store.credential_key_file.
Existing mail is not migrated. Only mail delivered after the root is configured is sealed; bodies already stored in plaintext stay that way, and each is read according to its own at-rest header, so the two coexist with no flag day. The exposure shrinks as mailboxes turn over rather than closing at once.
Trailing whitespace in the root material is ignored. A secret written to a file by shell redirection carries a trailing newline while the same secret read from a cloud secret manager does not; without this, moving a root from one to the other would derive different keys and quietly orphan every sealed body. Leading whitespace is part of the secret. Operators supplying binary material whose final bytes are meaningful whitespace should encode it (base64, hex) first.
Failure behavior differs by side, deliberately. A root that is
configured but cannot be loaded — an unreadable file, an unreachable
secret manager, material under 32 bytes — fails startup: an operator
who named a secret meant their mail sealed, and silently storing it in the
clear is the one outcome they cannot detect from the outside. At read
time an unresolvable key refuses that one message transiently and leaves
the rest of the mailbox listable, because there the mail is already sealed
and the safe answer is to serve less rather than to start wrong.
sithbitd: SMTP & submission settings
Part of the configuration reference, continuing
sithbitd’s core settings: the [smtp] (inbound MX) and
[submission] (authenticated outbound) listeners, sender authentication
(SPF/DMARC), outbound quotas, and the self-service refusal links. See
sithbitd: IMAP, POP & security settings
for the other two listener protocols and the shared TLS/rate-limit
machinery, and sithbitd: spooler settings for what
happens to mail after a listener accepts it.
[smtp], [submission] — the two SMTP listeners
[smtp] is the MX listener (enabled by default); [submission] is the
authenticated-submission listener (disabled by default). Both share the
same shape:
Row markers follow the going-public legend.
| Key | Default | Meaning |
|---|---|---|
enabled | true / false | REQUIRED (public) for [submission]. MX on, submission off by default — a deployment whose own users send mail turns submission on, and gives it a bind address and TLS of its own |
hostname | (discovered) | REQUIRED (public). EHLO greeting name. Unset, the daemon adopts the first local_domains entry (the first configured one, else the alphabetically-first discovered domain), then the machine’s /etc/hostname, then "localhost" — see Identity defaults from the chain below |
greeting | "SithBit ESMTP service ready" | Free text appended after the hostname in the 220 connection banner, which the defaults render as 220 localhost SithBit ESMTP service ready. Cosmetic — nothing in the protocol reads it — but it is the first line every connecting MTA logs, so keep the conventional ESMTP token in whatever you replace it with |
mode | (unset — [smtp] runs as "mx", [submission] as "submission") | Listener role. "mx" is inbound MX — no AUTH, sender policy active, relaying refused. "submission" requires AUTH, allows relaying, and holds the envelope sender to the authenticated user. A [submission] section runs as submission without saying so: the daemon fills the role in when the table names no mode. Writing mode = "mx" under [submission] is still honoured — it runs a second MX listener on its own port — which is why the value is filled in rather than forced, and why an unset mode is distinguishable from an explicit "mx" |
sender_auth | "spf" | RECOMMENDED (public). MX only: "spf", "dmarc-lite", "dmarc", or "none" — see Sender authentication below |
local_domains | (discovered) | REQUIRED (public) unless discovery supplies them. Domains accepted for local delivery. Unset, the daemon adopts every domain the gateway’s signing key is authoritative for on-chain; without a chain connection the empty list falls back to the hostname itself. One deployment may list several — see the startup check below |
dnsbl_zone | (unset) | RECOMMENDED (public). DNSBL zone to check the connecting peer’s IP against at connect, e.g. "zen.spamhaus.org" |
dbl_zone | (unset) | RECOMMENDED (public). DBL zone to check the sender domain against at EHLO and MAIL FROM. A listed domain (or EHLO host) is refused 554 5.7.1. Unset = off. See Domain block list below for the zone string and DQS key |
client_cert_auth | false | RECOMMENDED (public). Request a TLS client certificate and offer SASL EXTERNAL on this listener — meaningful on [submission] (the MX listener does no SASL AUTH). Inert without [submission.tls]. Client auth stays optional, so password clients keep working on the same listener |
self_service_base_url | (unset) | RECOMMENDED (public). Public base URL of the operator’s self-service pages. Set, the postage refusals link the funding page and (under sithbitd) the do-not-disturb refusal links the schedule page — see Self-service refusal links below. Unset keeps every refusal byte-identical to the linkless text |
postmaster_wallet | (unset) | RECOMMENDED (public). Wallet delivered mail for bare postmaster / postmaster@<local-domain> (RFC 5321 §4.5.1), bypassing alias resolution and the frombox/postage gate so external senders — notably DMARC reporters targeting the [spooler.dmarc_rua_ingest] mailbox — can reach it without stamps. Unset keeps postmaster on the normal postage path, byte-identical refusals |
accept_wallet_literals | false | Chain-less/dev instances only (no [grpc] gateway): accept a syntactically valid 32-byte base58 wallet address as the recipient local part, mirroring the account API’s chain-less compose route. No postage check applies without a chain — which is why the default is off: unset keeps the postage gate and every refusal byte-identical. Inert with a gateway configured, since that path already resolves wallet literals and keeps the postage gate |
smtp.max_message_size, submission.max_message_size | 26214400 (25 MiB) | RECOMMENDED (public). Spelled with their sections, unlike their neighbours, because the shared max_message_size is a top-level key of the same name and a bare cell here would read as documenting that one too. The SIZE ceiling in octets, both advertised at EHLO (RFC 1870) and enforced: an oversize SIZE= declaration on MAIL FROM, or a message that outgrows the ceiling mid-DATA/BDAT, is refused 552 5.3.4 and the transaction is aborted. 0 advertises SIZE with no fixed limit. This is a wire size — see [spooler.offload] for how it converts to a decoded attachment size |
max_recipients | 50 | RECOMMENDED (public). Recipients accepted per transaction; the next RCPT TO gets 452 4.5.3 while the recipients already accepted stand, so a sending MTA can split its list across transactions. RFC 5321 §4.5.3.1.10 sets 100 as the floor an MTA should tolerate, so raise it rather than lower it if legacy peers stumble |
max_messages | 10 | RECOMMENDED (public). Messages delivered per connection; the next MAIL FROM is refused 421 4.4.2 and the session closes (RFC 5321 §3.8), so a peer with more to send simply reconnects. Only successful deliveries count, so a refused transaction costs a sender nothing |
max_recipient_errors | 16 | RECOMMENDED (public). Refused recipients tolerated per session — the budget that ends a dictionary attack on your address space. The next RCPT TO gets 550 5.7.0 and the connection is dropped. Over-quota relay refusals are deliberately excluded from the count: “try again later” should never escalate into a dropped connection |
[health], [observability] | (the shared defaults) | RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table |
With the chain pipeline enabled, sithbitd checks every configured
local domain against the chain at startup (via the gateway’s
GetMailDomain): a domain that is unregistered, deactivated, or whose
on-chain authority is not the gateway’s signing key gets one loud
warning in the log — mail to it would otherwise fail silently
per-message at SendMail. The check never blocks or fails the boot,
and the chain-disabled dev stack skips it.
Identity defaults from the chain
With the chain pipeline enabled, the chain itself is the best source for
these two identity fields: at startup sithbitd asks the gateway once
(ListAuthoritativeDomains) for every active domain whose on-chain
authority is the gateway’s own signing key, and any field you left unset
adopts the answer — local_domains takes the whole list, hostname the
first local domain (alphabetically-first, when discovered). Explicitly
configured values are never overridden, the lookup never blocks or fails
the boot (trouble degrades to the static defaults with one warning), and
the boot log states each adopted value and its source (configured /
discovered / system / fallback). The chain-disabled dev stack
skips the lookup and falls through to the machine’s /etc/hostname,
then "localhost".
One caveat: on-chain authority proves protocol authority, not DNS
plumbing. The discovered name becomes the EHLO greeting, and legacy
relays may compare that greeting against forward and reverse DNS — a
domain apex with no matching A/PTR records can cost you deliverability
even though every SithBit-side check passes. If this server fronts
legacy SMTP peers, set hostname explicitly to the listener’s real
FQDN (the one its PTR record names).
Wallet submission envelopes (local_domains)
local_domains has a second job beyond local delivery: it bounds the
envelope sender a wallet-authenticated submission session may use. A
session that logged in as a bare wallet address — the mail
password or a client
certificate, both of which
authenticate the wallet itself — may present exactly
<its own wallet base58>@<a domain this listener is authoritative for>
and nothing else. Another wallet’s address, its own address at a domain
this server does not serve, a case-variant of its own base58, and the
null sender MAIL FROM:<> are each refused 553 5.7.1. Base58 is
case-sensitive — Alice and alice decode to different keys — so the
local part is compared exactly. Submission by alias with a password is
unaffected: the rule is consulted only for wallet-literal identities.
The domain leg has two scopes that stack. local_domains is always the
server’s authority — “is this one of the domains I serve?” — and it
alone gates a chain-disabled listener. But when the listener has a chain
gateway ([grpc] configured), a second, per-wallet check rides on
top: the envelope domain must also be one the authenticated wallet
owns on-chain — i.e. the wallet is that domain’s recorded authority,
looked up through the gateway’s GetMailDomain RPC (an exact base58
authority match). So local_domains scopes which domains the listener
will serve at all, and the on-chain authority check scopes which of
those the authenticated wallet may actually send as. On a multi-domain
instance a wallet may therefore send as itself only at the domains it
owns on-chain, not at every domain the instance serves.
When the listener has no chain gateway (a chain-disabled or
empty-config dev MX, e.g. MemoryVerifier / an empty [grpc]), the
per-wallet lookup is unavailable and the check falls back to
local_domains alone, exactly as before — so an empty-config dev stack
still sends. The daemon (sithbitd) submission path, which wraps the
same driver behind its away-schedule handling, enforces the tightened
rule identically.
The dev-stack trap. When local_domains is empty the check falls
back to hostname alone, and an empty config’s hostname is
"localhost" — the chain pipeline is disabled there, so discovery never
fills the list. Submitting as <wallet>@sithbit.net against that stack
is refused 553 5.7.1 Sender address does not match authenticated user,
whose text names the sender and never hints that the domain was
what failed. The fix is one line — list the domain explicitly on the
submission listener, which carries its own list; the MX section’s
copy does not carry over:
[submission]
local_domains = ["sithbit.net"]
Production instances are largely immune: sithbitd fills local_domains
from chain discovery at startup (see Identity defaults from the
chain above), so the domains the
server serves are exactly the ones its wallets may send from.
Domain block list (dbl_zone)
Where dnsbl_zone scores the connecting IP at connect time,
dbl_zone scores the sender domain: the MX listener queries the
Spamhaus DBL at both EHLO (the greeting
host) and MAIL FROM (the envelope-sender domain), and a listed domain
is refused with a permanent 554 5.7.1 naming it. The value is the
full zone string, and which zone you use is a Spamhaus registration
question, not a syntax one:
- DQS (recommended) — the current Spamhaus form is
<key>.dbl.dq.spamhaus.net, where<key>is your 26-character per-customer Data Query Service code from a free registered DQS account. Example:dbl_zone = "abcdefgh1234567890ijklmnop.dbl.dq.spamhaus.net". - Public
dbl.spamhaus.org(deprecated) — the legacy public zone still resolves, but Spamhaus deprecates it for anything beyond small non-commercial volumes and blocks it from the big public resolvers (Google8.8.8.8, Cloudflare1.1.1.1, Quad9): a query through one of those returns no useful answer. If you use it, point the host at your own recursive resolver, not a public one.
Default None = off: no domain lookups happen and no dbl_zone
line is needed. Following the repo convention, leave the example
commented out with its default when you do add it:
[smtp]
# dbl_zone = "" # off; set to "<key>.dbl.dq.spamhaus.net" to enable
Self-service refusal links (self_service_base_url)
One setting, two pages. Set self_service_base_url to the public base URL
where the self-service pages for refused
senders are
hosted (they ship in the onboarding web bundle, normally one of the
account API’s [[static]] mounts),
and the RCPT-time refusals start telling senders
how to fix themselves:
- the two postage refusals —
450 4.7.0(frombox out of stamps) and550 5.7.0(no frombox) — append; fund it at {base}/fund.html?to=<recipient>&from=<sender>; - under
sithbitd, the do-not-disturb refusal —450 4.2.1(recipient away) — appends; schedule at {base}/dnd.html?to=<recipient>.
Query values are percent-encoded and a trailing / on the base is
trimmed. Unset (the default), every refusal stays byte-identical to
the legacy linkless text — same code, same enhanced status, same line.
The standalone smtp-server dev binary honors the same key but carries
only the funding link: the DND gate (and so the schedule link) is
sithbitd’s. What each page shows the sender is on Do not
disturb.
Sender authentication (sender_auth)
The MX listener’s sender_auth selects how inbound relayed mail is
authenticated, from lax to strict. It has no effect on [submission]
(authenticated submission trusts the logged-in user). The four values:
"spf"(default) — SPF atMAIL FROM, rejecting only a published hardfail (RFC 7208 §8.4); DKIM is verified and recorded in theAuthentication-Resultsheader but never rejects."dmarc-lite"— full DMARC alignment (RFC 9989) at end-of-DATA, but it bounces only onp=rejectwith neither SPF nor DKIM aligned. A raw SPF hardfail no longer rejects on its own, so legitimately forwarded mail carrying an aligned DKIM signature survives.p=quarantineis recorded but not enforced — there is no junk folder at this layer — and enforcement is all-or-nothing."dmarc"— the full DMARC disposition. Alignment is evaluated as indmarc-lite, but the domain’s entire published policy applies:p=rejectbounces (554 5.7.26), andp=quarantineaccepts the message but files the recipient’s copy into theirJunkfolder (auto-created on first delivery — no operator setup). Enforcement is all-or-nothing: there is no sampled fraction any more (see Whypct=no longer does anything).sp=applies to an existing subdomain of the Author Domain’s organizational domain, andnp=to one that does not exist at all (see Subdomain policies and thenp=existence probe below, which an operator behind a caching resolver should read). A"dmarc"MX can also emit RFC 9990 aggregate (rua) reports back to the domains it evaluates — off unless you enable[spooler.dmarc_report]— and per-failure RFC 9991 forensic (ruf) reports, off unless you enable[spooler.dmarc_ruf]."none"— no sender authentication. Intended for tests, offline dev, and submission-only instances.
For an internet-facing MX, run "spf" or stricter; see
the threat model
for why a lax MX is worse than ordinary spam.
Why pct= no longer does anything
SithBit evaluates DMARC as RFC 9989 defines it, and 9989 retired the
pct= tag. A domain that still publishes pct= is not sampled and not
partially enforced: the tag is not even parsed, so it cannot reach the
disposition. The one direction this moves is stricter, never laxer — a
domain publishing p=reject; pct=0 used to have its unsampled mail
downgraded to quarantine, and now has it rejected. If you operate a
domain that was using pct=0 as a “publish the policy but don’t enforce
it yet” switch, publish t=y (test mode) instead: it is 9989’s deliberate
replacement for that use of pct, and receivers apply the policy one
level below the published one (reject → quarantine, quarantine →
none). SithBit honors t=y on inbound mail — the underlying library
applies it while selecting the policy record, so it needs nothing from
this configuration.
Subdomain policies and the np= existence probe
Three tags can name the policy applied to a failing message, and which
one wins depends on where the tree walk found the record that applies.
When the Author Domain publishes its own record, its p= applies. When
it does not — the record came from the organizational domain above it —
the subdomain tags decide: sp= for an existing subdomain, np= for
one that does not exist. Both sp= and np= default to a copy of the
tag above them, so an ordinary record publishing only p= behaves exactly
as before.
“Does not exist” is decided by a live DNS lookup — a single A query
for the Author Domain, performed by the DMARC library on the branch where
the Author Domain published no record of its own, and only when the record
publishes an np= that differs from its sp=. That is one extra query
class on the inbound path, and it is worth knowing two limits of it:
- A resolver that masks NXDOMAIN defeats
np=entirely. Some stub and caching resolvers — systemd-resolved at127.0.0.53among them — answer a non-existent name with NODATA (rcode 0, no answers) rather than NXDOMAIN. The probe then reads “the domain exists” for every name, and every subdomain takessp=. Usually that is harmless, becausenp=defaults tosp=; it is not harmless for a domain publishingp=none; sp=none; np=reject, which gets no enforcement at all on your MX. If you enforce DMARC, point the MX at a resolver that returns NXDOMAIN faithfully rather than at a NODATA-masking stub. - A failed probe falls back to
sp=, silently. A SERVFAIL or a timeout is not a temporary-failure path: the existence answer is simply unknown, and the message is dispositioned undersp=. There is no deferral and no distinct log line for it.
Outbound quotas ([smtp.quota] / [submission.quota])
Both listener sections carry a [quota] sub-section: rolling per-account
limits on the external recipients an authenticated sender may relay
per hour and per day. Only relayed foreign-domain recipients count —
local, on-chain-stamped mail never does, because stamps already price it.
External sends never touch the chain, so no on-chain fee prices them;
this quota is the off-chain counterpart, and its new-account ramp (below)
is the operational form of “reputation reduces sender friction”: a
week-old account earns double a new one’s allowance, doubling each week
up to the cap.
| Key | Default | Meaning |
|---|---|---|
enabled | true | RECOMMENDED (public). Master switch for the quota math only. Suspension (below) is independent — a suspended account is refused even with quotas off — and accepted external recipients are still counted while disabled, so the ledger is truthful if enforcement is enabled later |
base_hourly | 50 | RECOMMENDED (public). Hourly external-recipient allowance for a brand-new account |
base_daily | 200 | RECOMMENDED (public). Daily external-recipient allowance for a brand-new account |
max_hourly | 500 | RECOMMENDED (public). Ceiling the hourly allowance ramps up to |
max_daily | 2000 | RECOMMENDED (public). Ceiling the daily allowance ramps up to |
The effective allowance is min(cap, base × 2^account_age_weeks) —
at the defaults:
| Account age | Hourly | Daily |
|---|---|---|
| 0 weeks | 50 | 200 |
| 1 week | 100 | 400 |
| 2 weeks | 200 | 800 |
| 3 weeks | 400 | 1,600 |
| 4+ weeks | 500 (cap) | 2,000 (cap) |
How enforcement behaves on the wire:
- Per external RCPT: an over-quota external recipient is refused
452 4.5.3(transient — “try again later”); local recipients in the same transaction are unaffected, and quota refusals deliberately do not burn the session’s recipient-error budget, so a well-behaved client finishes the transaction for its accepted recipients and retries the refused one after the window rolls. - Recording happens on acceptance only — a refused or all-local message adds nothing to the counters.
- Counters key on the wallet: an alias login resolves to its wallet first, so aliases share the wallet’s counters (and its suspend flag) rather than getting their own.
- The counters live in the account store — hour-bucketed rolling windows,
on every
[store]backend alike.
The account API enforces the same policy on compose through its own
twin [quota] section (below) — the two are twins by
design and must be kept in step, so a sender meets one policy
whichever submission surface they use. Following the zero-config rule,
the defaults are complete; the commented block:
# [submission.quota] # ([smtp.quota] takes the same keys)
# enabled = true
# base_hourly = 50
# base_daily = 200
# max_hourly = 500
# max_daily = 2000
Alongside the quotas rides the account suspension flag, set and
cleared over the admin API and honored by every server (SMTP AUTH/MAIL,
IMAP, POP, compose) regardless of enabled. The refusal codes per
surface, the admin endpoints, and complaint handling are in
Monitoring — outbound quotas and suspension.
One scope note: the standalone smtp-server dev binary parses the
[quota] section (same config shape) but wires no account store, so the
gate is inert there — enforcement is sithbitd’s (and the account
API’s) job.
sithbitd: IMAP, POP & security settings
Part of the configuration reference, continuing
sithbitd’s core settings: the [imap] and [pop]
listeners, the cross-connection login-attempt budget, the on-chain
mailbox-login gate, the per-wallet storage cap, the shared [*.server]
listener shape and its production TLS posture, and client-certificate
(SASL EXTERNAL) authentication — all of which the SMTP listeners on
sithbitd: SMTP & submission settings share too.
[imap], [pop]
Row markers follow the going-public legend.
| Key | Default | Meaning |
|---|---|---|
enabled | true | Per-protocol toggle |
imap.hostname | "localhost" | REQUIRED (public). Named in the greeting and in CRAM-MD5 challenges. Unlike the SMTP listeners’ hostname it is never discovered from the chain — set it to the public IMAP name your clients, and domain-sithbit’s autoconfiguration records, use |
pop.hostname | "localhost" | REQUIRED (public). Named in the +OK greeting and in the CRAM-MD5/APOP challenges — the POP twin of the row above, and equally undiscovered from the chain |
imap.watch_poll_seconds | 0 | RECOMMENDED (public). Split deployments only: poll for mailbox changes every N seconds to feed IDLE when deliveries happen in another process. 0 trusts in-process push — except on an IMAP-only instance (both SMTP roles disabled), where nothing delivers in-process, so a 0 auto-adopts 5 with an info log; an explicit value is always honored. See Role-split topologies |
imap.client_cert_auth | false | RECOMMENDED (public). Request a TLS client certificate and offer SASL EXTERNAL. Inert without [imap.tls]; client auth stays optional |
pop.client_cert_auth | false | RECOMMENDED (public). Same, for POP: EXTERNAL joins the CAPA SASL line once TLS is live — on POP3S from the greeting, and equally on a plaintext listener the moment it upgrades with STLS, which the offer re-reads mid-connection. Inert without [pop.tls]; client auth stays optional |
imap.max_message_size | 26214400 (25 MiB) | RECOMMENDED (public). Largest APPEND literal accepted, in octets — deliberately the same ceiling as the SMTP listeners’ max_message_size, so a message that arrived can always be uploaded back. An oversize literal is refused with a tagged BAD and its bytes discarded; the session carries on. This number is not enforcement-only: it is also what the server advertises as its APPENDLIMIT capability (RFC 7889) and what a synchronizing literal’s refusal names in its text, so a client can read the ceiling before uploading and is told it again when refused. The command line wrapped around the literal gets 64 KiB of headroom on top |
imap.max_login_attempts | 3 | RECOMMENDED (public). Failed logins tolerated before the session ends with BYE. Each failure also tarpits its own refusal (2s, then 4s, doubling), so this is the backstop rather than the primary rate limit; a cancelled AUTHENTICATE costs no attempt. 0 is not unlimited — the session counts the failure before comparing, so it behaves exactly like 1: one strike, hang up on the first failure, no tarpit. Per CONNECTION: it is spent and forgotten when the socket closes, so the budget that survives a reconnect is [auth_rate_limit] below |
pop.max_login_attempts | 3 | RECOMMENDED (public). Failed logins tolerated on one connection before the session hangs up. Each failure also tarpits its own refusal (2s, then 4s, doubling), so this is the backstop rather than the primary rate limit; POP3 allows one reply per command, so the final refusal is the goodbye. 0 is not unlimited — the session counts the failure before comparing, so it behaves exactly like 1: one strike, hang up on the first failure, no tarpit. Per CONNECTION, like IMAP’s: the budget that survives a reconnect is [auth_rate_limit] below |
imap.idle_command_timeout_secs | 1800 (30 min) | How long a client may sit silent inside an accepted IDLE before the session ends, in seconds. A client in IDLE is deliberately silent far longer than the shared read deadline tolerates, so while the exchange is active this deadline replaces the listener’s limits.idle_timeout_secs, restored the moment DONE (or expiry) ends it — RFC 2177 tells clients to re-issue IDLE at least every 29 minutes, and the default allows the full half hour, that interval plus a minute of slack. At shipped defaults the two deadlines coincide, since limits.idle_timeout_secs is 1800 too, so the substitution raises nothing; the seam earns its keep because the two settings move independently — an operator who tightens the shared read deadline for every other command does not thereby hang up on a conformant idler. Expiry here is a protocol goodbye rather than the shared deadline’s silent close: the server sends an untagged * BYE IDLE timed out and closes cleanly |
pop.enable_apop | false | Advertise APOP by putting a per-connection timestamp banner in the +OK greeting (RFC 1939 §7). Off by default because APOP digests the secret itself: it only works for accounts whose mail password the server can recover in plaintext — the same requirement CRAM-MD5 carries, and the same trade described under mail passwords |
smtp.enable_stored_passwords, submission.enable_stored_passwords, pop.enable_stored_passwords | true | RECOMMENDED (public). Advertise CRAM-MD5 on that listener. Set to false — together with account-api’s key of the same name, which stops new passwords being stored — to retire stored mail passwords fleet-wide, which is what keeps delivered mail sealed at rest for every account: a stored password is plaintext-recoverable by the server, and the challenge mechanisms it exists for could never unwrap a sealed body’s key. Passwords already stored keep verifying over PLAIN/LOGIN until each is cleared with DELETE /v1/account/password; pop.enable_apop is independent, so leave it off too. IMAP never offers CRAM-MD5 and has no such key |
[health], [observability] | (the shared defaults) | RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table |
[auth_rate_limit] — the cross-connection login budget
max_login_attempts above is spent and forgotten when the socket closes,
so a guesser that reconnects after every third try keeps a steady rate
forever. This table is the budget that survives the reconnect: it
remembers the (client address, account) pair, and once that pair has
spent its failures inside the window every further attempt is refused
unchecked — no store lookup, no password comparison. It is on by
default; the defaults are complete, and the commented block reads:
# [auth_rate_limit]
# max_failures = 10
# window_secs = 900
# max_tracked = 10000
| Key | Default | Meaning |
|---|---|---|
max_failures | 10 | RECOMMENDED (public). Failures one (client address, account) pair may accumulate inside the window before further attempts are refused. 0 is not unlimited — the failure is counted before the comparison, so it behaves exactly like 1, deliberately the same spelling as max_login_attempts |
window_secs | 900 (15 min) | RECOMMENDED (public). How long a failure is remembered, in seconds. The window slides on every counted failure, so a pair that keeps guessing stays locked out; a refused attempt does not extend it, so a locked-out client always recovers window_secs after its last counted failure. 0 disables the limiter entirely, matching limits.handshake_timeout_secs’ spelling of “off” |
max_tracked | 10000 | RECOMMENDED (public). Ceiling on tracked pairs, so a spray across random logins cannot grow the table without bound. At the ceiling expired entries are swept first; if that frees nothing the new pair goes untracked (the limiter fails open for it, logged once) while pairs already counting keep counting — flooding the table cannot un-track an attack already in progress |
Nine behaviours decide whether the defaults suit your deployment, and none of them are guessable from the key names:
-
One table for the whole daemon.
sithbitdshares a single limiter across POP, IMAP, the MX listener and submission. A per-protocol[pop.auth_rate_limit]/[imap.auth_rate_limit]/[smtp.auth_rate_limit]/[submission.auth_rate_limit]still parses — the sections exist on the underlying configs — but is ignored. The daemon logs a startup warning naming the section it dropped, but only when that section was tuned away from the defaults above — a block carried across unchanged is dropped in silence, because dropping it changes nothing. So a quiet boot means no tuning was lost, not that no per-protocol section was present. Private per-protocol budgets would let a guesser rotate POP → IMAP → MX → submission against one account for four times the allowance. The standalonepop-server/imap-server/smtp-serverdev binaries are the exception: each reads its own[auth_rate_limit]section, because each is one protocol. Their shipped example files (pop_server.toml,imap_server.toml,smtp_server.toml) each carry the table commented out with these same defaults written into it, so the budget in force is visible in the file you are editing — and uncommenting the header alone changes nothing, since every key under it defaults to the value shown. -
SMTP has no per-connection sibling. POP and IMAP back this budget with
max_login_attempts; SMTP has nothing of the kind — the session counts no failures, tarpits nothing, and never hangs up on a guesser. On the submission listener this table is therefore the only authentication budget in the stack, which is why its refusal has to close the connection itself and why turning it off (window_secs = 0) costs more here than it does on the other two protocols. -
The SMTP refusal is two replies and a close, and the wire code is ambiguous by design:
454 4.7.0 Too many authentication failures; try again in 873 seconds 421 4.7.0 Too many authentication failures, closing transmission channel454 4.7.0is RFC 4954 §6’s temporary authentication failure — the completion theAUTHcommand is owed, since a silently dropped socket reads to a mail client as a server fault it should retry — and421is RFC 5321 §3.8’s announced close. That same454 4.7.0is what a credential-store outage answers (454 4.7.0 Temporary authentication failure), deliberately: a locked-out client must not be able to tell a rate limit from an outage. When you are reading logs, the message text is what distinguishes them, not the code. Both SMTP roles answer identically, byte for byte, retry hint included, so a guesser rotating between MX and submission cannot tell from the refusal which one it is on. POP answers-ERR [AUTH] too many authentication failures; try again laterand IMAP a taggedNO [UNAVAILABLE]plus* BYE; neither discloses the remaining seconds, because only on SMTP is a 4xx an instruction to retry. -
The refusal is not tarpitted. Unlike
max_login_attempts, whose every refusal is delayed on a doubling schedule (2s, then 4s, …), a cross-connection refusal is answered immediately and the connection slot comes straight back. Half of what this budget buys is that slot: a limiter that made a locked-out client wait would spend the server’s own concurrency defending against it. -
The address is the effective client address — the one the accept loop resolved through any PROXY protocol preamble, i.e. the client behind a load balancer and not the balancer. That makes
proxy_protocolload-bearing for this control: left off on a listener that really is behind a balancer, every user of that balancer shares one bucket, and ten failures anywhere in the fleet lock all of them out of the account they were failing on. -
The key is the pair, not the address alone, so one hostile login from a shared NAT or a carrier-grade address cannot lock out a co-located neighbour’s account. The account half is normalized before keying: trimmed, ASCII-lowercased (varying the case of a login must not buy a fresh budget) and truncated to 128 bytes (an unauthenticated peer picks the string, so the untruncated form would let it grow the table by megabytes per entry). The domain part is kept — two logins that differ past the
@are two accounts, and merging them would let one tenant lock out another. A login the server could not parse at all normalizes to the empty string, which gives every such attempt from one address a single shared bucket. -
Only a failed credential comparison moves the count, and the two cases an operator will actually meet are the interesting ones. A credential-store outage moves it in neither direction: one store blip cannot lock a whole deployment out, and equally a client able to provoke store errors gets no free reset of the count it has already run up. A refusal raised after a credential verified — a suspended account, an unreachable postage gate, a POP maildrop another session already holds — clears the pair wherever the server can tell that is what happened, because whoever just proved they own the account is not the guesser this table is for. POP and SMTP can, and do; that matters in practice, since a second concurrent POP session is an everyday event and charging it would lock a legitimate user out for the whole window. IMAP deliberately does neither: the same refusals reach its driver from a pre-credential store lookup as well, and it cannot tell the two apart, so charging would let a store blip lock out a legitimate user while clearing would let the same blip erase a guesser’s accumulated count.
-
Recovery needs no restart and no admin action. The ban lapses on its own
window_secsafter the pair’s last counted failure — refusals in between do not push it out — and a successful login forgets the pair immediately. A user who waits, or who finally gets their password right from another address, is simply let back in. -
The counters are in-process and per-replica. Nothing is shared through the store, so two
sithbitdreplicas over one cloud store each keep their own table and a guesser reaching both gets two budgets. Sizemax_failuresper replica and expect the effective ceiling to scale with the instance count — the same caveat every in-memory control in the fleet carries (see Known seams).
Every refusal logs at WARN with the client address and the seconds
remaining — authentication refused: too many recent failures for this client and account — and a table that hits max_tracked logs
auth rate-limit table full; new client/account pairs go untracked
once, not once per login, so a saturated table is visible without
drowning the log.
A misspelled setting here fails startup naming the key, the same
deny_unknown_fields contract [*.server]’s limits carry below.
login_requires_mailbox — the on-chain mailbox login gate
| Key | Default | Meaning |
|---|---|---|
login_requires_mailbox | false | RECOMMENDED (public). Whether an IMAP or POP session may only open for a wallet that owns an on-chain mailbox. Top level, not under [imap] or [pop]: one posture for the whole daemon, so the two protocols cannot disagree about it. Written above the first section header in sithbitd.toml, or it reads as a key of whichever section precedes it. Env override SITHBITD_LOGIN_REQUIRES_MAILBOX, the standard spelling |
A SithBit login is self-proving: the credential is a wallet signature
(or a client certificate bound
to a wallet), so what it proves is a keypair, not an account. At the
shipped default the daemon takes that as enough — anyone who generates a
keypair can log in as that address, and the daemon creates storage for it
on the spot. Setting login_requires_mailbox = true makes it ask the
chain first: no mailbox account for that wallet, no session.
- The check runs before anything is written, which is the whole point. An IMAP session-open mints an INBOX row; a POP session-open mints the maildrop lease and the INBOX row. The gate answers ahead of all three, so a refused login leaves the store exactly as it found it — a gate consulted afterwards would refuse the session having already created the storage it exists to withhold. The suspension check is the one thing that still runs first, because it only reads.
- What the refused client is told. IMAP answers
<tag> NO [AUTHORIZATIONFAILED] no on-chain mailbox for this account; create one before logging in, and POP answers-ERR [SYS/PERM] no on-chain mailbox for this account; create one before logging in— one text, byte for byte, however the client arrived. The codes say authorization, not authentication: the credential proved out, so neither reply sends the holder back to re-enter a password that works. The remedy is in the text —sithbit mailbox create, and the login then works untouched. - A chain outage is a temporary failure, never that refusal. When the
gateway cannot be reached the daemon does not fall back on either posture:
IMAP answers
<tag> NO temporary authentication failure; try again laterand POP answers-ERR [SYS/TEMP] temporary server problem, both retryable. A passing outage must not lock a real account out, so the permanent refusal above is reserved for an answer the chain actually gave. - It costs one gateway round-trip per session open, and it needs
[grpc]. The lookup is aGetMailboxagainst the chain gateway; with the gate off no call is made at all, so the loopback dev stack’s login path is unchanged. The trap worth knowing before you set it:login_requires_mailbox = trueon a daemon with no[grpc]section has nothing to ask, and the gate admits everyone. The daemon says so at boot —login_requires_mailbox is set but no [grpc] gateway is configured: logins are NOT checked against the chain— but it starts anyway, because refusing to boot would break the zero-config dev stack. Read the warning as “the gate is off”, not as a nag. - What it does and does not cover. It gates the two session-opens that create storage — IMAP’s and POP’s. SMTP submission authentication is not gated, because it creates no account storage of its own, and neither is account-api’s wallet-challenge login, which is a separate binary with no switch of its own; that surface answers the same finding a different way, by provisioning nothing until a signature has verified.
- How a refusal moves the login budgets. Per connection it is an
ordinary failed attempt on both protocols: it spends one of
max_login_attemptsand is tarpitted like any other. Across connections the two differ, for the reason the cross-connection budget gives above — POP clears the (client address, account) pair, since the refusal can only be reached past a credential that verified for that very account, while IMAP neither charges nor clears it, since it cannot tell this refusal apart from one raised before any credential was looked at.
max_wallet_bytes — the aggregate per-wallet storage cap
| Key | Default | Meaning |
|---|---|---|
max_wallet_bytes | 0 | RECOMMENDED (public). Aggregate bytes ONE wallet may hold in this store — every mailbox it owns, and every stored blob those messages reference, counted once per wallet however many of its mailboxes point at the same blob. 0 (the default) is unbounded. Top level, beside login_requires_mailbox and for the same reason: one number for the whole daemon, so delivery and IMAP cannot disagree about how full a wallet is. Written above the first section header in sithbitd.toml, or it reads as a key of whichever section precedes it. Env override SITHBITD_MAX_WALLET_BYTES, the standard spelling |
The shipped default is no cap at all: the store grows until the disk does, which is how every SithBit deployment has run so far. Setting a byte count makes a write that would carry a wallet past it fail instead of succeeding — advisory rather than exact, since usage is read and the write happens separately, so writes in flight together can overshoot by their own combined size. Size a hard ceiling with that headroom.
- It is measured per wallet, not per mailbox. A wallet’s INBOX, Sent, Junk and every folder it has made share one budget, and a blob two of its mailboxes both reference is charged once. That is what makes the number an operator can reason about — the size of one account on disk — rather than a per-folder allowance that a client sidesteps by filing mail elsewhere.
- A refusal is transient on every path, and says so. An SMTP delivery
to a full recipient answers
452 4.2.2 Mailbox full, an IMAP APPEND answersNO [OVERQUOTA], and account-api’s compose answers413 Payload Too Large. None of them is a permanent rejection: a recipient who prunes receives the mail on the sending server’s next retry, with no bounce in between. - Mail clients can see the cap. IMAP’s
GETQUOTA/GETQUOTAROOT(RFC 9208) surface this ceiling — and the wallet’s current usage against it — to any connected mail client, so a user can watch fullness approach instead of meeting it as a refusal. With the default0no quota is advertised at all:GETQUOTAROOTreports no quota roots, which clients render as “no quota”. The usage shown is this setting’s own per-wallet, deduplicated meter, not per-folder arithmetic — see the conformance appendix for how that reads to a client that does its own sums. - Bounce reports are exempt, deliberately. A delivery-status
notification for a message this wallet sent is written straight to its
INBOX and is never refused, because the one report that must survive a
full mailbox is the one saying the mailbox is full — refuse it and the
sender never learns their mail is failing. The cost is a bounded
trickle: a sender can push their own stored bytes a little past the
ceiling by sending mail that bounces, bounded by their own outbound
volume, which
[quota]already limits. - The SMTP verdict answers for the whole envelope. A message with five recipients, one of them full, is refused to all five and re-sent to all five — the spool reports one outcome per transaction, not one per recipient. See the delivery sink’s own note before choosing a number tight enough for that to happen routinely.
- Set account-api’s key of the same name to the same
number. The two binaries write into one store, so a ceiling only
sithbitdholds is a ceiling any sender walks around by composing in webmail. The two configs are twins on purpose and are documented as such; nothing at startup can check that they agree, because neither process reads the other’s file.
[*.server] — the shared listener section
Every listener ([smtp.server], [submission.server],
[imap.server], [pop.server]) takes the same fields:
| Key | Default | Meaning |
|---|---|---|
bind_addr | 127.0.0.1:2525 / :1430 / :1100 | REQUIRED (public). The loopback defaults reach nobody off this host, and the address is enforced at startup the moment the table exists (see below). Listen address (the only per-listener default that differs: SMTP 2525, IMAP 1430, POP 1100; the submission listener has no distinct default — set it explicitly when enabling) |
implicit_tls | false | REQUIRED (public). The production posture for submission, IMAP and POP (below). Wrap the socket in TLS at accept instead of STARTTLS/STLS |
proxy_protocol | false | RECOMMENDED (public). Expect a PROXY protocol preamble and attribute sessions to the client it names. Only behind an L4 balancer — never on a directly reachable listener (the preamble is spoofable), and clients that don’t send one are dropped. It also decides what the cross-connection login budget keys on: that budget uses the effective client address, so leaving this off on a listener that really is behind a balancer puts every user of that balancer in one bucket |
proxy_trusted | [] | REQUIRED (public) when proxy_protocol is on, and that one is enforced at startup. CIDR allowlist of the socket peers permitted to speak PROXY protocol, e.g. ["10.0.0.0/8", "2001:db8::/32"] (bare addresses count as /32 or /128; a v4 entry also matches v4-mapped peers on dual-stack listeners). Untrusted peers are refused before a single header byte is read, so a stray direct client can’t spoof its address even if it reaches the port. Required whenever proxy_protocol is on: an empty list fails startup, because the allowlist is the only thing standing between a reachable port and a peer that asserts whatever client address it likes. To deliberately trust every peer — appropriate only when nothing but the balancer can reach the port — say so explicitly with ["0.0.0.0/0", "::/0"]. Ignored unless proxy_protocol is on; a malformed entry fails startup naming it |
limits.max_connections | 1024 | RECOMMENDED (public). Concurrent connections across the listener |
limits.max_per_peer | 16 | RECOMMENDED (public). Concurrent connections per peer IP |
limits.idle_timeout_secs | 1800 (30 min) | Session idle timeout. IMAP sets the value: RFC 3501 §5.4 requires an inactivity autologout of at least 30 minutes, so a shorter shared deadline broke that MUST every time an IMAP session fell quiet. POP3 (RFC 1939 §3) and SMTP (RFC 5321 §4.5.3.2) state only smaller minimums of their own, and a deadline clearing the largest minimum clears the rest — which is why one setting stays conformant on all three listeners. The cost is that a silent session holds its connection slot for half an hour; bound that with max_connections and max_per_peer rather than by lowering this under 1800 on a listener serving IMAP. On IMAP it does not bound an accepted IDLE: that exchange runs under imap.idle_command_timeout_secs instead, and this deadline is restored the moment the exchange ends |
limits.handshake_timeout_secs | 30 | Deadline for a client to finish the TLS handshake — one setting covering both paths: the implicit-TLS accept and the STARTTLS/STLS upgrade. A peer that connects and then never completes one would otherwise hold its connection slot until the kernel gave up on the socket. Expiry is a silent close, matching the other admission refusals, so the slot is freed. 0 disables the deadline |
limits.write_timeout_secs | 60 | Deadline for one write to the client to make progress. Reads are bounded by idle_timeout_secs; writes were not, so a peer that stops draining its socket could wedge the session task behind TCP backpressure. The clock restarts whenever the peer accepts any bytes, so it expires only on a peer taking none at all for the whole window — a dead or hostile reader, not a slow one. 0 disables the deadline |
bind_addr becomes required the moment the table exists. It is the
one field here with no default of its own — the listen addresses above
are defaults for the whole absent section, not for the field. So
uncommenting a single limit under [imap.server.limits] and nothing else
fails startup with missing field bind_addr in imap, which reads as a
puzzle when all you changed was a timeout. Uncomment the listener’s
address line as well — that is exactly why the example files write it
directly under every [*.server] header. The same holds for
[smtp.server], [submission.server] and [pop.server]: there is
deliberately no protocol-neutral fallback, since each listener’s port is
part of its identity.
A misspelled key here fails startup rather than being ignored. Both
this table and its limits sub-table reject names they do not recognise,
so writing idle_timeout_sec (no trailing s) stops the daemon with an
“unknown field” error naming the key and listing the ones it accepts,
instead of silently leaving the default in force while you believe you
overrode it. That listing is the authoritative key set for whichever
level the typo is on, so read it before reaching for this page.
REQUIRED (public). [smtp.tls] / [submission.tls] / [imap.tls] /
[pop.tls] each take certs and key (the PEM certificate chain and private key, e.g.
fullchain.pem / privkey.pem). Each is a key
source: a file path (default), or
a cloud secret-manager secret holding the PEM. With no TLS section the
listener runs plaintext — fine on loopback, not on the internet.
One certificate for every listener: the top-level [tls]
Four listeners on one host almost always terminate the same wildcard
or multi-SAN certificate — they must, since clients reach
mail.<domain>, imap.<domain> and pop.<domain>. Writing it four
times meant four places to miss on a renewal, so the daemon takes it
once at the top level:
| Key | Default | Meaning |
|---|---|---|
tls.certs | (absent — plaintext listeners) | REQUIRED (public). PEM certificate chain inherited by [smtp.tls], [submission.tls], [imap.tls] and [pop.tls]. A key source, same as the per-listener spelling |
tls.key | (absent — plaintext listeners) | REQUIRED (public). PEM private key for that chain — same key source forms |
A listener that writes its own [*.tls] keeps it, so a deployment
that really does present a different certificate on one port still can.
Inheritance only fills in the listeners that named none, which means an
existing config file that writes all four behaves exactly as it did.
[tls] is a table, so — unlike hostname and local_domains — it can
sit anywhere in the file.
Production posture: implicit TLS and require_tls (RFC 8314)
The dev defaults are loopback plaintext, but the production posture is RFC 8314: TLS on connect for submission and access, and no credentials offered before the connection is protected. Two settings carry it:
implicit_tls = trueon the[submission.server]/[imap.server]/[pop.server]listeners, bound to the standard secure ports — 465 (submission, SMTPS), 993 (IMAPS), 995 (POP3S) — so the socket is wrapped in TLS at accept, with no STARTTLS/STLS round trip. The MX listener on 25 stays plaintext-with-STARTTLS by nature (foreign MTAs reach it that way). The STARTTLS/STLS secondaries on 587/143/110 (implicit_tls = false) are an opt-in for legacy clients; advertise them at a lowerSRVpreference (DNS setup).require_tlsrefusesAUTH/LOGIN/USERuntil TLS is active. It is on by default for the SMTP submission edge (mode = "submission"), IMAP, and POP, so the production posture needs no config entry for it — the MX listener does no SASL AUTH and is unaffected. The three protocol-appropriate enforcement guards are detailed in the conformance reference.
mail_spooler/sithbitd.example.toml ships the full implicit-TLS stack as
commented [submission.server] / [imap.server] / [pop.server] +
[*.tls] blocks, and docker-compose.prod.example.yml publishes the
465/993/995 primaries (plus 25 MX) with the STARTTLS ports commented out —
see Running a mail server: Production.
Client-certificate auth (SASL EXTERNAL)
The client_cert_auth toggle on [submission], [imap], and [pop]
turns on the passwordless login path: a client presents a
self-signed Ed25519 TLS client certificate whose public key is the
wallet’s 32-byte signing key (the wallet address, and so the
mailbox/maildrop identity), and the server authenticates it as that
wallet via SASL EXTERNAL — the
completed TLS client-auth handshake is the proof of key possession, so
nothing is stored server-side. It is the alternative to the derived
mail password (sithbit mailbox credentials, SASL PLAIN);
sithbit mailbox create-cert mints the certificate (see
Thunderbird
/ Outlook).
The certificate’s subject/SAN are ignored — only the SPKI key binds — and
an empty or wallet-matching SASL authzid is accepted while a mismatching
one is rejected.
The toggle is off by default — an empty config file leaves it off on
all four of sithbitd’s listeners, and a test fences that — and it is
inert unless the matching […tls] section is present, since client-cert
auth exists only over TLS. Turning it off does not disable AUTH:
the password mechanisms (PLAIN, LOGIN, CRAM-MD5, APOP) are unaffected
either way, and only the one mechanism the transport cannot back is
withheld. Turning it on makes the listener merely request the client
certificate (optional TLS client auth), so password clients keep
connecting on the same port.
EXTERNAL is advertised if and only if the listener really sends a TLS
CertificateRequest. Each binary reads client_cert_auth in exactly one
function — listener_tls in sithbitd / pop-server / imap-server /
smtp-server, one shared helper rather than a copy per binary — and that
function returns the acceptor and the client-auth mode it was built with
as a single value; every handler gates the offer on that recorded mode,
never on the config boolean. There is no consistency here for you to
maintain, because there is one setting and one read of it — now literally
one, in one place, for every listener the project ships. The corollary: you can no
longer produce a listener that offers EXTERNAL but cannot complete it.
The old failure mode — clients see the offer, attempt it, fail, and on
IMAP burn a login attempt each time — is not reachable by configuration.
If EXTERNAL is missing from your CAPABILITY / CAPA / EHLO, the cause
is client_cert_auth = false, or no [*.tls] section at all, never a
wiring mismatch. (The guarantee runs acceptor → advertisement, so a
client-auth acceptor paired with client_cert_auth = false would still
advertise it — a pairing no binary’s config path can produce, since the
flag is what picks the acceptor, and constructible only in tests.)
Three things that invariant does not promise:
- It is per listener, not per daemon.
sithbitdreads its[submission],[imap]and[pop]copies of the flag independently — and[smtp]’s too, though the MX listener does no SASL AUTH — and nothing cross-checks them. On for IMAP and off for POP is valid and silently accepted, which makes it a plausible mistake: set it on every authenticated listener, or on none. - A silently absent offer is still possible.
client_cert_auth = truewith no matching[*.tls]section is inert and warns nothing, and so is a[*.tls]section on a listener whose clients never reach implicit TLS or STARTTLS. What is guaranteed is that no false offer is made; nothing is guaranteed about a missing one. - It is about advertisement and completability, not authorization. Whether the wallet a presented certificate proves is one this daemon will serve is the SASL EXTERNAL verify step’s business, and is unchanged by any of the above.
So advertising the mechanism and accepting it remain two different gates: the offer follows the listener and the live channel, never the individual peer — a client that sent no certificate is still offered EXTERNAL — while acceptance requires that the peer really did present a certificate whose Ed25519 key matches the wallet it authenticates as. A certificate-less client is refused at the SASL step, so nothing but a matching key ever logs in.
The second gate is why the offer can appear mid-connection. All three
protocols re-read the pair at every use — one mechanism list feeds the
EHLO / CAPABILITY / CAPA advertisement and the AUTH /
AUTHENTICATE accept check — so EXTERNAL is withheld while the socket is
still cleartext, however the listener is configured (the certificate that
proves the identity does not exist until TLS is up), and appears the
instant an in-band upgrade completes: STARTTLS on 587, STLS on 110. The
same connection that was refused AUTH EXTERNAL in the clear is offered
it, and logs in with it, after the upgrade. POP used to be the exception —
its mechanism list was fixed at connection start, so EXTERNAL reached
implicit-TLS listeners (POP3S, port 995) only — and is not any more; the
three listeners now behave identically, and a [pop.tls] section with
client_cert_auth = true is enough on port 110.
sithbitd: spooler settings
Part of the configuration reference, continuing
sithbitd’s core settings: the [spooler] background
workers — relay, DSNs, the smarthost and DKIM signing, MTA-STS and DANE,
the auto-settle sweeper — plus DMARC aggregate (RUA) and forensic (RUF)
reporting, inbound DMARC report ingestion, SMTP TLS reporting (TLS-RPT),
and large-attachment IPFS offload. This is what turns mail a listener
accepted (see the SMTP / IMAP, POP &
security pages) into delivered mail.
[spooler] — outbound workers
Row markers follow the going-public legend.
| Key | Default | Meaning |
|---|---|---|
enabled | true | Run the background workers — relay, DSN, chain pin/send + delete, the auto-settle sweeper, the reconciler, and the repin migration — as one unit. false makes a listeners-only role instance: mail is still accepted and spooled, and a worker-enabled sibling over the same shared store drains the queues. The DMARC RUA/RUF workers keep their own switches, and the embedded IPFS swarm is unaffected. See Role-split topologies |
spooler.hostname | "localhost" | REQUIRED (public). EHLO name, Reporting-MTA, and the MAILER-DAEMON domain. Inherits the shared top-level hostname while still on the built-in value |
spooler.local_domains | [] | REQUIRED (public). Domains the DSN builder treats as locally deliverable. Inherits the shared top-level local_domains while empty |
delay_dsn | false | Emit “delayed” DSNs on retry schedules |
mta_sts | true | RECOMMENDED (public). Honor recipient domains’ MTA-STS policies (RFC 8461) on direct-to-MX delivery. Ignored when [spooler.smarthost] is configured |
dane | true | RECOMMENDED (public). Honor recipient MX hosts’ DANE TLSA records (RFC 7672) on direct-to-MX delivery, DNSSEC-validated; preferred over MTA-STS where both exist. Ignored when [spooler.smarthost] is configured |
dead_retention_days | 30 | Hourly prune of dead-lettered jobs buried longer ago than this many days; 0 = never prune. Entries with no readable bury date count as older than any cutoff — see Monitoring |
report_retention_days | 0 | RECOMMENDED (public). Hourly prune of stored report blobs older than this many days — ingested DMARC aggregate reports under dmarc_rua/ and pending TLS-RPT result rows under tlsrpt/pending/; 0 (the default) = never prune, keep forever. dmarc_rua/ is the data GET /v1/admin/dmarc-reports serves — enabling retention removes reports from that admin surface once they age past the window, which is exactly why the default never does so un-asked. The worker only runs with enabled = true above |
spooler.settle.enabled | true | Run the auto-settle sweeper (only when the chain pipeline is enabled) |
spooler.settle.after_days | 30 | Post-delivery grace window before a delivered copy is settled |
spooler.settle.keep_pin | false | RECOMMENDED (public). Keep the IPFS pin at settlement instead of releasing it |
[health], [observability] | (the shared defaults) | RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table |
One more hourly prune runs alongside the two retention prunes above and
carries no setting at all: the daemon deletes every expired
login-challenge row (the account API’s auth_nonces, written by
POST /v1/auth/nonce) each hour, unconditionally — even with
enabled = false above, since a listeners-only instance shares the
store those rows live in. There is nothing to configure because there is
no retention policy to pick: each row carries its own ~300-second expiry
set at issuance, so an expired challenge is garbage by definition, unlike
a dead-lettered job an operator may want to inspect. A deployment running
the account API with no daemon over its store keeps expired rows between
logins — see account-api.
RECOMMENDED (public). [spooler.smarthost] routes all outbound mail
through a fixed relay — a
smarthost — instead of MX resolution:
host, port, user, password,
require_tls, implicit_tls. implicit_tls = true (default false)
dials the smarthost with TLS from the first byte — the port-465 “SMTPS”
style, named after the
listeners’ switch — instead of the
default in-band
STARTTLS; the
port is not auto-switched to 465, it stays whatever you set.
Certificate verification stays the smarthost path’s strict webpki check,
and because implicit TLS is TLS the conversation is encrypted even
with require_tls = false.
REQUIRED (public). [spooler.dkim] signs authenticated submissions
with DKIM:
domain, selector, key_file (see DNS setup for the
matching DNS record). The key_file is a key
source — a file path (default) or
a cloud secret-manager secret. A multi-domain server writes one entry per
sending domain with the [[spooler.dkim]] array form (the single-table
form keeps working); the signer is selected by the sender’s domain so
each domain’s signature aligns for DMARC, and a sender domain with no
entry spools unsigned. A domain listed twice refuses to start.
Each entry can additionally opt into RFC 8463 dual-signing with the
ed25519_selector / ed25519_key_file pair — set both or neither (half
a pair refuses to start; with both absent, the default, the entry signs
rsa-sha256 only, unchanged). When configured, every signed message
carries a second, ed25519-sha256 DKIM-Signature header alongside the
rsa-sha256 one, each signing the same headers and body independently, so
verifiers honor whichever algorithm they support. ed25519_key_file is
the same key source shape
as key_file and holds a PKCS#8 PRIVATE KEY PEM — what
openssl genpkey -algorithm ed25519 writes. The second selector is
DNS-mechanical, not decorative: one _domainkey name publishes one key
record, so the ed25519 public key needs its own record at
<ed25519_selector>._domainkey.<domain>, shaped
v=DKIM1; k=ed25519; p=<key> where p= is the raw 32-byte public key
base64 — not a DER-wrapped SubjectPublicKeyInfo like RSA’s.
With no smarthost, the relay resolves each recipient domain’s published
MTA-STS policy
(RFC 8461)
before dialing its MXes — on by default via mta_sts. An enforce
policy restricts delivery to the MX hosts matching the policy’s mx
patterns, each contacted over TLS with a verified certificate; any TLS
failure — STARTTLS missing or refused, a handshake or certificate
error, or zero matching MXes — defers the mail on the normal retry
schedule rather than falling back to plaintext. A testing policy
delivers opportunistically and logs each MX target that would fail
under enforce (with [spooler.tlsrpt]
enabled, TLS-RPT reports cover these attempts too); a none policy, no
policy, or a transient DNS failure with nothing cached keeps today’s
opportunistic TLS (RFC 7435). Policies are cached in memory per domain
for their max_age (clamped to one year), so a cached enforce policy
keeps applying even if the DNS record is stripped. mta_sts = false is
a deliverability-debugging escape hatch only; the switch is not consulted
when a smarthost is configured, since that path never resolves MXes.
DANE
(RFC 7672)
rides the same direct-to-MX path — on by default via dane. When an MX host publishes a DNSSEC-validated TLSA record
set at _25._tcp.<mx-host>, the STARTTLS handshake must match the
published certificate data (the handshake is pinned to the records, not
to the webpki roots), and any mismatch or TLS failure defers the
mail rather than falling back — for that host DANE outranks an MTA-STS
policy, including its mx pattern filter. A validated TLSA set whose
records are all unusable for SMTP still demands TLS (unauthenticated —
unless an MTA-STS enforce policy applies, which then stays the stricter
floor). Hosts whose TLSA lookup fails DNSSEC validation are not dialed
at all; domains without DNSSEC or without TLSA records keep today’s
opportunistic TLS, so the switch only ever tightens delivery to domains
that opted in. The switch also turns on DNSSEC validation for MX
resolution itself: DANE requires a validated MX answer — or, for a
domain with no MX record, a validated denial. When the negative
answer’s SOA proves Secure, the implicit-A fallback is DANE-eligible
and TLSA records at _25._tcp.<domain> apply (the domain itself is
the connect host); a denial that cannot be validated keeps the
fallback on opportunistic TLS as before. Like
mta_sts, dane = false is a deliverability-debugging escape hatch
only, and the switch is not consulted when a smarthost is configured.
[spooler.settle] runs the auto-settle
sweeper: an hourly scan
reclaims the on-chain stamp value of every delivered copy older than
after_days (via DeleteMail) while keeping the local IMAP/POP
copy — so the recipient keeps reading their mail, but its stamp value
stops being locked on-chain. It is on by default at a 30-day window,
and by default it also unpins the sealed IPFS copy at settlement,
since settling removes the on-chain message account and the reclaimed
copy no longer needs serving. That interplays with trustless web-client
retrieval: once a message settles past the window, its decentralized
copy is no longer fetchable — the on-chain CID is gone and the pin is
released. Set keep_pin = true to leave the pin
in place so the sealed copy stays fetchable after settlement, or
enabled = false to never auto-settle. The sweeper only runs when the
chain pipeline is enabled ([grpc] + [ipfs]); with either absent
there is nothing on-chain to settle.
Pinning leases override
the unpin leg per-CID, with no configuration: before releasing a pin the
sweeper asks the gateway whether the copy’s CID carries a live on-chain
lease. A leased copy still settles — DeleteMail reclaims the stamp on
schedule — but keeps its pin for as long as the lease account exists.
The check fails closed: if the gateway cannot answer (chain
unreachable, or a gateway predating the GetPinLease RPC), the whole
copy is left unsettled and retried next sweep, because a settled copy is
never re-examined and a wrongly released pin cannot be won back. One
accepted asymmetry follows: closing a lease after its copy settled does
not retroactively release the pin — that storage is reclaimed by
ordinary operator garbage collection, not by the sweeper.
[spooler.dmarc_report] — aggregate (RUA) reporting
[spooler.dmarc_report] emits DMARC
aggregate (rua) reports — the RFC 9990 §3.5.2 gzip XML feedback a
receiver sends back to each domain whose mail it evaluated. It is off
by default: with the section absent (or enabled = false) the MX
records no aggregation data and no reporting worker runs, so an
empty/commented config stays a complete dev stack. Recording only
happens on an MX running sender_auth = "dmarc"
and only while this section is enabled.
| Key | Default | Meaning |
|---|---|---|
spooler.dmarc_report.enabled | false | RECOMMENDED (public). Master switch. false (or section absent) records nothing and runs no worker |
spooler.dmarc_report.org_name | (empty) | REQUIRED (public) when enabled. Your reporter identity, written to the report’s org_name. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
spooler.dmarc_report.email | (empty) | REQUIRED (public) when enabled. The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
spooler.dmarc_report.submitter | (the email domain) | The reporting-MTA domain used in the report filename/subject |
spooler.dmarc_report.extra_contact_info | (none) | Optional contact URI/text carried in the report |
spooler.dmarc_report.window_hours | 24 | Retired — still accepted so an existing config boots, but ignored. The period is fixed at 24 h |
spooler.dmarc_report.interval_hours | 24 | Retired — as above; a daily report’s drain cadence is its window |
Every 24 hours the worker drains the DMARC evaluations recorded since the
last tick and emails one aggregate report per policy domain to the
rua addresses that domain publishes in its DMARC record. Before
sending to any address outside the policy domain it enforces the RFC
9990 §4 external-destination check — the target must publish a
<policy-domain>._report._dmarc.<target> authorization record — so a
report is only delivered where the receiving domain has opted in.
Reports go out through the normal outbound relay path from email and
are DKIM-signed like any other outbound mail (configure a matching
[spooler.dkim] entry for that domain).
Scope and failure behavior, stated honestly:
- A policy domain that publishes no
ruaaddress gets no report. - A
ruatarget that fails the external-destination check is skipped (definitively discarded for that window). - A transient DNS or spool failure defers rather than drops: the affected rows are re-recorded for the next tick instead of being lost.
- The report’s
policy_publishedfields carryp/sp/np/adkim/aspf, read from the record that governed each evaluation.npis echoed only when the domain actually published annp=tag: the DMARC library fills an absent one in with a copy ofspwhile parsing, and reporting that would attribute a policy the domain never stated, so the element is simply omitted instead — as it also is for annp=published equal tosp=, which RFC 9091 makes the default reading of an absent one anyway. One element is not emitted at all:discovery_method, deliberately omitted because this MX folds the report’s grouping domain with the public suffix list while its alignment verdicts come from the tree walk — claiming either method would be a false statement rather than a missing one. - Reports are emitted in the RFC 9990
urn:ietf:params:xml:ns:dmarc-2.0namespace, and that is the intended behavior rather than an accident of a library version. RFC 9990 is Standards Track and obsoletes RFC 7489, so emitting the current schema is precisely what running a DMARCbis receiver means; andnp— the element the bullet above discusses — exists only indmarc-2.0, so downgrading the namespace would foreclose ever reporting it. The tradeoff, stated rather than hidden: a consumer that still validates strictly against the RFC 7489dmarc-1.0schema will refuse our reports, and there is no switch to emit the older form. That is an accepted consequence of adopting DMARCbis. In the receiving direction we stay lenient — the optional report ingest path accepts bothdmarc-1.0anddmarc-2.0bodies. The emitted namespace is pinned by a test, so a future library bump that moves it again fails loudly instead of silently.
Commented block from sithbitd.example.toml (defaults shown):
# [spooler.dmarc_report]
# enabled = false
# org_name = "Example Mail"
# email = "dmarc-reports@example.com"
# submitter = "example.com"
# extra_contact_info = "https://example.com/dmarc"
# # Retired: both keys still parse, but the period is fixed at 24 h.
# window_hours = 24
# interval_hours = 24
[spooler.dmarc_ruf] — forensic (ruf) reporting
[spooler.dmarc_ruf] emits DMARC
failure/forensic (ruf) reports — the RFC 9991 §2 per-message
feedback a receiver sends back the instant a message fails DMARC, wrapping
the offending message in an RFC 5965 ARF message/feedback-report. Like
aggregate reporting it is off by default: with the section absent (or
enabled = false) the MX builds no forensic reports, so an
empty/commented config stays a complete dev stack. Reporting only happens
on an MX running sender_auth = "dmarc"
and only while this section is enabled.
| Key | Default | Meaning |
|---|---|---|
spooler.dmarc_ruf.enabled | false | Master switch. false (or section absent) builds and sends nothing |
spooler.dmarc_ruf.include_body | false | Attach the full offending message (message/rfc822) instead of the headers-only default — see the privacy note below |
spooler.dmarc_ruf.org_name | (empty) | REQUIRED (public) when enabled. Your reporter identity, the report From: display name. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
spooler.dmarc_ruf.email | (empty) | REQUIRED (public) when enabled. The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
spooler.dmarc_ruf.subject | "DMARC Forensic Failure Report" | The report Subject:; an empty value derives this default |
Unlike aggregate reporting there are no window or interval settings:
forensic reports are per-message, not batched. On a DMARC failure whose
published policy carries a ruf= URI and whose fo= failure-options
match, the server builds one ARF report and relays it directly and
best-effort — no store, no worker, no retry queue. Before sending to any
ruf= target it enforces the RFC 9991 §5 external-destination check (the
target must publish a <policy-domain>._report._dmarc.<target>
authorization record), exactly as aggregate reporting does, so a report is
only ever delivered where the receiving domain has opted in. Reports relay
from email through the normal outbound path and are DKIM-signed like any
other outbound mail (configure a matching
[spooler.dkim] entry for that domain). Each
report also carries the offending message’s envelope identifiers — the RFC 5965
Original-Mail-From, Original-Rcpt-To, and Original-Envelope-Id fields — so
the receiving operator can correlate the failure to the delivery attempt.
Privacy — headers-only by default. A forensic report carries the
offending message itself to whoever the sender domain’s ruf= URI names,
so it is a content-exposure surface aggregate reports never are. SithBit
follows the RFC 9991 §7.1 content-minimization guidance: only the offending
message’s headers are attached (text/rfc822-headers). Setting
include_body = true attaches the full message/rfc822 — leaking the
message’s entire content to the ruf= operator. Leave it off unless you
specifically need full-body forensics and trust every domain you evaluate.
Scope and failure behavior, stated honestly:
- A policy domain that publishes no
rufaddress (or whosefo=does not select the failure) gets no report. - A
ruftarget that fails the §5 external-destination check receives nothing — it is dropped, not retried. - A transient resolver or spool failure also drops the report: there is no persistence and no retry queue. A forensic report lost to a transient failure is acceptable by design (unlike aggregate reporting, which defers and re-records affected rows for the next tick).
Commented block from sithbitd.example.toml (defaults shown):
# [spooler.dmarc_ruf]
# enabled = false
# include_body = false
# org_name = "Example Mail"
# email = "dmarc-reports@example.com"
# subject = "DMARC Forensic Failure Report"
There is no extra_contact_info here, unlike
[spooler.dmarc_report]: the ARF
forensic format has no field to carry one, so the key never reached a
report and was removed.
[spooler.dmarc_rua_ingest] — DMARC report ingestion
The receiving side of DMARC aggregate reporting: when another operator’s
receiver mails an RFC 9990 aggregate (rua) report to one of your
operated domains, this section makes the delivery path parse it and store
the result as JSON for the account API’s
GET /v1/admin/dmarc-reports surface. Off by default — with the
section absent (or enabled = false) inbound reports are ordinary
delivered mail. Pair it with
postmaster_wallet above so
external reporters (who never hold a prefunded frombox) can reach the
mailbox at all.
| Key | Default | Meaning |
|---|---|---|
spooler.dmarc_rua_ingest.enabled | false | RECOMMENDED (public). Master switch. false (or section absent) delivers reports as ordinary mail, parsing nothing |
spooler.dmarc_rua_ingest.recipients | ["postmaster"] | Delivered recipients that trigger parse+store; each entry is a bare local-part (matched at any local domain) or a full address. The raw message still lands in the mailbox either way — parsing is additive, never a diversion. Parsed reports are stored under the fixed dmarc_rua/ blob prefix (deliberately not configurable — it is the admin surface’s read contract) |
[spooler.chain_budget] — on-chain publication rate limit
Bounds how fast delivered mail is published to Solana, and with it how fast this daemon can spend from the gateway’s fee-payer wallet.
Why this exists. Recipients pay for postage, but the transaction fee
for every SendMail comes out of the operator’s own gateway wallet. Most
paths price the sender before anything reaches the chain — except
postmaster_wallet,
which RFC 5321 §4.5.1 requires to accept mail from anyone and which
therefore skips the postage check by design. Without a budget, an
unauthenticated sender can drive fee-payer spend at whatever rate the
connection limits allow. Set this whenever postmaster_wallet is set.
Over-budget mail is not refused. It is accepted, stored, and readable over IMAP/POP immediately — only its on-chain publication is paced, by enqueueing the chain job invisible until its turn. Nothing bounces and no sender sees an error; the copy simply reaches the chain later. A sustained flood therefore grows the chain queue rather than being shed, which is the deliberate trade: mail is never destroyed to protect the wallet.
max_per_window is the setting that actually holds. On the
postmaster_wallet path the envelope sender is unauthenticated and free to
vary, so a per-sender budget alone is walked past by changing MAIL FROM
on every message. max_per_sender_per_window is fairness on top — it stops
one heavy sender consuming the whole shared allowance — never a substitute.
Both budgets count publications per window, not lamports: a SendMail
fee is near-constant, so a publication count stands in faithfully for the
money spent. The window is fixed rather than sliding, so a burst straddling
a boundary can reach up to twice the budget — the same trade the
account API’s rate limits make.
| Key | Default | Meaning |
|---|---|---|
spooler.chain_budget.max_per_window | 0 | REQUIRED (public) when postmaster_wallet is set. On-chain publications admitted per window across every sender together. 0 = unlimited. This is the budget that bounds fee-payer spend; set it whenever postmaster_wallet is set |
spooler.chain_budget.max_per_sender_per_window | 0 | RECOMMENDED (public). Publications admitted per window for any one envelope sender, so a single heavy sender cannot consume the whole shared allowance. 0 = unlimited. Fairness only — an unauthenticated sender can vary MAIL FROM, so this never bounds total spend on its own |
spooler.chain_budget.window_secs | 60 | RECOMMENDED (public). Seconds both budgets reset on. When this daemon shares a store with account_api, keep this at or below that service’s longest rate-limit window — the store’s window sweep takes a cutoff rather than a key prefix, so a shorter-windowed sweeper can retire a live window early, which makes the budget under-enforce for one window |
The budgets are charged against the same store-backed counter the account
API’s durable rate limits use, so every sithbitd
replica over one store shares one budget rather than getting its own. A
store that cannot answer fails open — the publication goes out
unpaced, and a warning is logged — because a counter outage must not stop
mail reaching the chain.
Report traffic this daemon generates itself (DSNs, DMARC forensic reports, TLS-RPT reports) is deliberately not budgeted: it is the operator’s own mail, exactly as it is already exempt from the per-wallet storage cap.
[spooler.tlsrpt] — SMTP TLS reporting (TLS-RPT)
[spooler.tlsrpt] records SMTP TLS Reporting (TLS-RPT, RFC 8460)
results for outbound mail: one row per relay attempt against a recipient
MX host — including hosts a policy excluded before dialing — noting
whether the TLS session succeeded and, on failure, the derived RFC 8460
result code plus the policy (MTA-STS, DANE TLSA, or none) that governed
the attempt. It is off by default: with the
section absent (or enabled = false) the relay records nothing, so an
empty/commented config stays a complete dev stack. Recording happens on
the direct-to-MX path only — a configured
smarthost is not the recipient domain’s
TLS posture, so nothing is recorded when one routes everything.
| Key | Default | Meaning |
|---|---|---|
spooler.tlsrpt.enabled | false | RECOMMENDED (public). Master switch. false (or section absent) records nothing |
spooler.tlsrpt.org_name | (empty) | REQUIRED (public) when enabled. Your reporter identity, written to the report’s organization-name. sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
spooler.tlsrpt.email | (empty) | REQUIRED (public) when enabled. The report From: and outbound relay origin. Should be a local, DKIM-signable address (RFC 8460 §3 requires reports to pass DKIM). sithbitd refuses to start when the section is enabled and this is empty — set both org_name and email, or disable the section. |
spooler.tlsrpt.contact_info | (mailto: the email) | The report’s contact-info URI |
spooler.tlsrpt.window_hours | 24 | Retired — still accepted so an existing config boots, but ignored. The period is fixed at 24 h |
spooler.tlsrpt.interval_hours | 24 | Retired — as above; a daily report’s drain cadence is its window |
Recorded rows accumulate as JSON blobs under the fixed tlsrpt/pending/
blob prefix (deliberately not configurable). The one enabled switch also
starts the report drain worker: every 24 hours it
folds the pending rows into one RFC 8460 report per recipient domain,
discovers the domain’s rua= targets from its _smtp._tls TLSRPT
record, and delivers over both channels — mailto: targets ride the
normal outbound relay, DKIM-signed on spool entry (hence the local,
DKIM-signable email, whose domain doubles as the report’s submitter
identity), https: targets receive the gzip-compressed JSON directly.
A domain that publishes no TLSRPT record gets no report and that
window’s rows are dropped; rows are deleted only after delivery to
every target, so a crash between send and delete can re-deliver a
window — the deterministic report-id lets receivers de-duplicate. See
the conformance
appendix
for the full recording and reporting semantics.
Scope and failure behavior, stated honestly:
- Recording is strictly observational: a failed row write logs a warning and never changes the delivery outcome, and a recorded TLS failure still defers/retries exactly as before.
- Success rows are flag-truthful: a success is recorded only when
the completed conversation actually ended on TLS. A completed
plaintext opportunistic delivery — TLS never negotiated, including
a declined STARTTLS offer that continued in the clear — records no
row at all: under RFC 8460 it is neither a TLS session nor a failed
attempt. The seam cannot tell “STARTTLS never offered” apart from
“offered but declined” — both go unrecorded;
starttls-not-supportedfailure rows are reserved for enforced postures that abort the delivery. - Handshake failures are recorded with the general
validation-failurecode and the TLS error detail infailure-reason-code; the specific certificate codes (certificate-expired,certificate-host-mismatch, …) cannot be distinguished at this seam. - Hosts a policy excludes before dialing record never-dialed failure
rows: an unusable DANE TLSA set records
dnssec-invalid, an MX target outside an enforce-mode MTA-STS policy recordssts-policy-invalid, each with the planner’s diagnostic infailure-reason-code. Unreachable or timed-out hosts still produce no row, and MTA-STStesting-mode mismatches stay warn-log only.
Commented block from sithbitd.example.toml (defaults shown):
# [spooler.tlsrpt]
# enabled = false
# org_name = "Example Mail"
# email = "tlsrpt@example.com"
# contact_info = "mailto:tlsrpt@example.com"
# # Retired: both keys still parse, but the period is fixed at 24 h.
# window_hours = 24
# interval_hours = 24
[spooler.offload] — large-attachment IPFS offload
[spooler.offload] keeps oversized attachments out of the stored and
pinned message. An attachment larger than threshold_bytes decoded
bytes is sealed under its own freshly generated key, pinned through the
same [ipfs] provider the chain workers use, and replaced in the
delivered message by a placeholder part whose link points at
<gateway_url>/ipfs/<cid> — with the key riding in the URL’s
#fragment, the one part of a URL a client never puts on the wire. The
gateway therefore serves ciphertext it cannot read, and only a recipient
holding the whole link can open the attachment.
Two size rules decide which parts leave, and they compose as a union
rather than one overriding the other. threshold_bytes takes any part
individually over it. aggregate_bytes then caps what one message may
leave inline in total, offloading the largest eligible parts until
the rest fits — the case a per-part threshold structurally cannot catch,
because it is compared per part and only per part, so twenty 1 MB parts
under a 5 MiB threshold ride inline as a 20 MB message.
It is off by default, and opting in is deliberate: with the section
absent — or present but leaving both threshold_bytes and
aggregate_bytes at 0 — no message is ever rewritten and delivered
bytes are byte-identical to a daemon without the feature. Naming a
gateway_url alone does not arm it. Either size rule arms it on its
own, so aggregate_bytes with no threshold set is a valid “cap the
total, ignore part size” configuration. Offload also requires the chain
pipeline ([grpc] + [ipfs]): without a pinning provider there is
nothing to build a fetchable link from, so the settings stay inert.
| Key | Default | Meaning |
|---|---|---|
spooler.offload.gateway_url | "http://127.0.0.1:8183" | REQUIRED (public) when enabled. Public base URL of the read-only IPFS gateway (sithbit-gateway) serving the pinned ciphertext. Recipients’ clients fetch this, so it must be the address they can reach — never an internal one; the default is that gateway’s own loopback dev bind so the zero-config stack stays coherent. A trailing / is ignored and a path prefix (https://example.com/gw) is honored |
spooler.offload.threshold_bytes | 0 | RECOMMENDED (public). Decoded attachment size, in bytes, above which a part is offloaded (strictly greater; the comparison is against the decoded size, not the base64 on the wire). 0 — the default — disables the feature outright |
spooler.offload.aggregate_bytes | 0 | RECOMMENDED (public). Decoded attachment bytes one message may leave inline in total, above which the largest eligible parts are offloaded until the rest fits (strictly greater, decoded, like threshold_bytes). 0 — the default — leaves this rule off; a non-zero value arms the offload on its own. Counts attachment parts only, never the text or HTML bodies, and is a target rather than a guarantee: a part content_id holds inline still counts toward it but is never taken to satisfy it |
spooler.offload.content_id | "never" | What to do with an over-threshold part carrying a Content-ID — the header an HTML body uses to render a part inline as cid:…. "never" holds every such part inline at any size; "orphaned" also offloads the ones no body actually references; "all" additionally offloads referenced ones, rewriting each <img> that rendered one into a link. See What is offloaded — and what never is |
A pin failure tempfails the whole submission (451) rather than
delivering the attachment inline: the sending MTA retries with the
attachment intact, while a silent downgrade would defeat the threshold in
exactly the case it exists for.
Commented block from sithbitd.example.toml (defaults shown):
# [spooler.offload]
# gateway_url = "http://127.0.0.1:8183"
# threshold_bytes = 0
# aggregate_bytes = 0
# content_id = "never"
Choosing a threshold. 5 MiB (5242880) is the suggested production
value: comfortably above ordinary photo and PDF attachments — which stay
inline and behave like ordinary mail — and well under the 25 MiB
max_message_size accept
ceiling, where a single attachment would otherwise dominate every stored
and pinned copy. Set it lower only if you would rather more attachments
became links.
Choosing a budget. 10 MiB (10485760) is the suggested production
value for aggregate_bytes: it leaves ordinary multi-attachment mail
alone while bounding the pathological case the threshold misses. The two
numbers answer different questions and are worth setting together — the
threshold decides “is this one file too big to store?”, the budget
decides “is this whole message too big?” — and because the budget takes
the largest parts first, a message that trips only the budget loses as
few of its inline attachments as the arithmetic allows.
account-api settings
Part of the configuration reference. Covers the
account API’s own settings table; same-origin static mounts for the
browser clients ([[static]]); the per-wallet mutation and per-pubkey
login-challenge rate limits ([rate_limit], [nonce_rate_limit]); the
in-memory summary-cache sizes ([cache]); and
sithbit-console, the admin TUI that talks to this
API’s /v1/admin routes.
account-api
Row markers follow the going-public legend.
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8180" | REQUIRED (public). HTTP listen address. The loopback default is reachable from this host only, so a public deployment names the address deliberately and puts TLS in front of it — a fronting proxy, or the [tls] section below |
admin_wallets | [] | RECOMMENDED (public). Wallets allowed on the /v1/admin routes (account/queue inspection, dead-letter requeue — see Monitoring). Empty disables the admin surface: every admin call is 403 |
max_wallet_bytes | 0 | RECOMMENDED (public). Aggregate bytes ONE wallet may hold in this store — the twin of sithbitd’s key of the same name, applied to the copies POST /v1/mail/send writes; 0 (the default) is unbounded on both. Keep the two in step: compose writes into the store the mail servers read, so a ceiling only they hold is one any sender walks around by composing here. A compose that would cross it answers 413 Payload Too Large |
enable_stored_passwords | true | RECOMMENDED (public). Whether an account may set a stored mail password (a non-empty PUT /v1/account/password; false answers 403). Off makes the deployment wallet-signature-only, which is what keeps delivered mail sealed at rest for every account — a stored password is plaintext-recoverable by the server, and the CRAM-MD5/APOP logins it exists for could never unwrap a sealed body’s key. Clearing (DELETE) and declaring the wallet-auth state (empty PUT) stay allowed, and passwords already stored keep working; pair it with the mail servers’ key of the same name |
[rate_limit] | (on, 30 per 300s) | RECOMMENDED (public). Per-wallet budget over the sensitive account mutations — mail password, pin-provider writes, auth-epoch rotation (below). Reads, login, timezone, DND and compose are never limited |
rate_limit.enabled | true | RECOMMENDED (public). Master switch for that budget. On by default — a control that ships off protects nobody, because no operator finds it before the abuse does |
rate_limit.max_per_window | 30 | RECOMMENDED (public). Attempts one wallet may make per window, shared across all the guarded routes together. 0 = no limit |
rate_limit.window_secs | 300 | RECOMMENDED (public). Length of the fixed window, in seconds. 0 = no limit |
rate_limit.durable | false | RECOMMENDED (public); set it once more than one replica serves the same accounts. Keep this budget’s windows in the shared [store] instead of this replica’s memory, so N replicas spend one budget (below). Off is the right default for a single replica and costs no store round trip per guarded request |
[nonce_rate_limit] | (on, 30 per 300s) | RECOMMENDED (public). Per-pubkey budget over login-challenge issuance (POST /v1/auth/nonce) — the same settings as [rate_limit], a separate budget keyed on the pubkey in the request body (below). Step-up challenge issuance is deliberately not limited |
nonce_rate_limit.enabled | true | RECOMMENDED (public). Master switch for that budget. On by default, for the same reason as rate_limit.enabled |
nonce_rate_limit.max_per_window | 30 | RECOMMENDED (public). Challenge requests one pubkey may make per window. 0 = no limit |
nonce_rate_limit.window_secs | 300 | RECOMMENDED (public). Length of the fixed window, in seconds. 0 = no limit |
nonce_rate_limit.durable | false | RECOMMENDED (public); set it once more than one replica serves the same accounts. The same switch as rate_limit.durable, for this budget. The two are independent — and the keys here are attacker-supplied, which is why a durable deployment sweeps lapsed windows on a timer |
[store] | (same as sithbitd) | REQUIRED (public). Point it at the same store so one database serves both. The default SQLite store admits exactly one instance, so a deployment running more than one names a kind that several can share (see Scaling out) |
jwt.issuer / jwt.audience | "sithbit" | Token claims |
jwt.key_file | "jwt.key" | REQUIRED (public). 32-byte signing key — auto-generated if missing, then unrecoverable; back it up (losing it invalidates all sessions). A key source: a file path (default) or a cloud secret-manager secret (auto-generation applies to the file form only). Every replica must load the same key: one that mints its own rejects the sessions the others issued |
jwt.ttl_hours | 24 | RECOMMENDED (public). Token lifetime |
[cache] | (the small-deployment defaults) | The four bounded caches — their sizes, and since v0.110.0 which backend the plaintext summary cache lives in — every key optional (below). The defaults serve a small self-contained deployment; a farm raises summary_capacity and max_cached_sessions, sized by what one replica sees, or shares the plaintext cache outright with kind = "redis" (below) |
cache.kind | "local" | RECOMMENDED (public); set "redis" once more than one replica serves the same accounts. Which backend holds the plaintext summary cache: "local" is this replica’s in-process map; "redis" shares one cache across every replica (below). The sealed-summary cache and the reading secrets stay per replica either way. "redis" needs the redis cargo feature — the docker image’s all feature set has it; a binary built without it refuses to start on "redis", naming the feature, rather than falling back to a private map the fleet would then not share |
cache.redis_url | "redis://127.0.0.1:6379/" | RECOMMENDED (public) with kind = "redis". The shared server — redis:// plain, or rediss:// for TLS (via rustls; no system OpenSSL involved). A password in the URL is used and never logged (startup logs the kind only). A malformed URL refuses to start; an unreachable server does not — the API starts, and every read is a miss until the server answers |
cache.redis_auth | (unset) | RECOMMENDED (public) with kind = "redis". Where the Redis password comes from — a key source: a file path (bare string or { kind = "file", path = … }) or a cloud secret (akv / asm / gsm), like jwt.key_file. The secret’s bytes (UTF-8, trailing whitespace trimmed) are the password, so redis_url stays host-only; a password embedded in the URL is still honoured, but this selector wins when both are set. Loaded once at startup: an unreadable or empty secret refuses to start (an empty password would send AUTH ""), and a missing file is never auto-generated. Setting it under kind = "local" refuses to start too — a selector that would be silently ignored is a misconfiguration |
cache.redis_ca | (unset) | Set with kind = "redis" only when the server’s certificate is not signed by a CA in the platform root store — Google Memorystore’s SERVER_AUTHENTICATION mode presents an instance-specific Google-managed CA (its server_ca_certs); AWS ElastiCache and Azure Cache for Redis use publicly trusted certificates and need nothing here. A key source of the same four kinds and shape as redis_auth, whose bytes are one or more PEM certificates that replace the platform roots for this connection. Needs a rediss:// redis_url (a plain redis:// one would never read it — the pair refuses to start), and is refused under kind = "local". An unreadable, empty, or certificate-less value refuses to start — an empty root store would trust nothing and fail open forever, which reads as a down server, not the misconfiguration it is |
cache.redis_ttl_secs | 86400 | RECOMMENDED (public) with kind = "redis". Seconds a shared summary lives before Redis expires it (a per-entry SETEX lifetime), which is what bounds the server’s memory even with no maxmemory-policy set. 0 refuses to start |
cache.summary_capacity | 4096 | Entry cap of the shared plaintext summary cache, across every wallet and session. Past it the least-recently-used entry is evicted and rebuilt from its immutable blob on the next read — latency, never a wrong answer. A value at or below the search scan window is clamped up to one above it, with a warning (how to decide the value) |
cache.session_summary_capacity | 1024 | Entry cap per session of the sealed-summary cache; past it that session’s own least-recently-used entry goes. Must exceed the search scan window or startup refuses (how to decide the value) |
cache.max_cached_sessions | 8 | Full-quota sessions the sealed-summary cache holds at once (its ceiling is this × session_summary_capacity); past it the least-recently-used session’s whole map drops and that session re-decrypts on its next read. 0 refuses to start (how to decide the value) |
cache.max_session_secrets | 4096 | Concurrent sessions that may hold a reading secret; when full the soonest-expiring session is evicted and simply logs in again. 0 refuses to start (how to decide the value) |
[chain] | (absent) | RECOMMENDED (public). Enables the authenticated /v1/chain on-chain read proxy + signed-transaction relay (the Thunderbird extension’s surface). Absent = those routes answer 503; an empty section uses the two defaults below |
chain.grpc_endpoint | "http://127.0.0.1:50051" | REQUIRED (public). The mail-grpc gateway serving mailbox/key/alias/ frombox/tx-status reads. The default names a gateway on this host’s loopback; anything else is an address the operator sets |
chain.grpc_tls | (absent — plaintext) | REQUIRED when the gateway has [auth] configured (every gateway not on this host’s loopback): the API’s half of the mutual TLS, the three keys below — all or none. That half is an operator obligation: the API cannot see the gateway’s configuration, so nothing here checks it — a table left absent against a gateway that enforces [auth] starts cleanly and fails per call, when the gateway refuses the unauthenticated connection. What the API does check at startup is that this table and the endpoint’s scheme agree: http:// with this table, or https:// without it, refuses to start. Add the API’s key to the gateway’s authorized_keys |
chain.grpc_tls.cert | (unset) | REQUIRED when the gateway has [auth] configured. The api’s PEM client certificate — a path or a key source. Its Ed25519 key is what the gateway allow-lists |
chain.grpc_tls.key | (unset) | REQUIRED when the gateway has [auth] configured. Private key for cert, PEM. Same source forms |
chain.grpc_tls.gateway_key | (unset) | REQUIRED when the gateway has [auth] configured. The base58 Ed25519 key in the gateway’s certificate, pinned — no CA, no hostname check; any other certificate fails the handshake |
chain.rpc_url | "http://127.0.0.1:8899" | REQUIRED (public). Solana JSON-RPC node for SOL balances and relaying client-signed transactions. The default is a local validator — a public deployment names its own RPC provider, since rate-limited public endpoints are not production-grade |
mail.local_domains | [] | RECOMMENDED (public). Domains whose recipients live in this store: compose (POST /v1/mail/send) delivers them locally, resolving aliases and prechecking stamps over the [chain] gateway. Mirror sithbitd’s local_domains. Empty = every recipient rides a relay job |
mail.auto_mark_seen | false | Mark a message \Seen when it is fetched over GET /v1/mail/messages/{uid}. Off, a fetch stays a pure read and read state is the client’s explicit PATCH; turn it on for webmail clients that treat opening a message as reading it |
[quota] | (enabled, sithbitd’s defaults) | RECOMMENDED (public). Outbound-relay quotas + suspension on compose — the same keys, defaults, and ramp as sithbitd’s [smtp.quota] / [submission.quota]; the two are twins by design, keep them in step. Over-quota external recipients at compose answer 429, a suspended composer 403 |
[mail.dkim] | (absent) | RECOMMENDED (public). DKIM keys for composed mail — the same one-or-many shape as sithbitd’s [spooler.dkim], selected by the sender’s domain. Absent = composed relay mail goes unsigned |
[[static]] | (no entries) | Serves static directories on the API’s own origin (browser pages reach /v1/… without CORS) — one array-of-tables entry per mount, see below. No entries = no static routes; a bare entry mounts the two defaults below |
static.route | "/addin" | Route prefix this mount’s files appear under; must start with /, which is never added for you. Must be unique across entries — a repeated prefix, /, a missing leading slash, or a route on or below another entry’s harness nest (test, tests, __tests__, spec) aborts the process at startup |
static.root | "wwwroot" | Directory this mount serves (point it at a built bundle, e.g. webclients/outlook/staging). A directory that does not exist is not a startup error: its requests 404 |
[tls] | (absent) | REQUIRED (public) unless a fronting proxy terminates TLS. Terminates TLS on the API listener itself (dev sideloads, small deployments — production guidance is still a reverse proxy). Both keys are required when present; a missing/invalid file fails at startup, never per-connection |
tls.certs | (required) | REQUIRED (public) whenever [tls] is present. PEM certificate chain — a key source: a file path (default) or a cloud secret-manager secret holding the PEM. Spelled as on every mail listener: the section is the same shared [tls] shape |
tls.key | (required) | REQUIRED (public) whenever [tls] is present. PEM private key — same file-or-cloud key source as certs |
[health], [observability] | (the shared defaults) | RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table |
[[static]] — same-origin static mounts
The browser clients SithBit ships — the Outlook add-in, the onboarding
pages, a webmail shell — are static bundles: directories of HTML,
JS, CSS and wasm that a server hands back unchanged, whose only job is
to call this API’s /v1/… endpoints. They have to be served from
somewhere, and [[static]] lets that somewhere be the API itself.
The reason is the browser’s same-origin rule. Serve the pages from a
different scheme, host or port than the API and every call they make is
cross-origin: preflight requests, a CORS allowlist to maintain,
credential rules to get right, and a second web server whose
configuration has to stay in step with the first. Serve them from the
API’s own listener and the page and the API share one origin, so
fetch("/v1/…") works with no CORS configuration anywhere.
These are directories the API finds, not files it ships. The bundles
are built by webclients/<shell>/build.sh — a separate toolchain whose
output is gitignored — and in production they are provisioned alongside
the binary rather than inside it, at paths that differ per deployment.
The one exception is the default root = "wwwroot", which is checked in
and does travel with the crate. Which shells need a mount at all varies:
sithbit-outlook.zip holds four entries (manifest.json, assets/ and
the two package icons), and its manifest points Outlook at
{{BASE_URL}}/addin/taskpane.html — substituted at package time by
outlook/build.sh, default https://localhost:8180, overridable with
SITHBIT_ADDIN_BASE_URL. An Office Add-in is a web app, so the
taskpane’s real pages have to come from a [[static]] mount — which is
why route defaults to /addin. The
Thunderbird extension is the other case:
its sithbit-thunderbird.xpi is 61 entries, wasm/mail_wasm.js and
wasm/mail_wasm_bg.wasm among them, because a WebExtension packages its
own resources and needs no server at all.
This is not a general-purpose web server, and several of the rules below exist to keep it from becoming one: the default is no mounts at all (a pure JSON API), the origin root cannot be mounted over, a bad entry aborts the process at startup, and every mount fences off the harness directories a JavaScript bundle typically ships with. Mounts carry no authentication — they sit alongside the API’s routes, not behind its JWT layer — so a mounted directory is public, which is right for a built bundle and wrong for anything else.
Static hosting is a list: one [[static]] entry per directory you
want served, so one API instance can carry the
Outlook add-in bundle, the
onboarding pages, and a
webmail shell at once, each on the API’s own
origin. The operator-facing walkthrough is in
account-api.
Commented block from account_api.toml (first entry shows the defaults):
# [[static]]
# route = "/addin"
# root = "wwwroot"
# [[static]]
# route = "/onboarding"
# root = "webclients/onboarding/staging"
Five rules the list obeys:
-
Each
routemust be unique. Two entries sharing a prefix abort the process while the router is built — before the listener binds, so it fails fast rather than half-serving. The abort names[[static]]and quotes the offending value verbatim:[[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.so the first move is to grep your file for the quoted prefix. A trailing slash buys no second mount —
/addinand/addin/are one prefix and collide — and when the two entries spell it differently the message carries both spellings (… by an earlier entry (spelled "/addin" there)), so whichever line you grep for, you find one of them. Mounting at the origin root aborts with a message of its own: the API’s own routes live there, so serve the directory under a prefix instead. One hole in the grep, worth knowing: an entry that omitsrouteinherits the/addindefault, so a message can quote a prefix that appears nowhere in your file — the accusation is still correct, only the search fails. -
A
routemust start with/. The leading slash is never added for you —route = "addin"is refused, so that the prefix serving requests is always the one spelled in the file:[[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. -
Nested prefixes are fine, except under a harness nest.
/addinand/addin/helpmay both be mounted; the most specific prefix wins regardless of which entry is written first, so the order of the entries is cosmetic. The exception is the next rule. -
Every mount 404s
test,tests,__tests__andspecbeneath itself, and no entry may be mounted on or below one of them. Those four nests are hardcoded, unconditional, and independent of what the mount’srootholds — a JavaScript harness shipped inside a bundle is never reachable from the API’s origin. A second entry standing exactly on a nest, or anywhere below one, aborts the process; the check is order-independent, and the message refuses the deeper route of the two:[[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.On the nest, the 404 wins and the mount could never have served a byte; below it, the deeper mount wins instead and would serve straight through the fence — which is why the wording differs, and why the second case is a break with earlier releases that built and served that list. Sharing a prefix with a harness name is not the same thing:
/addin/testingstill mounts, and so does/addin/help/sub. -
A
rootthat does not exist is not a startup error. That mount simply answers 404 per request — which is what you see when a bundle was never built or a relative path is read against a different working directory than you expected.
Migration from the single [static] table. The pre-list spelling —
one [static] table, at most one mount — is now a load-time error,
not a silently-ignored section: add the second pair of brackets and the
same two keys are read unchanged.
# Before — one table, now refused at load:
# [static]
# route = "/addin"
# root = "wwwroot"
# After — one array-of-tables entry, the same two keys:
# [[static]]
# route = "/addin"
# root = "wwwroot"
The whole list can also be set through one environment variable, since
__-nested names cannot address array entries: ACCOUNT_API_STATIC
takes the entire array as a TOML value (inline tables, key = value
— not JSON, which fails to parse and is then rejected as a string where a
sequence was expected):
ACCOUNT_API_STATIC='[{ route = "/addin", root = "/srv/www/addin" }]'
[rate_limit] — the per-wallet mutation budget
Three of the API’s routes can change or revoke an account’s mail
credentials: the mail password (PUT / DELETE /v1/account/password),
the pin-provider writes (PUT / DELETE /v1/account/pin-provider) and
auth-epoch rotation (POST /v1/account/auth-epoch, which logs every
session out at once). Those three — and only those — sit behind a
per-wallet budget. The reads, the token exchange, the timezone and DND
settings and compose are never rate-limited: hammering them buys an
attacker nothing worth the risk of throttling a legitimate client.
Login-challenge issuance answers to a separate budget of its own,
[nonce_rate_limit]
below.
# [rate_limit]
# enabled = true
# max_per_window = 30 # attempts per wallet per window; 0 = no limit
# window_secs = 300 # fixed window length; 0 = no limit
# durable = false # true = share one budget across replicas
Five properties decide whether the defaults suit your deployment:
- One budget per wallet, shared by all three routes. The count is keyed on the authenticated wallet, not on the route or the client address, so a caller cannot spread a burst across password / pin-provider / auth-epoch and earn three allowances. Every attempt is charged, including the ones that are refused — the hammering is the thing being blunted, not just its successes. An unauthenticated request is never charged: it never identifies a wallet, and gets the usual 401.
- A step-up refusal spends a slot too, so the number bounds
attempts, not changes. The same three routes are also step-up
gated
— they want a fresh wallet signature on the request — and the budget
is charged in front of that gate, so the
428 Precondition Requiredasking for that signature costs exactly what a success or a429costs (why). What a wallet gets formax_per_window = 30therefore depends on its client: one that fetches a challenge first and signs its very first send gets 30 real changes, while one that tries the mutation bare and steps up on the cue spends two slots per change and gets 15. Only the client that re-sends the bare request in a loop is punished as intended — it never succeeds and finishes on a429. Size the budget for the clients you actually ship, not for the ideal one. Which mix your clients actually produce is observable:sithbit.api.refusalscounts both statuses per route template — not per method — in Monitoring. 0means “no limit”, never “refuse everything”. That holds formax_per_windowandwindow_secsalike, matching the conventionserver_common’s listener limits already use, so a mistyped budget cannot lock an account out of its own settings. To switch the limit off deliberately, preferenabled = false, which says so out loud.- In-process and per-replica by default, shared if you say so.
With
durable = false(the default) the counters live in the API process’s memory and nothing is shared through the store, so two replicas behind a load balancer grant a wallet two budgets — size the number per replica, and expect the effective ceiling to scale with the instance count. The table of live windows is bounded (4096 wallets per replica, not a setting) and fails open when full: the earliest-opened window is dropped to make room, handing that wallet a fresh budget rather than refusing traffic the limiter has no memory of. Settingdurable = truemoves the windows into the store and makes the budget one budget however many replicas charge it. - A fixed window, not a sliding one. The window opens on a wallet’s first charged attempt and resets when it expires, so a burst that straddles a boundary can spend up to twice the budget back to back. That is deliberate — a sliding window costs per-attempt bookkeeping to buy precision this control does not need.
An over-budget attempt is refused with 429 Too Many Requests and a
Retry-After header carrying the whole seconds left on the window
(floored at 1, since a Retry-After: 0 would invite the immediate
retry the budget exists to prevent). A client that honours that header
recovers on its own; one that retries blind simply keeps the window
full. The refusal is written out in full — the exact response, why the
body carries no machine-readable field, and why the other 429 this API
can return has no Retry-After at all — under Rate limits on the
sensitive mutations.
durable — one budget across replicas
Both budgets ship with their windows in the API process’s own memory.
That is the right default for one replica and the only shape that works
with no store wiring at all, but it means a second replica keeps its own
count: two instances behind a load balancer hand the same wallet two
budgets, three hand it three. durable = true moves that budget’s
windows into the [store] every replica already shares, so the count is
one count however many instances charge it.
The two budgets carry the switch independently — turning it on for
[rate_limit] leaves [nonce_rate_limit] in memory — and each stores
its windows under its own key prefix, so the two never collide on a
wallet address that legitimately appears in both.
Three things are worth knowing before turning it on:
- It trades away the monotonic clock, and that is forced rather than chosen. An in-memory window is timed by a monotonic instant, which no wall-clock change can move. A window shared between processes has to be comparable between them, and a monotonic instant means nothing outside the process that read it — so a durable window is timed by the charging replica’s wall clock. A backwards jump on that replica can reopen a window early, and replicas whose clocks disagree disagree about where a window’s edge falls. Keep the fleet on NTP, as the same requirement already applies to every lease in the store.
- A store error admits. If the store cannot be reached the request is let through and a warning is logged. The limiter blunts abuse; it is not an authorization boundary, and refusing on a store outage would lock every account out of its own settings and every user out of logging in — a far larger blast radius than the abuse it prevents. This is the same fail-open direction the in-memory map already takes when its table is full.
- Lapsed windows are swept on a timer. Unlike the in-memory table,
the stored one has no fixed size, and
[nonce_rate_limit]is keyed on an unauthenticated, caller-supplied pubkey — so without sweeping, rows accumulate at a rate an attacker chooses. The API sweeps every five minutes (not a setting) using the longest configured window as the cutoff. On DynamoDB the sweep is the store’s own TTL instead of a delete, so removal is asynchronous and can lag by up to about two days; that bounds growth, which is the point, and a lapsed window is already ignored by the charge whether or not its row is gone.
Every backend supports it — SQLite, PostgreSQL, DynamoDB, Azure Tables, Turso/libSQL and Cloudflare D1 — so the switch needs no change of store.
Where to set it in a cloud deployment. The shipped production
documents under iac/appconfig/ spell both budgets out in full, so this
is one edit to iac/appconfig/aws/account-api.toml (then cargo run -p appconfig-gen to regenerate the Azure flavor) rather than something only
an ACCOUNT_API_RATE_LIMIT__DURABLE environment variable can reach. Both
ship false, matching the topology iac/aws provisions: that module
pins the account-api service to a single task, because without
account_api_jwt_key_asm every replica mints its own JWT signing key and
a token issued by one is rejected by the next. Give the replicas a shared
signing key first, then scale out, then turn this on — in that order.
[nonce_rate_limit] — the per-pubkey login-challenge budget
POST /v1/auth/nonce writes a challenge row for any valid ed25519
pubkey, unauthenticated by design — asking for a challenge is how a
login starts. That one route sits behind a budget of its own, keyed on
the pubkey in the request body rather than on an authenticated
wallet (there is none yet), and charged only after the pubkey parses, so
a malformed request never spends a slot:
# [nonce_rate_limit]
# enabled = true
# max_per_window = 30 # challenge requests per pubkey per window; 0 = no limit
# window_secs = 300 # fixed window length; 0 = no limit
# durable = false # true = share one budget across replicas
The machinery is [rate_limit]’s exactly — the same settings and defaults,
the same fixed window, the same 0-means-no-limit rule, the same
per-replica in-memory counters (two replicas grant a pubkey two
budgets), and a table of its own, bounded and fail-open the same way —
but a separate budget: spending one never touches the other, and each
is switched and sized on its own. The refusal keeps that shape as well —
429 with Retry-After in whole seconds (floored at 1) — but its body
prose names this budget:
{"error": "too many login challenges; retry in N seconds"},
where the mutation budget says “account changes”. Neither route is ever
told it spent the other’s allowance, and a client should key off the
status and the header rather than the sentence.
Two boundaries, so the budget is not oversold:
- It caps per-key hammering only. The count is keyed on the requested pubkey, so each fresh key an attacker generates arrives with a fresh budget — the challenge-row residue’s distinct-keys bound (see account-api) is not tightened by this control. What ages those rows out is the daemon’s hourly nonce sweep.
- The step-up challenge is deliberately not limited.
POST /v1/auth/step-updemands a valid token first, mints a row for that token’s own wallet only, and can only clobber its holder’s own slot — hammering it costs the caller nothing but their own outstanding challenge.
[cache] — the summary-cache sizes
The API keeps four bounded caches, and this section sizes them — and,
since v0.110.0, names which backend the plaintext summary cache lives
in (kind, below). Nothing here is
required at any deployment size: the defaults fit a small,
self-contained deployment, and a value the code cannot honour is caught
at startup rather than per request.
# [cache]
# kind = "local" # "redis" shares the plaintext summary cache across replicas
# redis_url = "redis://127.0.0.1:6379/" # the shared server (rediss:// for TLS); kind = "redis" only
# redis_ttl_secs = 86400 # seconds a shared summary lives; bounds Redis memory
# summary_capacity = 4096 # plaintext summaries, all sessions; LRU past it
# session_summary_capacity = 1024 # sealed summaries PER session; LRU past it
# max_cached_sessions = 8 # full-quota sessions the sealed cache holds
# max_session_secrets = 4096 # sessions holding a reading secret
What each bound does, and what happens past it:
summary_capacity— the shared plaintext summary cache, one map across every wallet and session. Past it the least-recently-used entry is evicted. An entry is derived from an immutable blob, so eviction costs a re-fetch and a re-parse on the next read and can never return a wrong answer.session_summary_capacity— the sealed-summary cache’s quota, per session. Past it that session’s least-recently-used entry goes — its own entry only; one session’s traffic never evicts another’s.max_cached_sessions— how many full-quota sessions the sealed cache holds. Its global ceiling is this ×session_summary_capacity. Past the ceiling the least-recently-used session’s whole map drops and that session re-decrypts on its next read: it lands in the cold-cache state every session starts in, which is degradation, never breakage.max_session_secrets— how many concurrent sessions may hold a reading secret. When full, the soonest-expiring session is evicted and simply logs in again.
Two startup validations, deliberately different in kind
(CacheConfig::validated):
- A too-small plaintext cache is clamped, with a warning. A keyed
search scans up to
SEARCH_SCAN_CAProws per request, and the plaintext cache must hold one whole scan window: asummary_capacityat or below that window is raised to one above it and a warning namingcache.summary_capacity, the configured value and the clamped value is logged. Nothing else is affected — a small plaintext cache is a performance defect only. - A too-small sealed-summary quota, or any zero bound, refuses to
start. A
session_summary_capacityat or below the scan window would let a keyed search evict its own rows mid-scan and the sealed-summary ledger under-report, so it is a hard refusal; so is amax_cached_sessionsormax_session_secretsof0, which is a misconfiguration, not a request for “unbounded”. The refusal happens before the JWT key is minted, so a bad value never leaves a stray key file behind.
Starting values for a farm. The defaults are read off a measured
hit-rate grid (kept on summary_cache.rs’s DEFAULT_CAPACITY, with
its caveats: it is a pessimistic bound, not a forecast). Off that same
grid, a larger farm-based deployment starts from:
| Setting | Farm value | What it buys |
|---|---|---|
summary_capacity | 16384 | a 100% hit rate up to 8 concurrent sessions, ~6–16 MB resident |
summary_capacity | 65536 | the same for 16 or more concurrent sessions, ~26–64 MB resident. Memory is the whole cost: eviction takes its victim from a stamp-ordered index, so it costs the same at any capacity |
max_cached_sessions | 32 or more | that many concurrent keyed sessions keep their sealed maps instead of re-decrypting |
max_session_secrets | (default) | already farm-sized: cheap per entry, and evicting one forces a user to log in again |
The trap: size by one replica, not by the fleet. With the default
kind = "local" each replica holds its own copy of every cache —
nothing here is shared through the [store] the way rate_limit.durable
shares a budget — so the number that matters is the concurrent sessions
one replica sees behind the load balancer, not the fleet total.
Sizing from the fleet total provisions every replica for all of it and
over-provisions memory N-fold. This is one of the replica-aware limits
on the Scaling out checklist. The one
cache a fleet can share is the plaintext summary cache, below.
A shared backend for a fleet
kind = "redis" points every replica at one Redis server, so a fleet
behind a load balancer warms one plaintext summary cache instead of
N cold private ones, and a deploy no longer empties it. The scope is
deliberately narrow:
- What is shared: the plaintext summary cache only. The
sealed-summary cache and the reading-secret stash stay per replica by
design — they hold decrypted content and credentials that must die
with the session, and moving either to a shared service is an
exposure-class change, not a configuration one.
summary_capacitysizes the local map only and is unused under"redis"; the other three sizes apply exactly as before. - Fail-open. A summary is re-derivable from its immutable blob, so
nothing the server does is ever an error to a request: a read that
fails, times out or returns something unparseable is a miss, and a
write that fails is dropped. A server that is down at startup, or goes
down later, makes the API slower, never wrong. The bound on “slower”:
a dead host costs a call about two seconds (a 1 s connect timeout,
one retry, a 500 ms response timeout); a refused port is instant.
Only a malformed
redis_urlrefuses to start. - Key shape. Every key is
sithbit:summary:v1:<blob key>with a JSON value; thev1is the value’s schema version, so an incompatible change to the shape bumps the prefix and old entries simply miss and age out. Share one server among fleets freely — the prefix keeps SithBit’s keys apart from anything else on it. - Memory.
redis_ttl_secsis the bound: every entry expires on its own, so the server’s footprint is one day’s summaries at most, at the default.maxmemorywithmaxmemory-policy allkeys-lruon the server is an optional second bound that evicts the coldest entry early — safe here, for the same reason a local eviction is. - The password.
redis_authnames where it comes from: a key source with the same four kinds and the same shape asjwt.key_file— a bare string or{ kind = "file", path = … }for a file, orakv/asm/gsmfor a cloud secret manager. The secret’s bytes are the password, trailing whitespace trimmed, soredis_urlstays host-only and never carries a credential into a config file or an environment listing. A password embedded in the URL is still honoured, but the selector wins when both are set. The secret is loaded once at startup and an unreadable or empty one refuses to start — an empty password would sendAUTH "", which is a silent misconfiguration, not a fail-open miss. A token on the wire is only as private as the connection, so pair the selector with arediss://URL. - The server’s CA.
rediss://verifies the server against the platform root store, which is enough for a publicly-signed certificate (ElastiCache, Azure Cache for Redis). Memorystore’s server-auth TLS presents an instance-specific Google-managed CA instead, andredis_cais how the API trusts it: a key source of the same four kinds whose bytes are one or more PEM certificates, which replace the platform roots for this one connection. It is loaded once at startup, and a value that is unreadable, empty, or holds no certificate refuses to start rather than building an empty root store — that store would trust nothing and fail open on every read, indistinguishable from a down server. Without arediss://URL the selector is refused too. - The build.
"redis"needs therediscargo feature, which is off by default so the default build stays dependency-free; the docker image’sallfeature set includes it.rediss://gives TLS through rustls, like every other TLS surface in the tree. - Where it runs.
docker-compose.prod.example.ymlcarries a commented-outredis:service (redis:7-alpine, on the compose network only, no volume — nothing in it is precious) beside the two commentedACCOUNT_API_CACHE__KIND/ACCOUNT_API_CACHE__REDIS_URLenvironment lines onaccount-api; a fleet uncomments the three. A managed Redis exists iniac/for all three clouds, each an opt-in unit whose token reaches the API throughredis_auth: ElastiCache (Valkey) on AWS, Azure Cache for Redis behind a private endpoint, and Memorystore on Google Cloud — the deploy chapter has each one’s operator steps.
What the metrics can and cannot see through the shared backend is on the monitoring page; the replica-level picture is on Scaling out.
sithbit-console
The management TUI over the account API’s /v1/admin routes: accounts,
mailboxes, messages with chain states, queue depths, and dead-letter
requeue/discard (see Monitoring; full tutorial and key
reference in the sithbit-console appendix). It
never touches the store directly — every read and action rides the API.
Config file sithbit_console.toml (or SITHBIT_CONSOLE_CONFIG), env
prefix SITHBIT_CONSOLE. Row markers follow the going-public
legend.
| Key | Default | Meaning |
|---|---|---|
api_url | "http://127.0.0.1:8180" | REQUIRED (public). Base URL of the account API. The default finds an API on this machine only; a remote one is named here as https://, since the admin session’s bearer token crosses the wire |
keypair_file | ~/.config/solana/id.json | RECOMMENDED (public). Solana keypair that signs the wallet-challenge login — its wallet must be on the API’s admin_wallets allowlist |
gateway_endpoint | "http://127.0.0.1:50051" | RECOMMENDED (public). mail-grpc gateway the balances pane reads on-chain mailbox/frombox state through — a SolanaMail gRPC endpoint (GetMailbox default stamp price + mail count, GetFrombox per-sender stamps). The console carries no client-certificate table, so it can only reach a gateway that runs without [auth] |
rpc_url | "http://127.0.0.1:8899" | RECOMMENDED (public). Solana JSON-RPC node the balances pane fetches native SOL balances from (getBalance) |
The balances pane is the one console view that reads chain state directly
rather than through the account API: press b on a wallet to see its
native SOL, its mailbox’s default stamp price and mail count, and the
prepaid stamps each other loaded wallet holds toward it. Those figures come
straight from mail-grpc and the JSON-RPC node above — so a console used
only for the admin panes can leave both endpoints at their loopback
defaults (or unset the whole file). Every entry defaults, so an empty or
absent sithbit_console.toml targets the loopback dev stack:
# api_url = "http://127.0.0.1:8180"
# keypair_file = "~/.config/solana/id.json"
# gateway_endpoint = "http://127.0.0.1:50051"
# rpc_url = "http://127.0.0.1:8899"
domain-sithbit settings
Part of the configuration reference. Covers the
domain verification service’s own settings; [mail_hosts], the public
IMAP/POP/SMTP coordinates it advertises to autoconfiguring mail clients;
and [mta_sts], the MTA-STS policy it publishes for the domains it fronts.
domain-sithbit
Row markers follow the going-public legend.
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8181" | REQUIRED (public). HTTP listen address. The loopback default reaches nobody off the host; a reachable deployment binds an address the TLS-terminating proxy can front. Nothing validates the address at startup — a public bind starts exactly as quietly as the default |
wwwroot | "wwwroot" | Static files (the verification front-end) |
delegate_key_file | (unset) | REQUIRED (public). Solana keypair of the postoffice’s standing delegate — the key that signs on-chain domain authorizations. A key source: a file path (default) or a cloud secret-manager secret. Re-loaded from the configured source on every POST /domain (for a cloud kind, a fresh fetch per request), so a delegate rotation is picked up by swapping the file or cloud secret, no restart. Boot-validated: an unreadable or malformed key fails startup, not the first request. Unset, POST /domain replies 503 (DNS lookups still work). A hot key by design — rotate it on a schedule |
json_rpc_url | (unset — Solana CLI config) | REQUIRED (public). Solana RPC endpoint the delegate’s on-chain authorization is sent through. Unset or blank is no override: the endpoint then resolves the way the sithbit CLI does — the CLI config’s json_rpc_url or a bare JSON_RPC_URL environment variable, and failing both the CLI’s default cluster, mainnet-beta. A container with no CLI config therefore signs against mainnet unless this names the intended cluster |
[health], [observability] | (the shared defaults) | RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table |
Its on-chain calls resolve the
RPC endpoint in this order:
json_rpc_url above when set; otherwise the way the sithbit CLI does —
the config file at ~/.config/solana/cli/config.yml, managed with
sithbit config get/set,1 or a bare JSON_RPC_URL environment
variable; and failing both, the CLI’s default cluster, mainnet-beta.
[mail_hosts] — client autoconfiguration
The public IMAP/POP/SMTP coordinates domain-sithbit advertises to
unmodified mail clients through its autoconfig/autodiscover routes (see
domain-sithbit: client autoconfiguration).
Every sub-server is dev-defaulted to a loopback stack on the standard
implicit-TLS mail ports, so an empty config still serves a valid document;
point the hosts at your real, publicly reachable servers before
advertising them.
| Key | Default | Meaning |
|---|---|---|
[mail_hosts.imap] host | "127.0.0.1" | REQUIRED (public). Public IMAP hostname a client connects to. The loopback default advertises a stack no remote client can reach |
[mail_hosts.imap] port | 993 | RECOMMENDED (public). IMAP port. The default is the standard implicit-TLS port; confirm it matches the listener you are advertising |
[mail_hosts.imap] socket_type | "SSL" | RECOMMENDED (public). Transport security (see below) — keep it consistent with the port you advertise |
[mail_hosts.pop] host | "127.0.0.1" | REQUIRED (public). Public POP3 hostname — the loopback default advertises an unreachable server |
[mail_hosts.pop] port | 995 | RECOMMENDED (public). POP3 port; the default is the standard implicit-TLS one |
[mail_hosts.pop] socket_type | "SSL" | RECOMMENDED (public). Transport security — keep it consistent with the port |
[mail_hosts.smtp] host | "127.0.0.1" | REQUIRED (public). Public submission hostname — the loopback default advertises an unreachable server |
[mail_hosts.smtp] port | 465 | RECOMMENDED (public). Submission port — implicit-TLS SMTPS by default (RFC 8314); set 587 with socket_type = "STARTTLS" to advertise the opt-in secondary instead |
[mail_hosts.smtp] socket_type | "SSL" | RECOMMENDED (public). Transport security — keep it consistent with the port |
socket_type takes the Thunderbird tokens "SSL" (implicit TLS from
connect), "STARTTLS" (opportunistic upgrade), or "plain" (no
encryption, dev only); lowercase aliases ("ssl", "starttls") are also
accepted, and the Outlook POX <SSL>/<Encryption> flags are derived
from it. A present [mail_hosts.<server>] sub-table must spell out
all three fields (a partial table fails startup loudly); an omitted
whole sub-server falls back to its defaults. Env overrides use the usual
__ descent, e.g. DOMAIN_SITHBIT_MAIL_HOSTS__SMTP__HOST=mail.example.com.
[mta_sts] — MTA-STS policy publication
The
MTA-STS
policy (RFC 8461) this instance publishes at GET /.well-known/mta-sts.txt
for the domains it fronts (see domain-sithbit: publishing the MTA-STS
policy). The whole
section is opt-in: absent, the endpoint replies 404. Senders fetch the
policy as https://mta-sts.<domain>/.well-known/mta-sts.txt, so front the
service with a TLS proxy holding a certificate for that hostname.
| Key | Default | Meaning |
|---|---|---|
mode | "testing" | RECOMMENDED (public). Choose the posture deliberately rather than inheriting it. What the policy demands of senders: "enforce" (MX mismatch or TLS failure = do not deliver), "testing" (failures are reported, delivery proceeds), or "none" (the domain withdraws its policy). The default reports without blocking mail |
mx | [] | REQUIRED (public) when the section is present. Enforced at startup — an enforce/testing policy with an empty list refuses to boot. MX identity patterns senders match delivery targets against — exact hostnames (mx.example.com) or a *. wildcard covering exactly one leftmost label (*.example.com). Must cover every host your MX records name. Required — at least one — unless mode = "none" |
max_age | 604800 (one week) | Policy lifetime in seconds — how long senders cache it. RFC 8461 recommends weeks. Values above one year (31557600, the §3.2 ceiling senders clamp to anyway) are rejected |
Validation is fail-fast at startup, never per request: a mode outside
the RFC vocabulary, an enforce/testing policy with an empty mx
list, or an over-ceiling max_age all refuse to boot rather than serve
a broken policy. Remember to bump the _mta-sts.<domain> TXT record’s
id whenever you edit this section — senders re-fetch the policy only
when that id changes (see DNS
setup).
-
This is the same file (
~/.config/solana/cli/config.yml, same location on Windows too) the Solana CLI’s ownsolana configcommand reads and writes, if you already have it installed — see CLI Quickstart. ↩
IPFS services settings
Part of the configuration reference. Covers the two
standalone IPFS binaries — sithbit-ipfsd (the self-hosted pin daemon
fleets can share) and sithbit-gateway (the read-only HTTP path gateway)
— plus the [swarm] service-record freshness settings shared by
sithbit-ipfsd and sithbitd’s own embedded node.
sithbit-ipfsd
The self-hosted IPFS node as its own daemon: the same embedded
repo/swarm sithbitd can run in-process, behind a small HTTP pin API
(POST/DELETE /pins/{name}, GET /ipfs/{cid}) for fleets that share
one node via [ipfs] kind = "remote". Config file sithbit_ipfsd.toml
(or SITHBIT_IPFSD_CONFIG), env prefix SITHBIT_IPFSD_. Row markers on
this page’s tables follow the going-public
legend.
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8182" | REQUIRED (public). HTTP listen address for the pin API. The loopback default is what keeps that write surface private; any other address exposes it to whatever can route there, so the daemon refuses to start on a non-loopback address unless auth_token is set (IPv4 and IPv6 loopback are exempt) |
auth_token | (unset — open) | REQUIRED (public). Enforced at startup. Bearer token required on every request when set. Unset (or blank), the token check passes every request, so a non-loopback bind_addr would leave the pin API (a write surface that stores and unpins blocks) open to everything that can reach the port — IpfsdConfig::validate refuses that pairing before the daemon listens, naming both settings and the remedy (set the token, or bind loopback). A loopback bind_addr needs no token |
max_pin_bytes | 33554432 (32 MiB) | RECOMMENDED (public). POST body limit; larger uploads are refused with 413. Size it against the largest object your fleet pins rather than inheriting the dev default |
[blobs] | local ipfs/ dir | REQUIRED (public). The local ipfs/ directory is single-host storage; a fleet needs the shared S3/Azure bucket every daemon writes and sithbit-gateway reads. Block/pin storage — the same shape and semantics as sithbitd’s [ipfs.blobs] |
[swarm] | (unset — no swarm) | RECOMMENDED (public). Absent, the node keeps its blocks to itself — decide deliberately whether this daemon joins a public DHT. When it does, identity_file becomes REQUIRED (public): without a persisted identity the PeerId changes on every restart and the DHT forgets the node. Identical to sithbitd’s [ipfs.swarm] (listen/bootstrap/provide/kad_protocol/identity_file); every pin announces to it, and pinned blocks serve over bitswap |
[cluster] | (unset — solo, no GC) | RECOMMENDED (public). Absent means a solo node and no garbage collection, so unreferenced blocks accumulate indefinitely; more than one daemon over one bucket needs the section. Identical to sithbitd’s [ipfs.cluster] (heartbeat/TTL/GC settings): N daemons over one [blobs] bucket heartbeat a membership roster in the bucket, partition the DHT reprovide keyspace by rendezvous hashing, and GC unreferenced blocks. See Scaling out |
[health], [observability] | (the shared defaults) | RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table |
[swarm] — service-record freshness
When a node advertises itself for decentralized service
discovery — publishing a signed
service record on the DHT so clients
can find its POP/IMAP endpoints without DNS SRV — two settings in the swarm
section govern how fresh that advertisement stays. They live under
[ipfs.swarm] for sithbitd and under [swarm] for sithbit-ipfsd (the same
section that carries listen/bootstrap/provide/identity_file), and both
have in-code defaults, so an empty swarm section is still valid — a plain node
that never advertises a service simply ignores them.
| Key | Default | Meaning |
|---|---|---|
service_record_ttl_secs | 900 (15 min) | How long an advertised record stays fresh from its created_at stamp. Deliberately minutes-scale — service liveness wants minutes, unlike the ~22 h content-reprovide cadence — so a node that stops heartbeating ages out of discovery quickly. Validated on the client’s DHT get, so a lapsed record is dropped before it is ever trusted |
service_heartbeat_interval_secs | 300 (5 min) | How often the node re-stamps created_at and re-publishes its records. Keep it comfortably below service_record_ttl_secs so a record never lapses between heartbeats (the default 5 min ≪ 15 min TTL leaves two missed beats of slack) |
reprovide_interval_secs | 79200 (22 h) | How often the node re-announces its provider records for every pinned block. DHT provider records expire (~24 h on the public network), so long-lived pins must be re-provided inside that window — keep it below the expiry with some slack, as the default does |
kad_protocol | "/ipfs/kad/1.0.0" | Kademlia protocol id to speak. The default is the public IPFS network’s; on a private network set "/ipfs/lan/kad/1.0.0" — Kubo runs a separate LAN DHT under that id for peers without public addresses and filters private-address peers out of the public one |
These settings affect only advertisement freshness. Authority — who may
serve the domain — is proved separately by the node’s
delegation chaining to the
on-chain MailDomain.authority, checked by the client both on the DHT record
and in the self-authenticating TLS
handshake,
never by these timers. A public DHT advertising POP/IMAP endpoints is
enumerable, so the usual [*.server] connection limits and DNSBL/DBL still
apply to the listeners those records point at.
sithbit-gateway
The read-only IPFS HTTP path gateway: GET/HEAD /ipfs/{cid}
(deserialized, plus trustless ?format=raw|car) over the same
block/pin bucket the node writes; locally-absent CIDs answer 404 — it
never fetches from the IPFS network. Config file ipfs_gateway.toml
(or IPFS_GATEWAY_CONFIG), env prefix IPFS_GATEWAY_.
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8183" | REQUIRED (public). HTTP listen address; the loopback default serves no remote reader. The surface is read-only, so no auth gate exists — bind it where readers live, behind the TLS-terminating proxy |
public_host | (unset — path gateway only) | RECOMMENDED (public). Set it if browsers will load content from this gateway — subdomain routing is what gives each CID its own browser origin — and leave it unset otherwise. Base domain for the subdomain (Host-based) gateway: set it to also serve <base32-cidv1>.ipfs.<public_host> browser-origin-isolated requests. Unset keeps path routing only |
[blobs] | local ipfs/ dir | REQUIRED (public). The local ipfs/ default is a separate, empty store from the fleet’s, and every lookup against it answers 404. Block/pin storage — the same shape as sithbit-ipfsd’s [blobs]; point it at the shared bucket the node/cluster pins into. The gateway only reads it |
[health], [observability] | (the shared defaults) | RECOMMENDED (public). The two shared sections; this binary’s health port is in the Monitoring table |
mail-grpc settings
Part of the configuration reference. Covers the
chain gateway’s own settings — the gRPC listen address, the mutual-TLS
[auth] section that decides who may call it, the fee-payer keypair, and
the alias enumeration index.
mail-grpc
Config file mail_grpc.toml (or MAIL_GRPC_CONFIG), env prefix
MAIL_GRPC. An empty or missing file is a runnable dev gateway: the
chain endpoint and the signing keypair fall back to the operator’s
Solana CLI config (~/.config/solana/cli/config.yml),1 exactly like
the sithbit CLI — a missing CLI config means the stock CLI defaults
(mainnet-beta). This replaced the legacy environment-only configuration
as a clean break: GRPC_SERVER_ADDRESS, DEFAULT_KEYPAIR, and the other
old names are no longer read — see the mail-grpc chapter
for the migration note. The one legacy name still honored is bare
JSON_RPC_URL, which overrides the endpoint for parity with the CLI (see
the json_rpc_url row below). Row markers follow the going-public
legend.
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:50051" | OPTIONAL on loopback. gRPC listen address. Loopback is the one address that runs unauthenticated; naming any other makes the whole [auth] section REQUIRED, and startup fails without it |
max_bounty_lamports | 100000000 (0.1 SOL) | Ceiling on a SendMail request’s bounty_lamports, which the gateway escrows from its own wallet; over-cap requests are refused with INVALID_ARGUMENT before anything reaches the chain. 0 refuses bountied sends outright. See the guardrails |
fee_payer_floor_lamports | 10000000 (0.01 SOL) | Balance below which the write RPCs are refused with UNAVAILABLE and /readyz reports not-ready, so a drained wallet is one honest refusal and an alert rather than every write failing a preflight at a time. The balance is sampled in the background (every 30 s), so the check costs no per-request round trip; a balance that has never been read successfully permits writes. 0 disables the floor |
json_rpc_url | (unset — Solana CLI config) | Solana RPC endpoint; unset falls back to the CLI config’s json_rpc_url. A bare JSON_RPC_URL environment variable overrides this (env > this key > CLI config), matching the sithbit CLI |
keypair | (unset — Solana CLI config) | The fee-payer/signing keypair, a key source: a keypair-file path (not the keypair content) or a cloud secret-manager secret holding the keypair JSON. Unset falls back to the CLI config’s keypair_path (~/.config/solana/id.json by default) |
alias_cache_seconds | 30 | TTL for the ResolveAlias cache (0 disables caching). Kept short because sold/transferred aliases must not resolve stale; when the alias indexer runs, marketplace events evict entries within its poll interval anyway |
[auth] cert | (unset) | REQUIRED when bind_addr is not loopback. PEM certificate chain for the mTLS listener — an Ed25519 certificate, because callers pin the gateway by the Ed25519 key in it (their gateway_key), not by a CA. A bare path reads a file; the table form fetches from a cloud secret manager (kind = "akv"/"asm"/"gsm"), like keypair |
[auth] key | (unset) | REQUIRED when bind_addr is not loopback. Private key matching cert, PEM. Same source forms |
[auth] authorized_keys | [] | REQUIRED when bind_addr is not loopback. Base58 Ed25519 public keys allowed to call — the same 32-byte transport identity the MX servers bind for SASL EXTERNAL. An empty list is not “allow all”: it is an incomplete section and startup is refused. One entry per calling process (sithbitd, a standalone smtp-server, account-api) so revocation and attribution are per-process; the four protocols inside sithbitd share one channel and therefore one key |
[alias_index] database | "alias_index.db" | SQLite path for the alias-enumeration index. Set explicitly empty to disable the indexer (ListAliases/ListSales then answer UNAVAILABLE) |
[alias_index] poll_seconds | 5 | How often the indexer polls for new alias transactions |
[health], [observability] | health on 127.0.0.1:8193 | The two shared sections above |
The callers’ half. Each calling process presents its own Ed25519
client certificate and pins the gateway’s key: sithbitd’s
[grpc.tls], the
standalone SMTP server’s
[grpc_tls], and account-api’s
[chain.grpc_tls] all take the same three settings —
cert, key, gateway_key — and the endpoint they dial becomes
https://. sithbit-console has no such table and reaches only an
unauthenticated (loopback) gateway.
The alias index is derived state: it backfills from chain history on an empty database, so the file needs no backup (see Monitoring and backups).
-
This is the same file (
~/.config/solana/cli/config.yml, same location on Windows too) the Solana CLI’s ownsolana configcommand reads and writes, if you already have it installed — see CLI Quickstart. ↩
Standalone protocol servers
Part of the configuration reference. Covers the
three single-protocol dev/pilot binaries — pop-server, imap-server,
smtp-server — for protocol work and pilots that want exactly one
listener, as an alternative to the combined
sithbitd daemon.
Standalone protocol servers
sithbitd is the production daemon, but each mail protocol also ships
as its own dev/pilot binary — pop-server, imap-server, and
smtp-server — for protocol work and pilots that want exactly one
listener. POP and IMAP serve in-memory dev accounts; the SMTP binary
logs accepted mail rather than storing it, and verifies recipients
against its [[mailboxes]] fixtures unless a grpc_endpoint points it
at a real gateway. Each follows the same
layering as the other TOML binaries: config
file pop_server.toml / imap_server.toml / smtp_server.toml in the
working directory (or the path in POP_SERVER_CONFIG /
IMAP_SERVER_CONFIG / SMTP_SERVER_CONFIG), env prefixes POP_SERVER
/ IMAP_SERVER / SMTP_SERVER, and an empty or missing file is a
runnable loopback dev instance. The shipped files are the annotated
per-key documentation, every default commented out — bar a live
exception each, so the offline dev instance works without certificates
or DNS: the POP and IMAP files set require_tls = false, and the SMTP
file sets sender_auth = "none" (it needs no require_tls line — that
key already defaults off in MX mode).
The keys sithbitd nests under [pop] / [imap] / [smtp] sit at the
top level of these files — [server], [tls], and
[auth_rate_limit] are top-level tables here, not [pop.server] and
friends. And unlike under sithbitd, each binary reads its own
[auth_rate_limit] section — it serves one protocol, so there is no
shared budget to defer to (see the cross-connection login
budget). A
misspelled key at any level fails startup naming it, the same
deny_unknown_fields contract the daemon’s sections carry. Row markers
follow the going-public
legend.
pop-server
| Key | Default | Meaning |
|---|---|---|
hostname | "localhost" | REQUIRED (public). Hostname used in CRAM-MD5 challenges and the APOP banner — the "localhost" default names the wrong host to every remote client, and a CRAM-MD5 challenge is only as good as the name it carries |
require_tls | true | REQUIRED (public). Refuse credential-bearing commands (USER/PASS, AUTH, APOP) until TLS is active (the production posture). The shipped dev file sets false so the loopback instance works without certificates — set it back to true before the listener is reachable |
enable_apop | false | Advertise APOP via a timestamp banner in the greeting (RFC 1939 §7) — same semantics and trade-off as sithbitd’s pop.enable_apop |
enable_stored_passwords | true | RECOMMENDED (public). Advertise CRAM-MD5 — same semantics as sithbitd’s pop.enable_stored_passwords. Decide the mode deliberately: it needs a plaintext-recoverable secret, and an account holding one keeps its mail unsealed at rest |
max_login_attempts | 3 | Failed logins tolerated on one connection, each refusal tarpitted (2s, then 4s, doubling) — same semantics as sithbitd’s pop.max_login_attempts |
[auth_rate_limit] | (on — 10 per 900 s, 10000 pairs) | RECOMMENDED (public). The cross-connection login budget: max_failures, window_secs, max_tracked. On by default, but a reachable listener should size the budget against the traffic it expects |
[server] | plaintext 127.0.0.1:1100 | REQUIRED (public) for bind_addr, RECOMMENDED (public) for the [server.limits] sub-table. The shared listener section: bind_addr, implicit_tls, proxy_protocol, proxy_trusted, and the [server.limits] sub-table (unprivileged stand-in for POP3’s 110; production runs implicit TLS on 995). The default binds loopback only, and proxy_trusted is one of the few settings enforced at startup: an empty list with proxy_protocol on is refused |
[tls] | (absent — plaintext) | REQUIRED (public). certs / key, each a key source holding PEM; enables STLS (and implicit TLS with server.implicit_tls). Absent, the listener is plaintext-only — which is why the dev file has to disable require_tls |
[[accounts]] | (no entries) | REQUIRED (public). Dev accounts served by the in-memory backend, one entry per account: user, secret. Each gets an empty maildrop. The shipped file carries a live fixture account whose password is written out in that file — remove it before the listener is reachable |
[health], [observability] | health on 127.0.0.1:8194 | RECOMMENDED (public). The two shared sections above |
imap-server
| Key | Default | Meaning |
|---|---|---|
hostname | "localhost" | REQUIRED (public). Named in the greeting and CRAM-MD5 challenges — the "localhost" default names the wrong host to every remote client |
require_tls | true | REQUIRED (public). Refuse LOGIN/AUTHENTICATE until the connection is protected, advertising LOGINDISABLED (the production posture). The shipped dev file sets false so the loopback instance works without certificates — set it back to true before the listener is reachable |
max_message_size | 26214400 (25 MiB) | Largest accepted APPEND literal, in octets — same semantics as sithbitd’s imap.max_message_size, down to being advertised as the APPENDLIMIT capability and named in the refusal a synchronizing literal draws |
max_login_attempts | 3 | Failed logins tolerated before the session ends with BYE, each refusal tarpitted — same semantics as sithbitd’s imap.max_login_attempts |
idle_command_timeout_secs | 1800 (30 min) | How long a client may sit silent inside an accepted IDLE before the session ends with BYE — same semantics as sithbitd’s imap.idle_command_timeout_secs, replacing this binary’s [server.limits] idle_timeout_secs for the duration of the exchange. Both default to 1800, so at shipped defaults the replacement changes nothing — it bites only where the shared deadline has been lowered, keeping an idler alive under a listener tuned for shorter reads |
[auth_rate_limit] | (on — 10 per 900 s, 10000 pairs) | RECOMMENDED (public). The cross-connection login budget: max_failures, window_secs, max_tracked. On by default, but a reachable listener should size the budget against the traffic it expects |
[server] | plaintext 127.0.0.1:1430 | REQUIRED (public) for bind_addr, RECOMMENDED (public) for the [server.limits] sub-table. The shared listener section, same fields as pop-server’s (unprivileged stand-in for IMAP’s 143; production runs implicit TLS on 993). The default binds loopback only |
[tls] | (absent — plaintext) | REQUIRED (public). certs / key, each a key source; enables STARTTLS (and implicit TLS with server.implicit_tls). Absent, the listener is plaintext-only — which is why the dev file has to disable require_tls |
[[accounts]] | (no entries) | REQUIRED (public). Dev accounts, one entry per account: user, secret. Each gets an empty INBOX — deliver test mail with APPEND. The shipped file carries a live fixture account whose password is written out in that file — remove it before the listener is reachable |
[health], [observability] | health on 127.0.0.1:8196 | RECOMMENDED (public). The two shared sections above |
smtp-server
One binary, either SMTP role per mode. Accepted mail is logged, not
stored — the store-backed delivery pipeline is sithbitd’s.
| Key | Default | Meaning |
|---|---|---|
hostname | "localhost" | REQUIRED (public). The EHLO/greeting domain, the Received: by host, the CRAM-MD5 challenge seed, and the SPF host domain — the "localhost" default is wrong on the wire for every peer, and receiving MTAs judge mail by it |
greeting | "SithBit ESMTP service ready" | Free text after the hostname in the 220 banner — same semantics as sithbitd’s |
enable_stored_passwords | true | RECOMMENDED (public). Advertise CRAM-MD5 on the submission listener — same semantics as sithbitd’s smtp.enable_stored_passwords. Decide the mode deliberately: it needs a plaintext-recoverable secret, and an account holding one keeps its mail unsealed at rest |
mode | (unset — runs as "mx") | RECOMMENDED (public). "mx" (inbound, sender policy active, no relay) or "submission" (AUTH over TLS, relay allowed). Unset behaves as "mx" here; only sithbitd distinguishes the two, filling in "submission" for a [submission] section that named no role. Name the role explicitly on a public instance: unset is "mx", so a submission service that never says so relays nothing |
local_domains | (the hostname) | REQUIRED (public). Domains accepted for local delivery; empty defaults to [hostname]. No chain discovery here — list them explicitly, or a public MX accepts mail for nothing it means to serve |
postmaster_wallet | (unset) | RECOMMENDED (public). Wallet bare/@local-domain postmaster mail delivers to, bypassing the frombox postage gate (RFC 5321 §4.5.1) — same semantics as sithbitd’s |
sender_auth | "spf" | RECOMMENDED (public). MX-mode sender authentication: "spf", "dmarc-lite", "dmarc", or "none". The shipped dev file sets "none" so offline dev skips DNS lookups — a public MX must choose a real policy; RUA/RUF aggregate reporting is not wired in this standalone binary — sithbitd does it |
dnsbl_zone | (unset — off) | RECOMMENDED (public). DNSBL zone the connecting peer’s IP is checked against at accept time. Off by default; a public MX wants one |
dbl_zone | (unset — off) | RECOMMENDED (public). Domain block list zone the sender domain is checked against at EHLO/MAIL FROM. Off by default; a public MX wants one |
grpc_endpoint | (unset — dev fixtures) | REQUIRED (public). The mail-grpc gateway for alias/frombox lookups, e.g. "http://127.0.0.1:50051" (https:// once grpc_tls is set); absent, recipients verify against the [[mailboxes]] fixtures below, which no real deployment wants |
grpc_tls | (absent — plaintext) | REQUIRED (public) once the gateway enforces [auth] — every gateway not on this host’s loopback. The server’s half of the mutual TLS, the three keys below — all or none. The endpoint must then be https://; http:// with this table, or https:// without it, refuses to start. Add the server’s key to the gateway’s authorized_keys |
grpc_tls.cert | (unset) | REQUIRED (public) with grpc_tls. The server’s PEM client certificate — a path or a key source. Its Ed25519 key is what the gateway allow-lists |
grpc_tls.key | (unset) | REQUIRED (public) with grpc_tls. Private key for cert, PEM. Same source forms |
grpc_tls.gateway_key | (unset) | REQUIRED (public) with grpc_tls. The base58 Ed25519 key in the gateway’s certificate, pinned — no CA, no hostname check; any other certificate fails the handshake |
[auth_rate_limit] | (on — 10 per 900 s, 10000 pairs) | RECOMMENDED (public). The cross-connection login budget. SMTP has no max_login_attempts sibling, so in mode = "submission" this table is the only authentication budget in the stack — all the more reason to size it against the traffic you expect |
[server] | plaintext 127.0.0.1:2525 | REQUIRED (public) for bind_addr, RECOMMENDED (public) for the [server.limits] sub-table. The shared listener section, same fields as the other two (production MX runs 25 with STARTTLS, submission implicit TLS on 465). The default binds loopback only |
[tls] | (absent — plaintext) | REQUIRED (public). certs / key, each a key source; enables STARTTLS (and implicit TLS with server.implicit_tls). Absent, the listener is plaintext-only, and submission’s own require_tls default then refuses every AUTH |
[[accounts]] | (no entries) | REQUIRED (public) in submission mode. Dev submission accounts (mode = "submission"), one entry per account: user, secret. The shipped file carries a live fixture account whose password is written out in that file — remove it before the listener is reachable |
[[mailboxes]] | (no entries) | Dev local mailboxes used when grpc_endpoint is absent, matched against RCPT addresses in local_domains: local_part, wallet, stamps (default 1), required_postage (default 0 lamports) |
[health], [observability] | health on 127.0.0.1:8195 | RECOMMENDED (public). The two shared sections above |
The standalone binary shares sithbitd’s [smtp] config shape, so the
remaining listener keys — require_tls (defaulted by mode: off for MX,
on for submission), max_message_size, max_recipients,
max_messages, max_recipient_errors, client_cert_auth,
self_service_base_url, accept_wallet_literals, and the [quota]
table — all parse here too, with the defaults documented under
sithbitd’s [smtp]/[submission]
section. Two of them are
inert by construction: the quota
gate wires no account
store here, and self_service_base_url carries only the funding link
(the DND gate is sithbitd’s). The POP and IMAP binaries likewise accept
client_cert_auth for SASL
EXTERNAL, off by default and
inert without [tls].
sithbit-migrate settings
The store-migration tool’s configuration: two complete store definitions in one file. For what the tool moves, the dry-run/commit split, and the caveats to read before committing, see the operator guide sithbit-migrate: moving a store between backends.
sithbit-migrate
The one-shot tool that copies an existing store into another backend — accounts, mailboxes, messages with their chain state, blobs, and the job queue. It runs, reports, and exits; it serves no listener and no health endpoint, so nothing polls it.
Unlike every other binary — each of which carries a single
[store] section — the
migrator reads one store and writes another, so it holds two independent
store configurations under [source] and [target]. Both take exactly the
[store] shape the servers use, down to the per-backend sub-sections
([source.aws], [target.postgres], [target.blobs], and the rest), and
both carry the same defaults — so a section you leave out is the dev SQLite
store rather than an error. An empty or absent file migrates that store onto
itself, a harmless no-op, which is what makes a dry run safe to try first.
Config file sithbit_migrate.toml (or SITHBIT_MIGRATE_CONFIG), env prefix
SITHBIT_MIGRATE. Individual settings override from the environment the
usual way, with __ descending one nesting level — e.g.
SITHBIT_MIGRATE_TARGET__KIND=aws, SITHBIT_MIGRATE_TARGET__AWS__TABLE=sithbit-prod.
Row markers follow the going-public
legend, read
here as “before a real cross-backend run” rather than “before a listener
goes public” — this tool binds nothing. Every marker is an operator
obligation: the migrator has no validate(), and the no-op defaults are a
document that runs cleanly while moving nothing.
| Key | Default | Meaning |
|---|---|---|
[source] | (dev SQLite) | REQUIRED (public). Left at its default this section is the dev SQLite store, so the run reads the wrong store and reports success. The store you are leaving — a whole [store] section under another name, typically kind = "sqlite". Every key and sub-section the [store] reference lists applies here unchanged |
source.kind | "sqlite" | REQUIRED (public). Name the backend you are actually reading; nothing infers it. Backend of the store being read. The migrator compiles in every backend, so any kind may appear on either side |
source.database | "sithbit.db" | RECOMMENDED (public). SQLite database file to read (kind = "sqlite"). The default is the dev file name — point it at the store you are actually leaving, and note that a missing file opens an empty database rather than failing |
source.credential_key_file | "credential.key" | REQUIRED (public). The key that seals stored mail passwords in the source — it must be the key that store was actually written with, or the passwords it holds cannot be read back |
[target] | (dev SQLite) | REQUIRED (public). Left at its default the target is the same dev SQLite store as the source, which is the harmless no-op the tool ships with. The store you are moving to — the same whole [store] shape. Its tables, queues, and lease store are created idempotently when it opens, exactly as they are when a server first boots against it. The one exception is an S3 blob bucket: like the servers, the migrator assumes it already exists (Azure blob containers are auto-created; S3 buckets — including GCS buckets reached through the interop endpoint — are not) |
target.kind | "sqlite" | REQUIRED (public). The setting that decides where the data lands. Backend of the store being written: "postgres", "aws", "azure", "turso" or "cloudflare" for a real migration |
target.credential_key_file | "credential.key" | REQUIRED (public). Must resolve to the same 32 bytes as the source’s, and the migrator enforces it: before any migration step, dry run and --commit alike, it compares the two keys and refuses the run when they differ ([source] credential_key_file and [target] credential_key_file hold different keys: migrated mail passwords would be unreadable on the target. Copy the source key file to the target before migrating.). The key seals every stored mail password, which the copy carries as ciphertext, so a target opened under a different key would hold credentials nobody can read back. Copy the file, or point both sections at the same cloud secret, before migrating — and note that the target store is opened (and a missing target key file is generated fresh on disk) before the check fires, so a path that does not exist yet is refused as a mismatch and leaves a stray generated key file behind; copying the source key over it resolves both. See the caveats |
Because both sections are [store] sections, their per-backend sub-sections
are documented once, on the [store] reference —
[source.blobs] and [target.blobs] for the blob store, [target.postgres]
for url, [target.aws] for region / table / queue_prefix,
[target.azure] for account / table / queue_prefix / access_key, and
[target.turso] / [target.cloudflare] likewise. Only the prefix differs.
The one shape that does not simply follow the prefix is Cloudflare, whose
blobs come from [target.cloudflare.r2] rather than [target.blobs].
A minimal SQLite-to-AWS file:
[source]
kind = "sqlite"
database = "sithbit.db"
[target]
kind = "aws"
credential_key_file = "credential.key"
[target.aws]
region = "us-east-1"
table = "sithbit"
queue_prefix = "sithbit"
The annotated mail_migrate/sithbit_migrate.example.toml shows the same
document with every entry commented out at its default, and the PostgreSQL
and Azure alternatives doubly commented beneath it — each replaces a
table above rather than adding to it, so uncomment one level at a time.
Nothing reaches the target until you pass --commit; see Dry-run, then
commit.
Which services get a .env
Part of the configuration reference. A cross-cutting
summary of which binaries ship a sample .env template, which ones layer
.env/.env.$APP_ENV without shipping a sample, and which ones read no
environment file at all.
Which services get a .env
mail_spooler, account_api, domain_sithbit, ipfs_daemon,
ipfs_gateway, pop_server, imap_server, and smtp_server each ship
a sample .env and .env.development in their crate directory —
commented-out templates for every environment-variable override, using
the precedence chain above. Copy the ones you need and uncomment.
The workspace’s other binaries deliberately don’t have one:
mail-grpc,sithbit-console, andsithbit-migrateuse the same layered.env/.env.$APP_ENVmechanism as the binaries above (each reads them from the working directory, under its ownMAIL_GRPC_*/SITHBIT_CONSOLE_*/SITHBIT_MIGRATE_*overrides), but ship no sample: each one’s whole surface is a single short table —mail_grpc/mail_grpc.example.tomlalready shows every gateway key, and the console’s and the migrator’s live above and in the migration chapter. The workspace-root.envthat used to configuremail-grpcis retired — its legacy names (GRPC_SERVER_ADDRESS,DEFAULT_KEYPAIR, …) are no longer read by anything, and the file survives only as commented-out devnet fixture history.- The
sithbitCLI (mail_client) reads no.envat all; its only configuration inputs are the config filesithbit configmanages1 and theJSON_RPC_URLenvironment variable. mail_program/alias_program/domain_programare on-chain SBF programs with no runtime environment.appconfig_genis a repo-side generator (cargo run -p appconfig-gen, never deployed): it readsiac/appconfig/’s AWS TOML documents and writes the Azure kvset artifact beside them, so it has no runtime configuration of its own.
Every remaining workspace crate is a library the binaries above link, with nothing to configure at runtime.
-
This is the same file (
~/.config/solana/cli/config.yml, same location on Windows too) the Solana CLI’s ownsolana configcommand reads and writes, if you already have it installed — see CLI Quickstart. ↩
DNS setup
Two independent sets of records matter to a SithBit operator: the classic mail records that let the rest of the internet find and trust your server — and let your users’ mail clients locate its POP/IMAP/ submission ports — and the SithBit-specific record that proves you control a domain before it is authorized on-chain.
Throughout, example.com is the mail domain (the part after @) and
mail.example.com is the host running sithbitd.
Receiving mail: MX
example.com. MX 10 mail.example.com.
mail.example.com. A 203.0.113.25
The MX target must be an A/AAAA name, not a CNAME. Add
example.com to [smtp] local_domains in sithbitd.toml so the MX
listener accepts mail for it, and set [smtp] hostname (and
[spooler] hostname) to mail.example.com so the EHLO greeting
matches DNS — many receivers score a mismatch as spam.
One server may host several mail domains: point each domain’s MX at the
same host and list them all —
local_domains = ["example.com", "example.net"]. Each domain needs its
own on-chain authorization (below) under the same authority key,
its own DKIM record and [[spooler.dkim]] entry, and the daemon warns
at startup about any listed domain whose on-chain authority doesn’t
match (see the
configuration reference).
Inbound sender checks (sender_auth = "spf", the default, or
"dmarc-lite") need nothing from your DNS; they query the sender’s
records.
Sending mail: SPF, DKIM, DMARC, PTR
Receivers judge your outbound mail by these records; without them, expect the spam folder.
SPF — authorize your relay host to send for the domain:
example.com. TXT "v=spf1 mx -all"
mx authorizes whatever your MX records point at; add ip4:/ip6:
mechanisms if outbound mail leaves from other addresses, or
include: your smarthost provider when [spooler.smarthost] routes
mail through one.
DKIM — sithbitd signs authenticated submissions (rsa-sha256,
RFC 6376) when [spooler.dkim] is configured. Generate a key and
publish its public half at {selector}._domainkey.{domain}:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out dkim.pem
openssl rsa -in dkim.pem -pubout -outform DER | openssl base64 -A
[spooler.dkim]
domain = "example.com"
selector = "mail"
key_file = "dkim.pem" # PEM, PKCS#8 or PKCS#1
mail._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=<the base64 output>"
Only authenticated submission is signed — mail arriving at the MX from other servers relays unsigned, which is correct: it isn’t yours.
DMARC — tell receivers what to do when SPF/DKIM fail, and where to send reports:
_dmarc.example.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
Add a ruf= address (and fo=1 to request a report on any failure) to
also receive per-message failure/forensic reports — a copy of each failing
message, so point it at a mailbox you control:
_dmarc.example.com. TXT "v=DMARC1; p=quarantine; fo=1; rua=mailto:dmarc@example.com; ruf=mailto:forensic@example.com"
Start with p=none while you confirm SPF and DKIM pass, then tighten.
The rua= address can be your own SithBit deployment — other operators’
aggregate reports about example.com are how you confirm your SPF and
DKIM actually align in the wild. Point the record at your postmaster
mailbox:
_dmarc.example.com. TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@example.com"
Two settings pair up on this receiving side, both off by default (rows
in the configuration reference).
[smtp] postmaster_wallet names the wallet that mail for bare
postmaster / postmaster@<local-domain> is delivered to, exempting it
from the frombox/postage gate — without it an external reporter, who
never holds a prepaid frombox, is refused like any other stranger.
And [spooler.dmarc_rua_ingest] parses each delivered report (the
message still lands in the mailbox as ordinary mail) and stores the
result as JSON for the account API’s admin reader,
GET /v1/admin/dmarc-reports — see
account-api for the routes and the
conformance appendix
for exactly what ingestion does and doesn’t do with the data.
MTA-STS — downgrade-resistant TLS for server-to-server delivery
(RFC 8461). For outbound mail there is nothing to configure:
sithbitd‘s relay discovers and honors recipients’ published policies
automatically (the mta_sts switch in the
[spooler] reference).
To protect mail inbound to your own domain, publish the discovery
record and serve the policy — domain-sithbit publishes the policy for
you from its [mta_sts] config
section (see
domain-sithbit: publishing the MTA-STS
policy):
_mta-sts.example.com. TXT "v=STSv1; id=20260716T000000"
Senders fetch the policy as
https://mta-sts.example.com/.well-known/mta-sts.txt, so point an
mta-sts.example.com A (or CNAME) record at your domain-sithbit
deployment, fronted by a TLS proxy holding a certificate for that
hostname; the service serves the configured policy at that well-known
path:
version: STSv1
mode: enforce
mx: mail.example.com
max_age: 604800
The mx patterns must cover every host your MX records name, and the
TXT record’s id must change whenever the policy content does — bump
it every time you edit the [mta_sts] section, since senders cache the
policy by that id. Start with mode: testing (the config default)
while you confirm your MX serves STARTTLS with a certificate valid for
its hostname, then move to enforce.
DANE — DNSSEC-pinned TLS for server-to-server delivery
(RFC 7672),
the stronger sibling of MTA-STS. For outbound mail there is nothing
to configure: sithbitd‘s relay looks up and honors recipient MX
hosts’ DNSSEC-validated TLSA records automatically (the dane switch in
the [spooler] reference),
preferring them over MTA-STS. To protect mail inbound to your own
domain, your zone must be DNSSEC-signed (unsigned TLSA records
are ignored by every DANE sender); then publish a TLSA record for each
MX host:
_25._tcp.mail.example.com. TLSA 3 1 1 <sha256-of-the-SPKI>
3 1 1 — DANE-EE, SPKI selector, SHA-256 — is the recommended form: it
pins the certificate’s public key, so ordinary renewals that keep the
key don’t touch DNS. Compute the digest from your certificate:
openssl x509 -in fullchain.pem -pubkey -noout \
| openssl pkey -pubin -outform DER | sha256sum
When rotating to a new key, publish the new record alongside the old
one, swap the certificate, then drop the old record (senders accept a
match against any published record). If your certificate chain rotates
keys on every renewal, pin the issuing CA instead (2 1 1, DANE-TA) —
but note the presented chain must then include that CA certificate.
A signed domain that receives mail on the implicit-A fallback — no MX record, mail delivered to the domain host itself — gets DANE too: DNSSEC proves the absence of the MX record to senders (SithBit’s relay validates the denial from the negative answer’s SOA), so publish the TLSA record at the domain’s own name, no MX required:
_25._tcp.example.com. TLSA 3 1 1 <sha256-of-the-SPKI>
TLS-RPT — feedback on how MTA-STS and DANE hold up in practice (RFC 8460). Publishing a TLSRPT record asks sending MTAs to aggregate the TLS outcomes of their deliveries to your domain — successes, STARTTLS stripping, certificate failures — and report them daily to an address you name:
_smtp._tls.example.com. TXT "v=TLSRPTv1; rua=mailto:tls-reports@example.com"
(https: report targets are also allowed.) This is how a downgrade
attack against your domain becomes visible instead of just delaying
senders’ mail. On the sending side there is one switch: with
[spooler.tlsrpt]
enabled, sithbitd records its own outbound TLS results and sends these
reports to every recipient domain that publishes the record — see the
conformance appendix
for exactly what is recorded and reported.
PTR — the reverse record for your outbound IP must resolve to your
EHLO hostname (203.0.113.25 → mail.example.com). This is set with
your hosting provider, not in your zone; several large receivers
refuse mail from IPs without a matching PTR.
Client access: POP, IMAP, and submission SRV records (RFC 6186)
MX tells other servers where to deliver mail; these SRV records tell
your users’ mail clients which host and port to reach for POP3, IMAP,
and message submission — so a client that knows only an address and
password can configure the rest. RFC 6186 defines the labels and RFC 8314
adds the implicit-TLS variants clients should prefer; publish only the
ones matching listeners your sithbitd stack actually runs.
Implicit TLS (preferred — RFC 8314). SithBit’s production posture is
TLS-on-connect on the standard secure ports, matching the
[mail_hosts]
defaults:
_imaps._tcp.example.com. SRV 0 1 993 mail.example.com.
_pop3s._tcp.example.com. SRV 0 1 995 mail.example.com.
_submissions._tcp.example.com. SRV 0 1 465 mail.example.com.
STARTTLS (secondary). If you also run the opportunistic-upgrade
listeners, advertise them at a higher priority number (lower
preference) so compliant clients still reach for implicit TLS first. The
shipped [mail_hosts] submission default is now implicit TLS on 465 (per
RFC 8314); STARTTLS on 587 is an opt-in you configure and advertise only
if you run that listener:
_imap._tcp.example.com. SRV 10 1 143 mail.example.com.
_pop3._tcp.example.com. SRV 10 1 110 mail.example.com.
_submission._tcp.example.com. SRV 10 1 587 mail.example.com.
The four fields after SRV are RFC 2782 priority weight port target:
the lowest priority wins, weight load-balances ties among equal
priorities, and — as with MX — the target must be an A/AAAA host, never a
CNAME. Point it at the same mail.example.com your other records use.
Advertising only what you serve
Publish a record only for a protocol you actually run. To tell clients a
protocol is not offered — e.g. an IMAP-only server with no POP —
publish a single record with target . and port 0:
_pop3s._tcp.example.com. SRV 0 0 0 .
SRV is advisory and one of three autoconfiguration paths: a client that
ignores it falls back to the autoconfig/autodiscover
documents
domain-sithbit serves, or to manual setup
( Thunderbird,
Outlook) — keep the ports and transport security
here consistent with what those advertise. SithBit clients can also skip
DNS for access-server discovery entirely and resolve authorized POP/IMAP
nodes over the DHT (see decentralized service
discovery).
The autoconfig and autodiscover hostnames
The document fallback above only works if the wizard can reach
domain-sithbit at the well-known hostnames it probes: Thunderbird
fetches https://autoconfig.<domain>/mail/config-v1.1.xml (and, as a
second try, /.well-known/autoconfig/mail/config-v1.1.xml on the mail
domain itself), while Outlook posts to
https://autodiscover.<domain>/autodiscover/autodiscover.xml. Publish
both names, pointed at the host where your domain-sithbit instance is
reachable — a plain A/AAAA or, since these are HTTP hosts (not SRV
targets), a CNAME is fine:
autoconfig.example.com. CNAME mail.example.com.
autodiscover.example.com. CNAME mail.example.com.
The wizard fetches over HTTPS, so the service’s TLS certificate must
cover these names too (add them as SANs, or use a wildcard). The
documents themselves are rendered from
[mail_hosts] —
no per-domain zone content beyond the two records.
Authorizing a domain on-chain
Before wallets can register aliases and mailboxes under
@example.com, the domain must exist as an on-chain Domain account —
created by the postoffice’s delegate for a
claimed authority key. The
domain-sithbit service automates the claim with a DNS proof:
-
The domain owner generates (or picks) an ed25519 keypair and publishes its public key, base58-encoded, as a TXT record:
_solana.authority.example.com. TXT "<base58 ed25519 public key>" -
POST /domainon thedomain-sithbitservice with the domain name as the body. The service resolves that TXT record, validates the key, and — signing with its configured delegate key — submitsCreateDomainnaming the key as the domain’s authority. -
GETon the same service answers what the TXT record currently claims, which is useful for checking propagation before posting.
Operational notes:
- Without a configured
delegate_key_file,POST /domainreplies 503 — DNS lookups still work. See the configuration reference. - The TXT lookup goes to a public resolver, so freshly published records may take a propagation delay to become visible.
- The authority key is a real signing key — the mail server submits
SendMailwith it — so store it like a wallet, not like a DNS token. The TXT record can be removed after authorization; the on-chain account is what matters from then on. - The same authority key can claim any number of domains: publish the same public key in each domain’s TXT record and POST each claim. A multi-domain server authorizes every domain it serves under its one gateway key this way.
See Domains for what a domain account contains and the CLI flows the delegate holder can drive by hand.
The Postmaster
The postoffice is a singleton on-chain account that administers the whole SithBit deployment. Since the delegation cutover its admin surface is split in two:
- The standing delegate — a wallet address recorded on the postoffice. It holds the operational powers: authorizing and deactivating domains, tuning the protocol fees, publishing the root KSK fingerprint, and fee-free bulk alias reservation. This is the key an operator uses day to day, and the one services like domain-sithbit keep hot.
- The postmaster — the owner. It is not a pubkey on-chain: the postoffice stores only a 32-byte Merkle commitment root over a hidden set of keys derived in an offline key ceremony. Nobody can read the postmaster’s keys off the chain, because they are never written there. The ownership powers — sweeping the postoffice revenue, rotating the delegate, and handing ownership itself over — are exercised by revealing one ceremony key with a Merkle membership proof.
Every ownership operation is rotate-on-use: the revealed key spends the entire committed generation, and the same instruction installs the next generation’s root. A revealed key is never accepted twice.
Note: almost everything in this topic is an operator/administrator action, not something an everyday mailbox owner does. If you’re just sending and receiving mail, you can skip this page — it’s here because understanding who administers your domain is part of understanding the system.
For how to hold the ceremony seeds safely — and what survives losing one — see the postmaster custody runbook.
Checking status
Anyone can check the current fees and the postoffice without signing
anything — the fee getters are public, read-only commands under
postoffice (the delegate only sets fees; reading them is ungated):
sithbit postoffice fee stamp # per-stamp protocol fee
sithbit postoffice fee domain # domain-authorization fee
sithbit postoffice fee alias # alias claim / transfer fees + the premium short-name schedule
sithbit postoffice fee settlement # settlement basis-point rates (operator share, stamp-purchase rate)
sithbit postoffice fee attestation # one-time verified-sender attestation fee
sithbit postoffice fee pin-lease # one-time pinning-lease creation fee + minimum deposit
sithbit postoffice postmaster # postoffice address, delegate, commitment status
sithbit postoffice postmaster reports the postoffice address, the standing
delegate’s wallet, and whether an ownership commitment root is installed
(the root itself is opaque — it reveals nothing about the keys behind it).
It is read-only and ships in the default CLI build; the state-changing
subcommands below are not (see the note at the end of this page). The same
query is also reachable as sithbit postmaster get in an admin build (see
the note below), since it lives on the postmaster command tree too.
Initializing the postoffice
sithbit postmaster init \
--seed <seed0.json> --seed <seed1.json> [--seed …] \
[--keys-per-seed <K>] \
[--keypair <delegate keypair>] \
[--skip-preflight]
A one-time step run once per network deployment, right after the on-chain
programs are first deployed. The signing keypair (--keypair) pays,
becomes the standing delegate, and the ceremony seed files (at least
two, in a fixed order) derive the genesis commitment root that ships in the
instruction — no postmaster pubkey is ever recorded. --keys-per-seed is a
ceremony parameter: every later ownership operation must present the same
value, so record it alongside the seeds.
A fresh init also claims the global postmaster alias
for the delegate, atomically in the same transaction: instruction 1
creates the postoffice (recording the delegate), so instruction 2’s alias
claim fee is waived by the delegate fee waiver by construction — the
claim costs only the alias account’s rent. Mail addressed to postmaster
resolves to the operator from the deployment’s first block. The claim has
three outcomes:
- Unclaimed (the normal fresh-deployment case) — registered to the delegate in the init transaction.
- Already held by the delegate — an idempotent skip: init proceeds and notes the alias already belongs to it.
- Held by anyone else — a loud refusal naming the holder, and
nothing is submitted, not even the postoffice init: the deployment
must never come up without its
postmastername. Recover by acquiring the alias (a transfer from the holder) or having the holder close it (sithbit alias close), then re-run init.
A reinit attempt (the postoffice account already exists) sends the init
instruction alone, so the chain’s authoritative refusal surfaces instead
of an alias complaint. One standing limitation to know: the alias program
has no reserved words, so the atomic claim narrows the squat window
to the gap between program deploy and postmaster init — it cannot close
it. Run init promptly after deploying the programs.
The delegate’s operational powers
The delegate signs the operational admin instructions; a non-delegate
signer is refused with on-chain error 66 (NotDelegate):
- All domain operations —
domain create, the two-step deactivation (request, cancel, finalize),domain close, anddomain transfer. These land on the domain program, which checks the delegate against the postoffice cross-program — the postoffice itself stays a mail-program account. - Fee tuning —
postmaster fee stamp,fee domain,fee alias,fee alias-tiers(the four premium claim fees for 1–4 character names, set together in lamports for 1/2/3/4 characters),fee settlement(the two settlement basis-point rates set together: the operator share of settled value and the stamp-purchase fee rate — the bps arm of the hybrid per-stamp fee; a zero rate resets to its protocol default),fee attestation(the one-time verified-sender attestation fee; zero likewise resets to the default),fee reputation-floor(the floor of reputation-scaled first-contact pricing, in basis points of a mailbox’s default postage; zero likewise resets to the default), andfee pin-lease(the one-time pinning-lease creation fee; zero likewise resets to the default), each value bounded by a hardcoded on-chain cap (see Economics), so even a compromised delegate cannot price the protocol out of reach. - The root KSK fingerprint —
postmaster ksk set(and the read-onlypostmaster ksk get), the DNSSEC trust anchor for authorize-by-proof. - Fee-free bulk alias reservation — the per-alias claim fee is waived
when the delegate signs a multi-alias
alias create, premium 1–4 character names included: reserve short names for rent alone and resell them on the marketplace.
The delegate is deliberately a hot-capable key: it can run inside domain-sithbit for self-service domain onboarding, because nothing it signs can move postoffice funds or touch the ownership commitment.
Ownership operations
The three ownership instructions share one CLI shape: the ceremony seed
files re-derive everything — the signing chain key, its membership proof
against the current on-chain root, and the next generation’s root to
install. The CLI is stateless; the current generation is recovered by
scanning candidate roots against the chain, so there is no counter file to
keep. A stale or invalid proof is refused with on-chain error 67
(InvalidCommitmentProof).
--seed <seed0.json> --seed <seed1.json> [--seed …] # every seed, ceremony order
[--keys-per-seed <K>] # must match the value init used (default 2)
[--key-index <INDEX>] # which committed key signs (default 0)
[--generation <G>] # force a generation (tests; normally scanned)
Installing a successor commitment (ownership handover)
sithbit postmaster commitment \
--seed <seed0.json> --seed <seed1.json> \
[--keypair <fee payer>] [--skip-preflight]
This replaces the old postmaster transfer (it rides the same
instruction slot on-chain): instead of pointing the postoffice at a new
owner pubkey, it installs a successor commitment root. To hand the
deployment to a new owner, the new owner runs their own ceremony and the
current owner installs the resulting root — from then on only the new
owner’s seeds can prove ownership. The --keypair here only pays the
transaction fee; it needs no admin standing.
Withdrawing protocol fees
sithbit postmaster withdraw [destination] \
--seed <seed0.json> --seed <seed1.json> \
[--keypair <fee payer>] [--skip-preflight]
Sweeps the postoffice balance above its rent-exempt minimum — the
accumulated domain, alias, and stamp protocol fees — to destination
(defaults to the fee payer’s address). Ownership-gated: the delegate
collects fees into the postoffice but can never take them out.
Rotating the delegate
sithbit postmaster delegate <new_delegate> \
--seed <seed0.json> --seed <seed1.json> \
[--keypair <fee payer>] [--skip-preflight]
Repoints the operational powers at a new wallet — the recovery path for a lost or compromised delegate key, and the scheduled-rotation path for a healthy one. The all-zero address is refused (it is the on-chain “no delegate” sentinel).
Because every one of these three operations spends the revealed generation and installs the next one, running any of them advances the generation for all of them — the next ownership operation, whichever it is, proves against the new root. The CLI recovers the new generation from the chain automatically.
postmaster reclaim — wipe and start fresh
Sometimes you want to throw a whole deployment away and start over — most
often on devnet or a local validator, where the on-chain state is fixture
data you’re done with. reclaim is the tool for that: in one pass it drains
every rent-empty account all three programs own and hands the rent back.
Accounts still holding value are deliberately out of its reach — see
It refuses accounts that still hold value.
sithbit postmaster reclaim \
[--rent-recipient <address>] \
[--keypair <delegate keypair>] \
[--yes] \
[--skip-preflight]
It enumerates every program-owned account for all three program IDs (a
getProgramAccounts scan of the mail, alias, and domain programs), then
batch-closes them with each program’s own delegate-signed
AdminCloseAccount instruction (a program can only drain accounts it owns,
so each of the three carries its own reclaim twin) — mailboxes,
fromboxes, domains, aliases, published-key accounts, the
lot. The postoffice singleton is closed last, deliberately: every other
close authenticates against it, so it has to outlive them. Each closed
account’s rent is refunded to --rent-recipient, which defaults to the
signing keypair’s own address.
It refuses accounts that still hold value
AdminCloseAccount closes an account only when its balance is at or below
its rent-exempt minimum. A target holding anything above that is refused
on-chain with custom error 106 (AdminCloseEscrowPresent), in all three
programs.
That guard is what keeps a wipe tool from being a drain: a frombox holds a
sender’s prepaid postage, a message account holds escrowed postage and any
reply bounty, an alias-bid account holds a live auction bid. Without the
check, the standing delegate could sweep all of it into
--rent-recipient — and on a real deployment that is other people’s money,
not fixture data.
The practical consequence on devnet is that a reclaim pass over live fixture
state will skip funded accounts rather than reporting a clean sweep. To
retire those, settle or close them through their normal paths first
(mail delete to settle messages,
frombox close or
frombox reclaim
to empty fromboxes, alias bid --cancel to return bids), then run reclaim
to collect the rent-empty remainder.
It is the standing delegate that signs
AdminCloseAccount is gated on a standing-delegate signature checked
against the mail postoffice — the same delegate that runs the operational
commands above. Since the delegation cutover there is no
postmaster pubkey on-chain, so for the purposes of this instruction the
“postmaster” simply is the standing delegate. A signer that isn’t the
recorded delegate is refused on-chain with AdminCloseUnauthorized; the
--keypair you pass must therefore be the delegate key. Being the delegate
is necessary but not sufficient — the value guard above applies to the
delegate too.
Why the launch build doesn’t have this command
reclaim is a compile-time footgun, and it is fenced off as one. The
AdminCloseAccount instruction lets the standing delegate destroy any
account any of the three programs owns — anyone’s mailbox, any domain, the
postoffice itself — and pocket that account’s rent. That is enormous power
to leave sitting in a live deployment’s admin key.
So the command is gated behind a second Cargo feature, reclaim, layered
on top of postmaster (the code is compiled only under
all(postmaster, reclaim)), and each of the three on-chain programs carries
a reclaim feature of its own gating the AdminCloseAccount handler. The
guard has to live in the bytecode, not the CLI: the value guard above cannot
tell leftover state from a live account, because a live mailbox, alias, or
domain normally holds exactly its rent-exempt minimum — so any program build
carrying the handler lets the delegate destroy real accounts and take their
rent. The intended lifecycle is:
- Pre-launch, build the CLI and the on-chain programs reclaim-capable,
so you can reset devnet/testnet freely while you iterate. The programs’
devnetfeature impliesreclaim— a devnet build is reclaim-capable by construction, no extra flag needed. - At mainnet launch, cut builds with the
reclaimfeature off (the programs’ default). The command disappears from the CLI and — because the on-chainAdminCloseAccounthandler is feature-gated out of the shipped bytecode — the whole close-anything path leaves the programs entirely. A launch-build program refuses the instruction outright with custom error 108 (AdminCloseDisabled): it simply has no instruction that can delete a user’s account out from under them.
Because it is destructive, reclaim will not run silently:
- On an unknown or mainnet cluster it demands a typed confirmation
phrase — you must type
reclaim mainnetexactly. This guardrail is never bypassable by--yes; there is no unattended way to wipe mainnet. - On devnet or localnet it takes a light
y/Nprompt instead, and there--yesdoes skip it, so scripted test resets stay ergonomic.
Data accounts vs. the program account
Reclaim exists because closing the program and draining its data are two different jobs, and a fresh start needs both:
solana program close <id>+ redeploy resets the program’s bytecode and reclaims the program account’s own rent. It does not touch the data PDAs — the mailboxes, aliases, domains, and fromboxes the program owns. Those live at addresses derived from the program ID, so a redeploy at the same vanity ID re-reads all the old state as if nothing happened.reclaimdoes the other half: it drains those data PDAs (refunding their rent) but leaves the program bytecode in place.
A genuine clean slate therefore requires both: run reclaim to empty the
data accounts, then close and redeploy the programs. Either step alone
leaves the deployment half-reset.
It prints the program-close steps — it does not run them
After the data reclaim finishes, reclaim prints the remaining
steps — the solana program close <id> commands and the cargo build-sbf /
solana program deploy redeploy sequence for all three programs — and stops.
It never runs them. Closing a program account is irreversible and, at the
same vanity ID, hands the deployer a one-way choice; the CLI deliberately
leaves that final, unrecoverable action to a human who has read what it
printed and is ready to paste the commands.
Note: the entire
sithbit postmastercommand tree — including the read-onlyget— is gated behind the CLI’spostmasterfeature, which is not part of the default feature set. Build the CLI withcargo build -p mail-client --features postmaster(or--all-features) to get it. The default build still exposes the read-only query, assithbit postoffice postmaster— the same underlying lookup aspostmaster get.
Postmaster key custody
Since the delegation cutover the postmaster is not a key at all — it is a hidden set of keys committed by a 32-byte Merkle root on the postoffice account, produced in an offline key ceremony. This page is the operator runbook for that ceremony: what to generate, what to store where, how the generation stepping works, which of the two owner-run commands to reach for and when, and — honestly — what today’s tooling can and cannot recover when a seed is lost.
The other half of the admin surface, the standing delegate, is an
ordinary hot wallet with strictly operational powers. The owner rotates it
with postmaster delegate; the ceremony seeds behind the commitment root
are rotated wholesale with postmaster commitment. Those two commands run
on completely different clocks, and getting that cadence right is most of
the postmaster’s job — see When to run which
command. The command shapes themselves are
on The Postmaster.
CLI build note: the entire
sithbit postmastercommand tree used throughout this runbook (init,commitment,withdraw,delegate,set-*-fee,ksk get/set/iana, and the read-onlyget) is gated behind the CLI’spostmasterfeature, which is not in the default feature set. Build an admin CLI withcargo build -p mail-client --features postmaster(or--all-features). The default build exposes only the read-only query, assithbit postoffice postmaster.
The two owner-run commands at a glance
The owner never signs day-to-day admin — that is the delegate’s job. What
the owner does sign are the ownership operations, and in normal running
only two of them recur: delegate and commitment. They look similar on
the command line (both present the ceremony seeds and spend one committed
key), but they answer opposite questions and run on opposite schedules.
postmaster delegate | postmaster commitment | |
|---|---|---|
| Answers | “who operates the deployment day to day?” | “who owns the deployment?” |
| Changes on-chain | the delegate_address (the hot key) | the commitment_root (the whole hidden owner set) |
| Cadence | routine, on a schedule (quarterly is a fine default) + on delegate compromise | rare, event-driven — ownership handover, or retiring a seed from the set |
| Touches the seeds? | briefly, to sign — the seed set is unchanged | yes, it replaces the seed set with a fresh ceremony’s |
| Ceremony needed? | no — same seeds, next generation | yes — a new ceremony produces the successor root |
Both are ownership operations, so both share the rotate-on-use mechanics below: each spends one revealed generation and installs the next generation’s root in the same instruction. Running either one advances the generation the other will prove against next time — the CLI recovers the current generation from the chain, so you never track this by hand.
What the ceremony produces
The ceremony input is N ≥ 2 seed files — ordinary Solana keypair JSON
files whose 32-byte secret halves act as independent master secrets (the
public halves are ignored; any 32-byte secret in that container works).
From each seed a deterministic chain of signing keys is derived by
hashing; the derived keys’ public hashes from every seed are interleaved
into one Merkle tree; and the tree’s root is the commitment root the
postoffice stores at postmaster init.
The picture below is the whole custody scheme in one frame — where each seed and leaf sits under the single on-chain root, and how one signed op reveals a leaf and rotates the root:
Nothing about the member keys is published — the chain holds only the
root. An ownership operation (commitment, withdraw,
delegate) reveals one derived key: the key signs the
transaction and presents its Merkle membership path, which the program
verifies against the stored root.
Rotate-on-use, in generations. Every ownership operation spends the
whole revealed generation and installs the next generation’s root in the
same instruction. Generation g’s tree is derived from each seed’s key
window g·K … g·K+K−1 (K = --keys-per-seed), so the next generation is
a fresh, disjoint key set from the same seeds — no new ceremony needed.
A key that has appeared on-chain is never accepted again.
This is why routine operation never needs commitment: the seed set
replenishes itself, generation after generation, out of the same seeds.
You reach for commitment only when the seed set itself must change.
The CLI is stateless. There is no counter file: the current generation is recovered by re-deriving candidate trees from the seeds and matching their roots against the chain (bounded scan). Every ownership command takes the same arguments — the seed files in ceremony order, plus the ceremony parameters:
sithbit postmaster withdraw \
--seed vault-a/seed0.json --seed vault-b/seed1.json \
--keys-per-seed 2 --key-index 0 --keypair payer.json
What to store where
Two rules produce the whole custody scheme:
- Each seed lives in its own vault, held by a different person, organization, or physical location. The seeds are the ownership; anyone holding all of them owns the postoffice outright. Splitting them is what makes the postmaster resistant to any single theft, phish, or subpoena — an attacker with one vault has nothing usable on its own.
- The ceremony parameters are recorded with BOTH (all) seeds: the
seed count, the seed order, and
--keys-per-seed. These are not secret, but every ownership operation must present exactly the valuespostmaster initused — a ceremony rebuilt with the wrong parameters derives a different tree and its proofs are refused on-chain (error 67,InvalidCommitmentProof). Write them on paper in every vault.
The fee-paying --keypair on ownership commands needs no admin standing
— any funded wallet can pay; it is not custody material.
When to run which command
This is the section to internalize. The two owner commands are not interchangeable and they do not run together; keeping their cadences straight is what a healthy deployment looks like over its lifetime.
postmaster delegate — routine, scheduled hot-key hygiene
The standing delegate is a hot key by design (see Rotating the delegate for what it can and cannot do). Because it is hot, it is rotated often and on a calendar — quarterly is a reasonable default — plus immediately on any suspicion the delegate host was compromised or the person holding it left. Each rotation:
sithbit postmaster delegate <new_wallet> \
--seed vault-a/seed0.json --seed vault-b/seed1.json --keypair payer.json
repoints the postoffice’s delegate_address at a new wallet and, being an
ownership operation, steps the ceremony generation as a side effect. In
practice these scheduled delegate rotations are the main reason the
owner takes the seeds out of their vaults at all: a short signing session,
then the seeds go back. Nothing about the owner set changes — same seeds,
same custodians, next generation.
postmaster commitment — rare, event-driven ownership change
commitment installs a successor commitment root: a brand-new
ceremony’s root, replacing the entire hidden owner set. It changes who
owns the deployment, so it runs only when ownership genuinely moves, not
on any clock:
sithbit postmaster commitment \
--seed vault-a/seed0.json --seed vault-b/seed1.json \
--keypair payer.json
Run it when:
- Adopting the ceremony world. Migrating a deployment off the old single-key postmaster is a one-time install of the first commitment root — the old owner signs it, and from then on only the ceremony seeds prove ownership.
- Handing ownership over. The new owners run their own offline ceremony on their own seeds and hand you the resulting root; you install it. Afterwards only their seeds can sign, and your old seeds are out of the trust set forever.
- Retiring a seed from the set. If a vault is at risk — a custodian is leaving, a location is no longer trusted — you run a fresh ceremony on a new mix of seeds and install its root while you still hold all the current seeds. This is the proactive move the recovery story below hinges on: rotate a seed out before it is gone, not after.
The on-chain effect is narrow: commitment writes only the new
commitment_root and leaves delegate_address untouched — the operating
key keeps working straight across an ownership handover. (delegate, by
contrast, writes the new delegate_address and steps the root; the two
never overlap.)
How the two clocks relate
Many delegate rotations happen between any two commitment installs. A
delegate rotation is a small, routine act against an unchanged owner
set; a commitment install is a larger, deliberate event that replaces
that set and needs a fresh offline ceremony first. If you find yourself
reaching for commitment on a schedule, that is a smell — routine hygiene
is delegate’s job, and the generation stepping already refreshes the
committed keys under you for free.
Rotating the delegate
The standing delegate is the opposite of the ceremony seeds: a hot key by design, and safe to be one. Its powers are operational only — authorize/deactivate domains, tune capped fees, publish the root KSK, waive bulk-alias fees. It can never sweep postoffice funds, touch the commitment root, or change what the ownership keys are. The worst a stolen delegate does is operational damage (bounded further by the deactivation timelock), and a single ownership operation revokes it.
Recommended cadence:
- Rotate on a schedule — quarterly is a reasonable default; the cost
is one
sithbit postmaster delegate <new_wallet> --seed … --seed …(an ownership operation, so it also steps the ceremony generation). - Rotate immediately on any suspicion of host compromise, and on personnel changes that touched the delegate host.
- domain-sithbit needs no redeploy: it re-reads
its
delegate_key_filefrom the configured key source — disk or Azure Key Vault — on everyPOST /domain, so after rotating on-chain, swap the key file (or vault secret) in place and the next request signs with the new key. No restart, no signal, no downtime.
Keep the delegate keypair on the host that needs it and nowhere else; back
up nothing — a lost delegate key is a delegate away from
irrelevance, which is precisely the point of the split.
Losing a seed: the honest recovery story
Read this section before you rely on the N-seed split. The design goal is that losing one seed must not lose the postoffice, and the chain upholds it — but today’s CLI does not yet, and this runbook will not pretend otherwise.
What the chain requires from a recovery is exactly one valid ownership
operation: a signature from any single committed key of the current
generation plus its membership path. The successor root installed by that
operation is opaque to the program — so a survivor who can produce one
proof can install a brand-new ceremony’s root (fresh seeds, held by
new custodians) with postmaster commitment and the lost seed is out of
the trust set forever. One proof is full recovery.
Producing that one proof without the lost seed is the catch. The Merkle path runs through sibling hashes derived from every seed’s secret — the survivor can re-derive their own keys, but the lost seed’s contribution to the current generation’s tree must come from somewhere. It is public material (leaf hashes, not keys), and the ceremony library can rebuild the tree from one secret plus the other seeds’ published leaf hashes — but only if those hashes were exported while the seed was still available, and they are per-generation: the genesis leaf list is useless once one ownership operation has stepped the tree to generation 1.
Today’s limitations, plainly:
- The CLI cannot run a one-seed recovery. Every ownership command
requires every
--seedfile and re-derives all leaves from the secrets. There is no flag to substitute a lost seed’s public leaf hashes, and noexport-leavescommand to produce the artifact in the first place. The library layer (mail_client’sceremonymodule) supports the rebuild and it is covered by tests — but exercising it today means writing code against that library, not running a shipped command. - Consequence for operators now: treat the seed set as
all-or-nothing until the survivor tooling ships. Losing any seed means
ownership operations stop working, and the practical mitigation is to
run
commitmentto a fresh ceremony while you still hold the remaining seeds and the failing one — i.e. rotate out a seed at the first sign its vault is at risk, not after it is gone. - If you want to be ready for the survivor flow anyway: after
initand after every ownership operation, export the new current generation’s public leaf hashes and store a copy in every vault (they are not secret). Whoever runs an ownership operation holds all seeds at that moment, so the export costs nothing. When the survivor tooling lands, those artifacts — plus any one seed — are exactly what it will consume.
See also
- The Postmaster — the delegate/postmaster power split and every command’s shape.
- Trust assumptions and threat model — where the postmaster and delegate sit in the trust boundaries.
- domain-sithbit: domain verification — the self-service flow the hot delegate key serves.
Serving the browser clients
The webmail app, the name marketplace page, and the three extensions — Chrome, Outlook, Thunderbird — are end-user surfaces. This page is the operator’s half of them: what each one needs of account-api, which bundles you build and mount, and where the two server-hosted pages come from.
The reader-facing pages are under GUI clients, and the install instructions a user follows live there. Nothing on this page is something an end user does.
What account-api must have configured
All five clients talk to account-api and nothing else, so what a pane can do follows that one service’s config:
[store]— always. Login, mail reading, and the account settings panes (mail password, timezone, do-not-disturb) need only this.[chain]— amail-grpcgateway plus a Solana RPC endpoint (see the configuration reference). Without it the aliases, balances, and delegated encryption-key panes report their surface as unavailable, while login and the settings panes keep working. This applies identically to all four clients that carry those panes.[mail](local_domains,[mail.dkim]) — webmail’s compose path only; see The webmail app below.[tls]— Outlook only, which refuses to load a taskpane over plain http; see The Outlook add-in below.
Every bundle that account-api serves is served same-origin from one of its
[[static]] mounts, so the API base URL is simply the page’s own origin and no
CORS setup exists at all.
Those mounts are an array of tables — the doubled brackets are load-bearing.
The older single [static] table is refused at load rather than ignored, and
each route must be unique across the entries. See
the [[static]] list for
the full rules and the refusals they produce.
The webmail app
The webmail app is the one client an operator serves outright: there is no store listing and nothing for the user to install beyond the browser’s own PWA prompt.
Reading mail rides the /v1/mail surface, which needs only the shared
[store]. Compose (POST /v1/mail/send) routes recipients the way
sithbitd’s submission port does, so its reach follows the API’s config:
- No extra config — bare base58 wallet addresses deliver locally; anything else answers 503 ( alias resolution needs the chain).
[chain]— aliases anduser@your-domainaddresses resolve through the gateway, with the frombox postage precheck applied before the message is accepted.[mail](local_domains,[mail.dkim]) — mirrors sithbitd’s submission settings: which domains are yours (everything else is relayed) and the DKIM keys relayed mail is signed with.
Build the bundle and mount it:
cd webclients/webmail
./build.sh # wasm-pack build + stages shared/ into staging/
staging/ is the deployable bundle. Point account-api at it:
[[static]]
route = "/mail"
root = "webclients/webmail/staging"
then open http://127.0.0.1:8180/mail/index.html (or your deployment’s
origin).
The app does not require you at all, though: clearing the API URL in the connection settings switches it into trustless mode, wallet-only and server-free. If you offer a pin service to those users, read the pin lifecycle caveat first — client-made pins are outside every mail server’s pin lifecycle.
The name marketplace page
The marketplace panes ship inside the other clients, but the standalone page is served the same way webmail is:
cd webclients/marketplace
./build.sh # wasm-pack build + stages shared/ into staging/
staging/ is the deployable bundle, served same-origin from account-api
exactly like the webmail app — no CORS, the API base URL is the page’s own
origin:
[[static]]
route = "/marketplace"
root = "webclients/marketplace/staging"
Give this entry a route no other entry uses.
CSP note. The standalone page bridges the wire transaction to the format the external wallet signs with the vendored codec-only
@solana/kitbundle (shared/lib/kit-codec.esm.js) — served from the same origin as the page, so account-api’sscript-srcneeds no extra origins.
[chain] is required here rather than optional: all three routes the pane uses
answer 503 without it. The Sold tab is additionally only as complete and
current as your
alias/marketplace indexer
— disable the indexer and the tab is empty.
The Chrome extension
The Chrome extension is installed by the user, not served by you; what you provide is the account API it points at.
Its three-pane mail reader rides the account API’s /v1/mail surface, so it
appears whenever an account API is configured. The trustless on-chain inbox
talks to no server at all — it needs only a Solana RPC endpoint and an IPFS
gateway (both under the extension’s Connection settings), so it keeps
working with the mail server down, and it is the only reader shown when the
account API url is left blank.
The extension ships with host permissions for http://127.0.0.1 /
http://localhost by default and https://* as an optional grant, so a user
pointing it at your remote API is prompted for that origin.
The Outlook add-in
Office add-ins are https-hosted web pages, so you serve the built bundle
from one of account-api’s [[static]] mounts — the same origin as the API,
which is why the add-in needs no CORS setup. Office requires https: terminate
TLS with a reverse proxy, or use account-api’s [tls] section (see the
configuration reference, including the
dev-certificate recipe).
[[static]]
route = "/addin" # the default route, spelled out
root = "webclients/outlook/staging"
route defaults to /addin, so an entry naming only root serves the same
URL.
The bundle itself comes out of the same ./build.sh the user runs to get a
sideloadable package — see
Building and sideloading on
the client page for that command and the sideload paths.
The Thunderbird extension
The Thunderbird extension talks only to
account-api and there is nothing for you to host: MailExtensions need no
signing, so the .xpi a user builds or downloads installs permanently. Your
side is the [chain] section above, and — because the extension ships with host
permissions for http://localhost / http://127.0.0.1 only — an origin your
users will be prompted to grant when they point it at your remote API.
sithbitd: the mail daemon
Default port(s): SMTP 2525, IMAP 1430, POP 1100, health 8190. The
submission listener is disabled by default and has no distinct default
port (it would inherit SMTP’s 2525) — always set its bind_addr when
enabling it. The docker-compose files rebind everything to the 2xxx
convention (2525/2587/2143/2110) explicitly.
The combined mail daemon — SMTP MX and submission, IMAP, POP, and the spooler workers (chain pin/send, relay, DSN generation, reconciliation) all in one process. This is the production mail-handling binary; every deployment needs it.
When you need it: always — this is the core of a SithBit deployment.
Run exactly one sithbitd per SQLite store (IMAP IDLE push and per-wallet
SendMail ordering are in-process); a cloud store (aws/azure) lifts
that limit, letting you run one per fleet member — see
Scaling out.
Every role is a toggle: each listener section has its own enabled
switch, and [spooler] enabled = false skips all the background workers
— relay, DSN, chain pin/send + delete, auto-settle, reconciler, repin —
for a listeners-only instance. Accepted mail is still spooled; a
worker-enabled sibling over the same shared store drains the queues. The
DMARC RUA/RUF reporting workers keep their own section switches, and the
embedded IPFS swarm is unaffected. Presets and the role matrix are in
Role-split topologies.
Quickstart:
cargo run -p mail-spooler --bin sithbitd
Config file sithbitd.toml, or point SITHBITD_CONFIG at an alternate
path.
Note: an empty or missing config runs a loopback dev stack with the chain pipeline disabled — delivered mail stays in state
received. This is the expected zero-config shape, not a bug.
Running as an OS service
sithbitd service installs the daemon under the host’s service
manager — and every SithBit server binary supports the same
subcommand: sithbitd, mail-grpc,
domain-sithbit, account-api,
sithbit-ipfsd,
sithbit-gateway, and the three standalone
dev/pilot protocol servers
(pop-server, smtp-server, imap-server) each register under their
binary name and record their own config env var
(SITHBITD_CONFIG, MAIL_GRPC_CONFIG, POP_SERVER_CONFIG, …).
Everything below reads the same for the other eight; only the names
change. Both platforms record
the current directory as the service’s working directory (config,
.env files, and relative store paths resolve there) and, with
--config, an absolute config path:
cd /srv/sithbit # becomes the service's working directory
sithbitd service install --config sithbitd.toml
systemd (Linux). service install writes
/etc/systemd/system/sithbitd.service (--unit-path <path> overrides
the destination; --print renders the unit to stdout instead) and
prints the activation step — it never touches systemd state itself:
systemctl daemon-reload && systemctl enable --now sithbitd
The generated unit restarts on failure and orders after
network-online.target. Commented User= and
AmbientCapabilities=CAP_NET_BIND_SERVICE lines are included for
running unprivileged while still binding privileged ports — the
standard low mail ports for sithbitd, the low web ports (80/443) for
the binaries that plausibly face the internet (domain-sithbit,
account-api, sithbit-gateway), and each standalone protocol
server’s own ports only (110/995 for pop-server, 25/465/587 for
smtp-server, 143/993 for imap-server); the fleet-internal
mail-grpc and sithbit-ipfsd units carry only the commented User=
line.
sithbitd service uninstall removes the unit file (disable the
service first).
Windows. service install, from an elevated prompt, registers an
auto-start service named sithbitd with the service control manager;
start it with Start-Service sithbitd. The registration launches this
same binary with the internal service run verb, which re-anchors the
recorded working directory before loading config (SCM services
otherwise start in System32). sithbitd service uninstall stops the
service and deletes the registration.
At-rest sealing (automatic)
On a chain-enabled deployment (a [grpc] gateway configured), delivered
mail for password-less accounts is
sealed at rest automatically —
there is no switch. The per-account rule is the only gate: an account
with a stored mail password keeps a readable copy (its CRAM-MD5/APOP
logins could never unwrap one); a wallet-signature-only account gets its
body envelope-sealed at spool time, with the recipient-facing IPFS copy
and reply-linkage ids computed in the same pass (the stored body can
never be re-parsed, and re-sealing later would change the pinned CID —
the spool-time facts are canonical, so a
reading key rotated between
delivery and pinning takes effect from the next message). The chain-less
dev stack has no gateway to resolve reading keys and stays all-plaintext.
There is still no switch over sealing, but there is one over what
creates the exception. enable_stored_passwords = false — set on
account-api (which refuses to store new passwords) and on the SMTP and
POP listeners (which stop advertising CRAM-MD5) — makes the deployment
wallet-signature-only, and therefore sealed at rest for every account.
It does not invalidate passwords already stored: those accounts keep
logging in, and keep getting readable copies, until each one is cleared
with DELETE /v1/account/password. See
the configuration reference.
Two operational consequences:
- Accepting mail for a password-less recipient asks the gateway for
their published key at SMTP
DATAtime. A gateway outage tempfails the submission (451) — the same posture as the postage checks atRCPT— rather than silently downgrading anyone to plaintext. - The wallet-signature
AUTHon SMTP refuses a reading-secret suffix (base58(sig).base58(secret)is an IMAP/POP/webmail login shape): the submission path never decrypts, so a client shipping the secret there is leaking it, and the misconfiguration fails loudly instead.
Large-attachment IPFS offload (opt-in)
An attachment does not have to ride inside the message. With
[spooler.offload] armed, sithbitd pulls each over-threshold part out at
spool time, seals it under its own freshly generated key, pins the
ciphertext through the same [ipfs] provider the chain workers use, and
puts a small text/plain placeholder in its place — one carrying a
gateway link whose #fragment is that key. Every copy of the message that
follows — the stored one IMAP/POP serve, the sealed one pinned for the
recipient’s wallet, and any relayed one — carries the link, not the file.
The feature is off by default, and the arming switches are the two
size rules: with threshold_bytes and aggregate_bytes both at 0 —
the default — no message is rewritten at all and delivered bytes are
identical to a daemon built without the feature. Either one alone arms
it. Keys and defaults live in
[spooler.offload];
this section is the operational picture around them.
Before turning it on, read Offloaded attachments: the link is the credential. An offload link is a bearer credential and is deliberately weaker than the sealed-box path the message body takes. That trade is the whole decision here; the threshold is just a number.
When to turn it on
Turn it on when a handful of large attachments dominate what the deployment stores, pins, and relays: one 20 MB file inflates the stored blob, the pinned copy for every local recipient, and each relayed copy, where the offloaded form costs one pin total and a few hundred bytes per copy. Leave it off — or set the threshold high — when your users’ mail is mostly ordinary photos and PDFs, when the recipients are on clients you do not control and cannot expect to follow a link, or when the bearer-link posture above is not acceptable for the mail this server carries.
Two prerequisites are real, not advisory:
- The chain pipeline must be configured. The sink is built from the
[ipfs]provider, and that provider is only constructed alongside[grpc]— a pinning-less daemon could not produce a fetchable link at all. On a chain-less dev stack (or with[grpc]but no[ipfs]) a non-zerothreshold_bytesis simply inert: mail is delivered whole, with no error and no rewrite. The daemon says so at boot — a warning naming the threshold you set and stating that attachment offload is inert — because that combination is an operator’s setting doing nothing rather than a broken configuration. It is a warning, not a refusal: the boot proceeds, the sink is never armed, every over-threshold attachment delivers inline, and nothing is ever pinned. Read the startup log after arming the threshold rather than assuming the setting took. gateway_urlmust be the address recipients can reach. It is baked into the delivered message — so an internal or loopback value produces mail that only works inside your network, and correcting the setting later does not repair links already delivered. Treat the gateway’s public base URL as a long-lived commitment, the same way you treat an MX name.
Two size rules, and why the second exists
threshold_bytes asks “is this one file too big to store?” and is
compared per part and only per part. That leaves a real gap: twenty
1 MB attachments under a 5 MiB threshold ride inline as a 20 MB message,
each part innocent on its own. aggregate_bytes closes it by asking
“is this whole message too big?”, capping the decoded attachment bytes
one message may leave inline in total.
When a message is over its budget, the largest eligible parts are offloaded first, and the pass stops the moment what remains inline fits. That ordering is deliberate: it reaches the budget while turning the fewest attachments into links, so the recipient keeps as many inline files as the arithmetic allows. The two rules compose as a union — the threshold takes what it takes, and the budget tops the selection up from what is left.
Three properties are worth knowing before you set a budget:
- It counts attachments, not the message. The text and HTML bodies are never metered, because they can never be offloaded — a budget measured on something the feature cannot shrink would be unsatisfiable by construction. A body-heavy message can therefore still exceed the number you set.
- It is a target, not a guarantee. A part held inline by
content_idcounts toward the budget but can never be taken to satisfy it, so a message full of referenced inline images can sit over budget with nothing left to offload. The pass takes what it may and stops; refusing oversized mail ismax_message_size’s job, not this one’s. - It can arm the offload alone. Leaving
threshold_bytesat0and setting only a budget is a valid policy — no individual file is too big, but no message may carry more than N bytes of attachment.
The threshold is a decoded size, not a wire size
Both size rules compare decoded bytes — the size a mail client shows next to the attachment — and both fire on strictly greater. The wire is bigger: base64 costs a third plus line breaks, so a 5 MiB decoded part is roughly 6.8 MiB of message on the wire.
That gap is the trap, because most of the numbers an operator has in hand
are wire sizes: SMTP transaction logs, the SIZE a sending MTA
advertises, max_message_size,
and the stored blob. Reading those and setting threshold_bytes to match
sets the bar about a third too high, and the attachments you meant to
catch keep riding inline. Convert first: a 25 MiB wire ceiling admits only
about 18 MiB of decoded attachment, so a threshold above that can never
fire.
What is offloaded — and what never is
Candidates are attachments only, and three exclusions are deliberate:
- Body parts are never touched. The
text/plainandtext/htmlbodies are not attachments and are never candidates at any size. - Containers are never offloaded —
multipart/*and a nestedmessage/rfc822. A container’s byte range covers its children, so offloading one would silently take an entire sub-message with it. - A part carrying a
Content-IDis held inline by default — at any size, which is how inline images survive: an HTML body referencing the part ascid:…would otherwise be left pointing at nothing. On the default a 40 MB inline image stays inline and the threshold will not save you from it.content_idis the setting that changes this; see below.
Inline parts: the content_id setting
Most mail clients stamp a Content-ID on every part they build,
referenced or not. The default therefore holds back two quite different
things under one rule: genuine inline images, and ordinary oversized
attachments whose sending client merely labelled them. content_id
separates them.
| Value | An over-threshold part carrying a Content-ID |
|---|---|
"never" (default) | Always stays inline. Delivered bytes are what a daemon without this setting produces |
"orphaned" | Offloaded when no body in the message references it. Nothing points at it, so nothing can be left dangling |
"all" | Offloaded even when referenced, and each <img> that rendered it is rewritten into a link |
Under "all" the inline rendering is lost, and it cannot be otherwise.
The offloaded file is sealed and its key rides in the link’s #fragment,
which a browser never sends to a server — so pointing an <img src> at
the gateway would fetch ciphertext and render a broken image in every
client. The rewrite therefore replaces the whole element with a link
naming the file, its size and its type, the same three facts the text
placeholder carries. That is a real downgrade for the recipient, and it is
the honest one: the alternative is a broken image.
A reference this cannot rewrite holds its part inline. Only a whole
<img …> element has an unambiguous replacement. A part reached through a
CSS url(cid:…), a background= attribute, a plain-text body, or an
element whose tag cannot be identified is left exactly where it is, and
that message delivers as it does today. The same applies if any cid:
reference in the message resolves to no part at all — percent-encoded, or
simply pointing at something absent: the scan cannot then prove any part
is unreferenced, so under both "orphaned" and "all" every one of them
stays inline. Refusing is always safe; guessing at a rewrite is not.
The threshold is still per part. content_id decides which parts
are eligible, never how big one has to be. A message whose parts are
each under the threshold is untouched however many of them there are and
however large the total — that is a property of the offload as a whole,
not of this setting. Bounding an oversized message is what
max_message_size
and max_wallet_bytes
do, by refusing it rather than shrinking it.
Two more properties worth knowing: a message that does not parse is delivered untouched rather than failed, and when nothing exceeds the threshold the delivered bytes are bit-for-bit what they would have been with the feature off — only the parts actually offloaded are spliced, and every other byte, boundary, and encoding is copied through verbatim. The rewrite happens before DKIM signing, so the signature covers the message as delivered.
What the recipient sees
The placeholder is a plain text/plain part naming the file, its type and
size, and the link, plus the one caveat that matters (“anyone you share
the whole link with can read the attachment”) — readable in any client,
including one that knows nothing about SithBit. Alongside the prose it
carries machine-readable headers, for clients that would rather render the
original attachment than a paragraph:
| Header | Value |
|---|---|
X-SithBit-Offload | 1 — marks the part as a placeholder |
X-SithBit-Offload-Type | The original part’s type/subtype (application/octet-stream when it declared none) |
X-SithBit-Offload-Size | The original decoded size in bytes |
X-SithBit-Offload-Url | The gateway link, key fragment included |
X-SithBit-Offload-Cid | The bare cid, without the key |
X-SithBit-Offload-Name | The original filename, when the part named one |
X-SithBit-Offload-Url holds the whole link, key and all — so it travels
through every relay, filter, and archive the message passes, exactly like
the link in the body. It is not a second secret, but it is a second place
the credential is written down.
X-SithBit-Offload-Cid carries the cid without the key, and is not a
third copy of the credential. It exists because the URL names one
operator’s gateway_url forever, while a cid is permanent: a client that
understands the offload, or a tool re-pointing old mail at a new gateway,
needs the content address rather than a hostname that may be gone. This is
the same reasoning behind the client-side offload envelope carrying
cid + key rather than a URL.
When a pin fails
A seal or pin failure tempfails the whole submission (451); the
sending MTA retries and the attachment is still intact. There is
deliberately no fallback to delivering it inline, which would defeat the
threshold in precisely the case the threshold exists for.
When an offloaded pin is released
Settling or expunging a message unpins that message’s copy. Expunging also releases the offloaded attachments that message was carrying — but only the ones no other copy still references, and only when the submission stayed inside this server. A storage-quota refusal releases them too, by a different route described below. Three properties are worth having straight before you plan storage around the feature.
Release is refcounted, not per-copy. Offload runs once per
submission, ahead of the fan-out: one attachment is pinned once, and
every copy that carries its link holds a reference — each local
recipient’s copy, the sender’s Sent copy, and every further copy an IMAP
COPY makes (a drag between folders in a mail client). Expunging a copy
drops that copy’s reference; only the last reference standing hands
the pin to the unpin worker, and it does so exactly once. So a user
deleting their copy of a message no longer implies the attachment is
gone: it is gone for them, and stays fetchable for every other reader
whose copy still exists.
Relayed submissions are exempt, permanently. If a submission had any
remote recipient, its offloaded pins are flagged at delivery and no
local expunge ever releases them — not even the last local copy’s. The
attachment’s URL has left this store’s reach by then, so releasing the
pin would break a link on a server this one cannot see. There is
therefore no automatic cleanup for relayed offload pins: their
retention is the pinning provider’s policy or an operator sweep’s job,
and the handle for that sweep is the offload/ object-name prefix every
offloaded pin carries. That is a deliberate trade, not a gap waiting on a
worker.
Two operational notes follow from how release is plumbed:
- No new queue to provision. The release job rides the existing
chain_deletequeue — the same worker, the same provider client, and the same “already gone is fine” posture as the message-body teardown it arrives beside. Turning offload on needs no queue provisioning changes on any store backend, cloud ones included. - The pin is released by the coordinates the spooler recorded, not by
a derived name: the object name (
offload/<uuid>) and the cid the provider returned both travel with the job, because providers split on which one addresses a pin — Filebase unpins by name, Pinata by cid. An offload pin belongs to the submission’s attachment rather than to any one recipient’s mailbox, so there is no wallet and blob key to derive a name from the way there is for a message’s own pin.
A message that offloaded nothing costs one empty lookup when it is expunged and enqueues nothing at all, so a deployment that never arms the threshold cannot tell this machinery is there. See the threat-model section for what all of this means for confidentiality.
See the Configuration reference for the full key/default table.
A quota refusal releases unrefcounted, and regardless of relay. The
two properties above describe the expunge route, where a pin is handed
over only by the last reference standing and a relayed submission is
exempt permanently. A submission refused for
max_wallet_bytes
never got that far: it was delivered nowhere, so no copy and no pin row
exist, and there is no reference for a count to reach zero. Its pins are
released directly, whether or not the message had a remote recipient.
Nothing still linked can be caught by this — every offload seals its
attachment under a freshly minted key and pins it under a fresh name, so
two submissions carrying the same file never share a pin.
Without it a full mailbox would leak an attachment on every delivery attempt: a quota refusal is deterministic, so the sending server retries the same message for its whole retry window and each attempt would pin the same payload again.
An over-quota recipient refuses the whole envelope
The storage cap answers per transaction, not per recipient. A message
addressed to five local wallets, one of them full, is refused to all five
with 452 4.2.2 Mailbox full, and the sending server re-sends to all
five on its next attempt. The spool reports one outcome per submission
and has no per-recipient verdict to give, so a cap tight enough to trip
routinely will hold up mail for recipients who had room. Size it for the
account you mean to bound, not for the median message.
The refusal is transient on every path, so nothing bounces while the recipient prunes. The cap is also advisory rather than exact: usage is read and the write happens separately, so two deliveries to one wallet in flight together both see the pre-write total and both land. Overshoot is bounded by how many writes are concurrent times their size, not by the number you set — size a hard ceiling with that headroom in mind.
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
| Route | Auth | Purpose |
|---|---|---|
POST /v1/auth/nonce | none | issue a login challenge (mints no account row; rate-limited per requested pubkey) |
POST /v1/auth/token | none | verify the signed challenge → JWT (optionally carries the reading secret) |
POST /v1/auth/logout | JWT | end the session: drop its reading secret and the summaries decrypted under it |
POST /v1/auth/step-up | JWT | issue a step-up challenge for the token’s own wallet |
GET /v1/whoami | JWT | the caller’s wallet |
Account settings
| Route | Auth | Purpose |
|---|---|---|
GET/PATCH /v1/account | JWT | account settings (timezone, DND exposure opt-in, current auth epoch) |
PUT /v1/account/password | JWT + step-up | set the POP/IMAP/SMTP mail secret |
DELETE /v1/account/password | JWT + step-up | delete the stored mail secret |
POST /v1/account/auth-epoch | JWT + step-up | rotate the wallet-signature mail credentials |
GET /v1/account/pin-provider | JWT | which custom IPFS pin provider is configured (kind only, never the credentials) |
PUT/DELETE /v1/account/pin-provider | JWT + step-up | set or clear the custom IPFS pin provider — see Per-recipient pin providers |
GET/PUT /v1/account/dnd | JWT | own away-schedule exclusions (replace-set) |
GET /v1/dnd/{wallet} | none | public 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.
| Route | Auth | Purpose |
|---|---|---|
GET /v1/chain/balance | JWT | native SOL balance (lamports) |
GET /v1/chain/mailbox | JWT | on-chain mailbox (mail count) |
GET /v1/chain/key | JWT | published delegated encryption key |
GET /v1/chain/cert?email= | none | resolve an email/alias/wallet → recipient’s published key (HKP/WKD-style keyserver) |
GET /v1/chain/aliases | JWT | aliases held by the wallet |
GET /v1/chain/listings | JWT | every open marketplace listing (aliases and domains for sale) — global, so the wallet is authenticated but unused |
GET /v1/chain/sales?wallet=&kind=&name= | JWT | marketplace sale history, all filters optional — global, so the wallet is authenticated but unused |
GET /v1/chain/participants?tags=&limit= | JWT | global participant pool, narrowed to beacons carrying every listed tag bit |
GET /v1/chain/frombox?from= | JWT | stamp balance one sender holds with us |
GET /v1/chain/account/{address} | JWT | one raw account (owner + base64 data), the escape hatch API-mode web shells decode client-side |
GET /v1/chain/blockhash | JWT | recent blockhash for client-built transactions |
POST /v1/chain/submit | JWT | relay a client-signed transaction |
GET /v1/chain/tx/{signature} | JWT | commitment 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.
| Route | Auth | Purpose |
|---|---|---|
GET /v1/mail/mailboxes | JWT | folder tree with per-folder counts |
POST /v1/mail/mailboxes | JWT | create a folder (and missing parents) |
POST /v1/mail/mailboxes/rename | JWT | rename a folder subtree |
DELETE /v1/mail/mailboxes?mailbox= | JWT | delete a folder + chain teardown |
GET /v1/mail/messages?mailbox=&limit=&before_uid= | JWT | newest-first summary page (keyset cursor) |
GET /v1/mail/messages/{uid}?mailbox= | JWT | full JSON rendering (headers, text, html, attachments) |
GET /v1/mail/messages/{uid}/raw?mailbox= | JWT | the stored .eml bytes (message/rfc822) |
GET /v1/mail/messages/{uid}/parts/{part}?mailbox= | JWT | one decoded part as a download |
PATCH /v1/mail/messages/flags | JWT | merge flag adds/removes onto stored flags |
POST /v1/mail/messages/move | JWT | copy + expunge + chain teardown (IMAP MOVE semantics) |
POST /v1/mail/messages/delete | JWT | expunge + chain teardown |
GET /v1/mail/search?mailbox=&q=&body=&limit=&before_uid= | JWT | capped scan with a resume cursor |
POST /v1/mail/send | JWT | compose 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.
| Route | Purpose |
|---|---|
GET /v1/admin/accounts?after=&limit= | paged wallet enumeration |
GET /v1/admin/accounts/{wallet}/mailboxes | mailbox tree |
GET /v1/admin/accounts/{wallet}/messages?mailbox= | copies with chain states |
GET /v1/admin/queues | job-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/requeue | re-drive on the source queue (echo the listed token) |
POST /v1/admin/dead-jobs/discard | delete permanently (echo the listed token) |
GET /v1/admin/accounts/{wallet}/quota | rolling outbound usage, suspend flag, and the allowances in force |
PUT /v1/admin/accounts/{wallet}/suspend | set/clear the outbound-suspend flag |
GET /v1/admin/dmarc-reports | ingested 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/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: 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/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.GET /v1/accountalso returnsauth_epoch, au64: 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 — at0, an account that has never rotated, exactly as firmly as anywhere else. The older epoch-less signature0once 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.428when the request brings no freshly signed challenge,404when 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 a428, 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/passwordkeeps it, and it keeps logging in — clearing that one is the separateDELETE /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 itsexp. 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_authoff 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.401re-login,428re-sign a challenge,404only when the token’s wallet has no account row,500on 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 no409/412and no “nothing to delete” error. - A route of its own, not a mode of
PUT /v1/account/password. An emptyPUTbody 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-epochretires the wallet-signature credential,DELETE /v1/account/passwordthe 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_authoff 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:
PUTandDELETE /v1/account/passwordPUTandDELETE /v1/account/pin-providerPOST /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
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}.- Sign the
noncestring’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-textis that signer. - Resend the mutation with that signature in the
x-sithbit-step-upheader:
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.
| Status | What it means | What the client does |
|---|---|---|
401 | the session itself is over | log in again — a whole new wallet-challenge login |
428 | the session is fine, the proof is missing, stale or spent | fetch a challenge, sign it, resend with the token already held |
400 | the header is not a base58 ed25519 signature | fix the encoding and retry; the challenge is untouched, so an honest retry works |
429 | the wallet’s mutation budget is spent | honour 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:againstSithBit 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-Afteris always delta-seconds, and never0. 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 at1— a sub-second remainder truncates to zero, and aRetry-After: 0tells 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-mail403 {"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
401without 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:
| Refusal | Raised by | Retry-After |
|---|---|---|
429 too many account changes; retry in N seconds | the 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 seconds | POST /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 proof | no — the remedy is a signature, not a wait |
429 the outbound quota (see Compose and outbound quotas) | POST /v1/mail/send, external recipients only | no — 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 Largeaxum answers by default. On this API413means 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 answers422with 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_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.
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 body | This session | Listing entry |
|---|---|---|
| plaintext | any | sealed: false + the parsed summary |
| DEK-sealed | holds a reading key that unwraps it | sealed: false + the parsed summary |
| DEK-sealed | supplied no reading secret | sealed: true + an all-default summary |
| DEK-sealed | wrong key, no wrapped key for this reader, or a store error | sealed: 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: Bearerheader — 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
jtisession id into the JWT, so the second-resolutioniat/expno 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
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.
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.
mail-grpc: the chain gateway
Standard port(s): gRPC 50051, health 8193 (both in-code defaults,
loopback-bound).
The gRPC gateway other servers use to reach Solana — it implements the
SolanaMail service’s full RPC surface. MX/ mail servers call this
instead of talking to Solana directly. See Route index below
for the complete method list.
Route index
Every RPC the SolanaMail service answers (mail_api/protos/sithbit.proto),
grouped by area. The surface carries no auth of its own — see the network
posture note above.
Alias directory
| RPC | Request → Response | Purpose |
|---|---|---|
ResolveAlias | AliasRequest → AliasResponse | resolve an email local-part alias to a wallet address |
ListAliases | ListAliasesRequest → ListAliasesResponse | list every alias local-part pointing at a wallet, off the alias indexer |
Mailbox and postage
| RPC | Request → Response | Purpose |
|---|---|---|
GetMailbox | MailboxRequest → MailboxResponse | read a mailbox’s mail count, default postage, and no_ipfs flag |
GetMailboxKey | MailboxKeyRequest → MailboxKeyResponse | fetch a wallet’s published encryption key (X25519/RSA/none) plus no_ipfs |
GetFrombox | FromboxRequest → FromboxResponse | look up a (from, to) frombox: required postage, stamp count, stamp fee terms |
Sending and receiving mail
| RPC | Request → Response | Purpose |
|---|---|---|
SendMail | SendMailRequest → SendMailResponse | submit a mail send on-chain, with an optional reply-bounty escrow |
DeleteMail | DeleteMailRequest → DeleteMailResponse | delete a received message on-chain |
RefundMail | RefundMailRequest → RefundMailResponse | refund a received message back to its sender |
FindMessage | FindMessageRequest → FindMessageResponse | locate a landed message by IPFS CID — a dedupe check after a lost SendMail response |
Reply bounties
| RPC | Request → Response | Purpose |
|---|---|---|
ClaimBounty | ClaimBountyRequest → ClaimBountyResponse | claim an escrowed reply bounty using a reply message as evidence |
RefundBounty | RefundBountyRequest → RefundBountyResponse | reclaim an expired, unclaimed reply bounty |
Transactions
| RPC | Request → Response | Purpose |
|---|---|---|
GetTransactionStatus | TransactionStatusRequest → TransactionStatusResponse | poll the commitment status of a previously submitted transaction |
Domains
| RPC | Request → Response | Purpose |
|---|---|---|
GetMailDomain | MailDomainRequest → MailDomainResponse | check a MailDomain account: exists, active, authority, gateway_is_authority |
ListAuthoritativeDomains | ListAuthoritativeDomainsRequest → ListAuthoritativeDomainsResponse | list active domains whose on-chain authority is this gateway’s own signing key |
GetSenderAttestation | SenderAttestationRequest → SenderAttestationResponse | check whether a domain has attested a sender wallet as legitimate |
Marketplace
| RPC | Request → Response | Purpose |
|---|---|---|
BrowseListings | BrowseListingsRequest → BrowseListingsResponse | browse open marketplace listings (aliases and domains for sale) |
ListSales | ListSalesRequest → ListSalesResponse | read the marketplace sales ledger (by wallet, name+kind, or newest overall) |
ListParticipants | ListParticipantsRequest → ListParticipantsResponse | search on-chain participant opt-in beacons by tag bitmap filter |
Reputation and pinning
| RPC | Request → Response | Purpose |
|---|---|---|
GetSenderReputation | SenderReputationRequest → SenderReputationResponse | look up a wallet’s cumulative stamp-spend reputation and resulting price-rate bps |
GetPinLease | PinLeaseRequest → PinLeaseResponse | look up IPFS pinning-lease deposits/count on a CID |
When you need it: always, in any deployment where mail should actually
reach the chain — without it, sithbitd runs with its chain pipeline
disabled.
Network posture: the gRPC surface carries no TLS or authentication — anyone who can reach the port can sign with the gateway’s keypair. Bind it to loopback or a private interface only, never a public address. Why the gateway is a separate private service at all (and what would change that) is recorded in the topology design note.
Quickstart:
cargo run -p mail-grpc
Run it as an OS service with mail-grpc service install — the shared
subcommand is documented in
Running as an OS service.
Config file mail_grpc.toml, or point MAIL_GRPC_CONFIG at an alternate
path — the same layered TOML/env mechanism as every other SithBit binary
(in-code default → TOML → ./.env → ./.env.$APP_ENV → environment;
MAIL_GRPC_* variables override individual settings, e.g.
MAIL_GRPC_BIND_ADDR or MAIL_GRPC_ALIAS_INDEX__DATABASE). An empty
or missing config file is a runnable dev gateway: it binds
127.0.0.1:50051 — the private-network posture above is the default the
code enforces now, not a convention — and resolves the chain endpoint and
the signing keypair from the operator’s Solana CLI config
(~/.config/solana/cli/config.yml; a missing file means the stock CLI
defaults, i.e. mainnet-beta), exactly like the sithbit CLI, so
solana config set governs a zero-config gateway. As with the CLI, a
bare JSON_RPC_URL environment variable overrides the endpoint (it wins
over a configured json_rpc_url), which is handy for pointing a gateway
at a local validator without editing config. The annotated
mail_grpc/mail_grpc.example.toml documents every key with its default;
the Configuration reference has the
key/default table.
Migration (clean break):
mail-grpcno longer reads its legacy environment-only configuration.GRPC_SERVER_ADDRESS,DEFAULT_KEYPAIR(andDEFAULT_KEYPAIR_VAULT_URI/DEFAULT_KEYPAIR_SECRET_NAME),ALIAS_INDEX_DB,ALIAS_INDEX_POLL_SECONDS,ALIAS_CACHE_SECONDS, andHEALTH_BINDare all ignored. The one legacy name still honored isJSON_RPC_URL(bare, un-prefixed), for parity with thesithbitCLI and the standard Solana convention — it overrides the configuredjson_rpc_url. In particular, there is no keypair-content-in-an-environment-variable shape anymore:DEFAULT_KEYPAIRused to hold the raw JSON keypair array itself, while the TOMLkeypairnames a key source — a keypair file path, or a cloud secret-manager secret. The nearest env-var equivalent isMAIL_GRPC_KEYPAIR=/path/to/id.json.
Signing keypair: a file or a cloud secret manager
keypair is a
key source like
the workspace’s other file-loaded secrets. A bare string is a Solana
keypair file; the table form fetches the keypair from a cloud secret
manager instead — the secret’s value holds exactly what the file would,
the raw JSON keypair array. Authentication is each cloud’s ambient
chain (Azure managed identity, the AWS credential chain, Google ADC):
no new auth to configure, and no key material in the config file — only
the secret’s coordinates.
keypair = "signer.json"
keypair = { kind = "akv", vault_uri = "https://<vault>.vault.azure.net/",
secret_name = "signer" }
keypair = { kind = "asm", secret_id = "sithbit/signer" }
keypair = { kind = "gsm", project = "my-project", secret = "signer" }
Unset (the default), the keypair comes from the Solana CLI config’s
keypair_path — ~/.config/solana/id.json unless solana config set
moved it.
mail-grpc also runs the alias indexer that backs Aliases’s
ListAliases lookups ([alias_index]; an explicitly empty database
disables it). See the
Configuration reference for the full
key/default table.
domain-sithbit: domain verification
Standard port(s): bind 8181, health 8192.
DNS-based domain verification and on-chain domain authorization: it checks a
domain’s _solana.authority.<domain> TXT record, then — if configured with
the delegate key — submits the on-chain
CreateDomain authorization for you.
When you need it: only if you want to offer a self-service “prove you
own this domain” flow instead of having the delegate holder
run sithbit domain create by hand for every domain operator. One
deployment (one delegate key) serves claims for any number of domains,
and the same authority key may claim several — the service is stateless
per request.
Quickstart:
cargo run -p domain-sithbit
Config file domain_sithbit.toml, or point DOMAIN_SITHBIT_CONFIG at an
alternate path. Run it as an OS service with
domain-sithbit service install — the shared subcommand is documented in
Running as an OS service.
Note: without
delegate_key_fileconfigured,POST /domainreplies 503 — DNS verification lookups still work, but on-chain authorization is disabled until a delegate key is set.delegate_key_fileis a key source: a bare string is a local file path, and the table form fetches the keypair JSON from a cloud secret manager (kind = "akv","asm", or"gsm"). The key is re-loaded from the configured source on everyPOST /domain, so a delegate rotation takes effect by swapping the file (or cloud secret) in place — no restart. Whichever source is configured is validated at boot: an unreadable or malformed key fails startup, not the first request.
This service’s DNS flow is covered in depth in DNS setup; see the Configuration reference for the full key/default table.
Route index
Every route the service answers.
| Method | Path | Purpose |
|---|---|---|
GET | /domain/{domain} | DNS-only check: reads the domain’s _solana.authority.<domain> TXT record and reports it back (200 valid key, 406 invalid-format/missing key, 404 not found) — no delegate key needed |
POST | /domain | the same TXT lookup, then submits the on-chain CreateDomainAuthority (201 created, 409 already exists) — 503 without delegate_key_file configured |
GET | /.well-known/mta-sts.txt | publishes the MTA-STS policy — 404 without [mta_sts] configured |
GET | /.well-known/autoconfig/mail/config-v1.1.xml | Thunderbird Mozilla autoconfig document, see below |
GET | /mail/config-v1.1.xml | the same autoconfig document, served from the autoconfig.<domain> host |
POST | /autodiscover/autodiscover.xml | Outlook POX autodiscover document |
None of these routes carry authentication — POST /domain is gated only by
whether the service has a delegate key configured, not by a caller
credential. Static file mounts (wwwroot, the SPA fallback) aren’t listed
here; see Hosting the enrollment page for
the one that matters operationally.
Client autoconfiguration (no plugin)
Beyond domain verification, domain-sithbit doubles as the
client-autoconfiguration endpoint that lets an unmodified
Thunderbird
or Outlook fill in its own IMAP/ POP/SMTP settings when a user types a
<wallet-base58>@<domain> address into the native Add Account wizard.
No SithBit plugin is installed on the client — the wizard fetches a
settings document from this service and auto-fills the connection fields.
Three routes serve those documents from the [mail_hosts] coordinates
(next section):
| Route | Method | Client | Purpose |
|---|---|---|---|
/.well-known/autoconfig/mail/config-v1.1.xml | GET | Thunderbird | Mozilla autoconfig, served from the mail domain itself |
/mail/config-v1.1.xml | GET | Thunderbird | The same document on the autoconfig.<domain> host — the domain is read from the ?emailaddress= query, else the Host header (any autoconfig. prefix stripped) |
/autodiscover/autodiscover.xml | POST | Outlook | Plain-old-XML (POX) autodiscover; the account address is read from the posted body |
For both clients the advertised username is the wallet address (the
base58 public key): the Thunderbird document uses the %EMAILLOCALPART%
placeholder and the Outlook <LoginName> is the local part of the posted
address, so a <wallet>@<domain> address logs in as just the wallet —
the @domain is never leaked into the credential.
These routes fill in connection settings only — they do not create
the account. Native account auto-provisioning is deliberately not
offered: the mailbox must already exist on-chain (sithbit mailbox create), and the user still needs a mail credential (next paragraph).
The wizard only saves the operator round-trips of hand-entering hostnames,
ports, and TLS modes.
After the wizard auto-fills the settings, the user obtains a mail
password — the wallet-signature credential their client authenticates
with — from either the self-serve enrollment page or the CLI
(sithbit mailbox credentials). See
Connect a standard mail client
for the end-to-end user walk-through.
Hosting the enrollment page
The enrollment page (enroll.html, default URL /addin/enroll.html) is
served by account-api from a [[static]] mount, not by
this service. It walks a user through wallet-challenge login and setting
(or deriving) their mail password entirely client-side — and, for a user
who needs to retire a wallet-derived password already handed out, carries
the same two-step
rotation control
the web clients do. Because every
signature is produced in the browser by the mail_wasm WebAssembly
module, that module’s built wasm/ bundle must be deployed beside
enroll.html — point the root of the [[static]] entry serving
/addin at a directory containing both
enroll.html and the wasm/ directory, or drop the enroll.* files into
the existing web-client bundle root that already ships wasm/. Without the
bundle in place the page loads but cannot sign, so login fails.
The [mail_hosts] config
[mail_hosts] declares the public IMAP/POP/SMTP coordinates the
autoconfig/autodiscover documents advertise. Every field is dev-defaulted
to a loopback stack on the standard implicit-TLS mail ports, so an empty
config still serves a valid (loopback) settings document — point the hosts
at your real, publicly reachable mail servers before advertising them.
| Sub-table | host default | port default | socket_type default |
|---|---|---|---|
[mail_hosts.imap] | 127.0.0.1 | 993 | SSL |
[mail_hosts.pop] | 127.0.0.1 | 995 | SSL |
[mail_hosts.smtp] | 127.0.0.1 | 465 | SSL |
socket_type is one of SSL (implicit TLS from connect), STARTTLS
(opportunistic upgrade after connect), or plain (no encryption — dev
only). Those are the Thunderbird tokens; lowercase aliases (ssl,
starttls) are also accepted, and the Outlook POX flags are derived from
them automatically.
A present [mail_hosts.<server>] sub-table must spell out all three
fields — a partial table fails startup loudly rather than mixing your host
with a surprising default port. An omitted whole sub-server falls back
to its defaults above. Individual fields override via env, e.g.
DOMAIN_SITHBIT_MAIL_HOSTS__IMAP__HOST=imap.example.com.
The full key/default table lives in the Configuration reference.
Publishing the MTA-STS policy
With an [mta_sts] section configured, the service also publishes your
domain’s MTA-STS policy
(RFC 8461)
at GET /.well-known/mta-sts.txt — the document telling sending MTAs
which MX hosts may receive your mail and how strictly TLS failures must
be treated. Without the section the route replies 404: publication is
opt-in.
[mta_sts]
# mode = "testing" # move to "enforce" once your MX TLS is confirmed
mx = ["mx.example.com"] # required unless mode = "none"
# max_age = 604800 # seconds; the RFC caps it at one year
The section is validated at startup: an unknown mode, an
enforce/testing policy with no mx pattern, or a max_age above
the RFC’s one-year ceiling (31557600 — the value senders clamp to
anyway) fails boot rather than serving a broken policy. Senders fetch
the document as https://mta-sts.<domain>/.well-known/mta-sts.txt, so
point an mta-sts.<domain> A (or CNAME) record at this service behind
a TLS proxy holding a certificate for that hostname, and publish the
_mta-sts.<domain> discovery TXT record — bumping its id whenever
you edit the section. The DNS side is covered in
DNS setup; the full
key/default table lives in the
Configuration reference.
sithbit-ipfsd: the IPFS pin daemon
Standard port(s): bind 8182, health 8197.
The self-hosted IPFS node run as its own daemon: an HTTP pin API
(POST/DELETE /pins/{*name}, GET /ipfs/{cid}) over the same embedded
node sithbitd can otherwise run in-process.
When you need it: only when multiple instances (several sithbitds,
or sithbitd plus account-api) need to share a single IPFS node, via
[ipfs] kind = "remote" pointed at this daemon. A single-instance
deployment can embed IPFS directly inside sithbitd and skip this service
entirely.
Quickstart:
cargo run -p ipfs-daemon
Config file sithbit_ipfsd.toml, or point SITHBIT_IPFSD_CONFIG at an
alternate path. Run it as an OS service with
sithbit-ipfsd service install — the shared subcommand is documented in
Running as an OS service.
Note: an empty
[cluster]section (even with no keys set) enables shared-bucket membership heartbeats, GC, and reprovide-keyspace partitioning across multiple daemons — see Scaling out for the shared-bucket cluster model.
See the Configuration reference for the full key/default table.
Discovering a domain’s nodes (sithbit discover)
Once a node advertises itself on the swarm (a [swarm] section with
service-record freshness
tuned), clients can find a domain’s POP/ IMAP endpoints over the DHT instead of
DNS SRV records — this is decentralized service
discovery. The client side is a CLI
resolver:
sithbit discover imap sithbit.com \
--bootstrap /ip4/203.0.113.7/tcp/4001/p2p/12D3Koo... \
--timeout-secs 20
pop|imap— the protocol to discover, then the domain.--bootstrap/-b— a reachable peer multiaddr (/ip4/…/tcp/…/p2p/<PeerId>) that seeds the ephemeral node’s DHT routing table. Repeatable; without at least one reachable peer the lookup finds nothing. Point it at a node you already run for the domain (or any swarm peer).--timeout-secs— how long to keep retrying the DHT lookup before giving up (default 20).
It spins a throwaway discovery-only swarm node (it never pins), looks the
(domain, proto) service record up,
and prints only the endpoints whose authority-signed
delegation chains to the
domain’s on-chain MailDomain.authority. Verified multiaddrs go to stdout;
a count of records that passed DHT validation but failed the on-chain check goes
to stderr — a poisoned record pointing at an impostor is dropped, not
printed.
The resolver only speeds discovery; it grants no trust. A real client still verifies each node’s self-authenticating TLS certificate against the chain when it connects, so a wrong address could never fool the session. Discovery is only safe to fail over across when the nodes share a cloud store; on a single-node SQLite deployment there is nothing to discover but the one box.
sithbit-gateway: the IPFS HTTP gateway
Standard port(s): bind 8183, health 8198.
A read-only IPFS path gateway (GET/HEAD /ipfs/{cid}) serving
mail blobs straight out of the same block/pin bucket the node writes —
deserialized file bytes by default, trustless raw-block and CARv1
responses via ?format=raw|car (or the matching Accept types), with
the standard immutable-caching, conditional-request, and range
semantics of the
IPFS gateway specs.
It is deliberately local-content-only: a CID whose blocks are not
in the bucket answers 404 — the gateway never fetches foreign content
from the IPFS network. Point [blobs] at the shared S3 bucket (or
share the local ipfs/ directory) and it serves exactly what the
node/cluster pinned, nothing else.
When you need it: only when pinned mail blobs should be fetchable
over plain HTTP — a recipient’s client verifying an on-chain CID
without running IPFS, a load-balancer-friendly read path in front of
the bucket, or public retrievability without opening the swarm port.
Mail delivery itself never needs it; sithbitd and decentralized
clients read blobs through IPFS directly.
One deployment does make it load-bearing:
large-attachment offload
delivers links of the form <gateway_url>/ipfs/<cid>#<key>, which
recipients’ clients fetch over exactly this surface. Run it publicly, and
give sithbitd the address recipients can reach — the link is baked into
delivered mail and cannot be corrected afterwards. The gateway serves that
ciphertext without ever seeing the key: fragments stay on the client.
Quickstart:
cargo run -p ipfs-gateway
Config file ipfs_gateway.toml, or point IPFS_GATEWAY_CONFIG at an
alternate path. Run it as an OS service with
sithbit-gateway service install — the shared subcommand is documented in
Running as an OS service.
Subdomain (Host-based) gateway
Alongside the path gateway, sithbit-gateway can answer subdomain
requests of the form <base32-cidv1>.ipfs.<public_host>, where the CID
lives in the leftmost DNS label rather than the URL path. This is the
gateway form that gives each CID its own web origin, so browsers isolate
one blob’s scripts, cookies, and storage from another’s.
It is off by default. Turn it on by setting the base domain:
public_host = "example.com"
Left unset (the default), the gateway is path-only and the subdomain dispatch is a no-op on every request. With it set:
- A
Hostof<label>.ipfs.example.comserves the CID named by<label>, reusing the exact same format negotiation (?format=raw|car), range, and caching behavior as the path route. - Both entry surfaces canonicalize to lowercase
base32CIDv1, matching Kubo:- Label surface. A
Hostwhose label is a CIDv0 (base58) or otherwise non-canonical answers a 301 to the canonical host (//<base32-cidv1>.ipfs.example.com/…), preserving path and query. - Path→subdomain surface. A request to the bare
public_hostitself (Host: example.com) with a path of/ipfs/{cid}answers a 301 into the subdomain form://<base32-cidv1>.ipfs.example.com/, scheme-relative, query preserved. This is the redirect Kubo issues to move a path request onto its own origin, so a blob reached by path lands on one stable origin too.
- Label surface. A
- Only the bare subdomain root resolves. A sub-path under a subdomain host
(
.../some/file) returns 404 — mail blobs are single-file DAGs, so the gateway does no directory resolution.
This surface is conformance-tested against a live Kubo (v0.42.0) node, and the canonicalization on both surfaces now tracks it. Two deliberate, spec-legal differences remain:
- Sub-path 404 shape. A path of
/ipfs/{cid}/<sub>on the bare host is, on Kubo, a 301 into the subdomain followed by a 404 there; ours short-circuits to a direct 404 — same observable end state, one fewer hop (no directory resolution to attempt on a single-file DAG). Locationshape. Our redirects are scheme-relative (//host/…); Kubo emits absolute (https://host/…). Both are RFC 7231 §7.1.2-legal and resolve identically in browsers.
Honest scope: this surface is spec-conformance and future-proofing for a
public deployment. The SithBit mail client fetches blobs by path
(GET /ipfs/{cid}) and never needs subdomain origins; the subdomain
gateway only adds the browser origin-isolation semantics that a general
web-facing IPFS gateway is expected to provide, which this private,
read-only gateway does not itself consume. Leave public_host unset
unless you are exposing the gateway to third-party browsers.
See the Configuration reference for the full key/default table.
Per-recipient pin providers
By default, every inbound mail body a server delivers is pinned to IPFS by
the operator’s provider — whatever the [ipfs] section of the server
config selects (see
[grpc] and [ipfs] — the chain pipeline).
That single pin is what the on-chain message references, and its
availability rests on that one operator continuing to pin.
A mailbox owner who wants an additional copy under their own control can
register their own pinning provider with the server. Once configured,
the delivery pipeline pins that recipient’s inbound sealed bodies to their
provider in addition to the operator’s default pin — automatically, at
delivery time, with no per-message action. It is the standing, server-side
counterpart to sithbit mail pin, which re-pins
already-delivered mail by hand.
Who it’s for: recipients who want their mail’s availability to outlive
the operator’s pin — without running their own mail server. Bring a
Pinata account, a
Filebase bucket, or your own
sithbit-ipfsd daemon.
How it fits into delivery
The recipient pin is deliberately best-effort and additive:
- The operator pin runs first and stays authoritative — it yields the CID the on-chain message account records. The recipient pin is a second copy of the same sealed bytes; because IPFS is content-addressed, both providers serve the same CID.
- A recipient-provider failure — provider outage, revoked credentials, a full bucket — is logged and dropped. It never delays, fails, or alters delivery or chain state. Your copy of the mail arrives either way; only the extra pin is missed.
- The
no_ipfsopt-out wins over everything: an opted-out mailbox gets no pin at all — not the operator’s, not yours. The opt-out means “my mail never touches public IPFS”, and a recipient-configured provider doesn’t override that.
Configuring a provider
The surface is three routes on the
account API, under the same
JWT wallet auth as the rest
of /v1/account — so a wallet holder manages their own provider, and only
theirs. The examples below assume $JWT holds a token from
wallet-challenge login.
The two writes need a fresh wallet signature as well as the token.
PUT and DELETE /v1/account/pin-provider are two of the account API’s
five step-up gated
mutations — they hand a stored credential to the server, or take it away,
so a token minted hours ago is not enough on its own. Without a proof they
answer 428 {"error":"step_up_required"} and change nothing. The read is
not gated.
This one is API-level: there is no GUI control for it. The other credential changes on this surface have buttons on the webmail settings pane, which fetches the challenge and has the wallet sign it for you; a pin provider is registered from your own tooling — a shell, a script, a provisioning job — so running the three-step dance is yours to do:
# 1. A step-up challenge for this token's own wallet (no body, no
# wallet parameter — it always answers for the token's own).
# Keep the "nonce" field, verbatim, as $NONCE.
curl -X POST http://127.0.0.1:8180/v1/auth/step-up \
-H "Authorization: Bearer $JWT"
# → {"nonce":"SithBit step-up nonce: 6f0f…","expires_in":300}
# 2. Sign that exact nonce string's UTF-8 bytes with the wallet's ed25519
# key: `sithbit mailbox sign-text` is that signer. Only the base58
# signature reaches stdout — the "Signed as <address>:" line goes to
# stderr — so the capture below is exactly the header value.
PROOF=$(sithbit mailbox sign-text "$NONCE" --keypair ./wallet.json)
# Drop --keypair to sign with your Solana CLI config's wallet.
# 3. Send the write with the proof in the x-sithbit-step-up header
# (see the examples that follow).
Pass the nonce verbatim — nothing is trimmed, prefixed or hashed on
either side, so one extra trailing space is a different message and the
proof will not verify. Step 2 signs that string and nothing else: it
contacts no RPC endpoint and no server, so it runs offline, including on an
air-gapped machine holding the wallet. The full reference is
sithbit mailbox sign-text.
A signature collected at login is not interchangeable with this one: the
route checks the SithBit step-up nonce: prefix before it verifies
anything. And none of this needs the CLI in a browser — the web clients’
wasm wallet signs the identical bytes in the page, which is how the
settings pane’s own buttons answer their challenges.
The challenge lives five minutes and is spent by one write, whatever
that write answers — so a PUT followed by a DELETE is two challenges,
not one, and a retry after a failure needs a new one: fetch a fresh nonce
and re-run step 2 against it.
Set (or replace) a provider — PUT /v1/account/pin-provider with a
kind-tagged JSON body, one of three kinds:
# Pinata: an API JWT plus your dedicated gateway
curl -X PUT http://127.0.0.1:8180/v1/account/pin-provider \
-H "Authorization: Bearer $JWT" -H "x-sithbit-step-up: $PROOF" \
-H "Content-Type: application/json" \
-d '{"kind":"pinata","jwt":"<pinata-api-jwt>","gateway":"https://example.mypinata.cloud"}'
# Filebase: S3 credentials plus the pinning bucket
# (endpoint is optional, default "https://s3.filebase.com")
curl -X PUT http://127.0.0.1:8180/v1/account/pin-provider \
-H "Authorization: Bearer $JWT" -H "x-sithbit-step-up: $PROOF" \
-H "Content-Type: application/json" \
-d '{"kind":"filebase","access_key":"<key>","secret_key":"<secret>","bucket":"my-mail"}'
# Remote: a sithbit-ipfsd pin daemon you operate
# (endpoint defaults to "http://127.0.0.1:8182"; auth_token is optional,
# matching the daemon's auth_token setting)
curl -X PUT http://127.0.0.1:8180/v1/account/pin-provider \
-H "Authorization: Bearer $JWT" -H "x-sithbit-step-up: $PROOF" \
-H "Content-Type: application/json" \
-d '{"kind":"remote","endpoint":"https://ipfsd.example.net:8182","auth_token":"<token>"}'
Success is 204 No Content. A missing, stale or already-spent step-up
proof is a 428 (fetch another challenge and sign it again — the token
itself is fine); a proof that is not a base58 ed25519 signature is a 400,
and that one leaves the challenge intact, so fixing the encoding and
resending works. A missing or empty required credential field
is a 400 naming the field (e.g. filebase.secret_key must not be empty);
an unknown kind is a 422; a wallet with no account on this server is a
404. For the remote kind, point the endpoint at a
sithbit-ipfsd daemon — and since its pin API is a
write surface, expose it beyond loopback only with its auth_token set;
the daemon refuses a non-loopback bind without one at startup.
Check what’s configured — GET /v1/account/pin-provider:
curl -H "Authorization: Bearer $JWT" http://127.0.0.1:8180/v1/account/pin-provider
# → {"configured":true,"kind":"filebase"} (or {"configured":false,"kind":null})
The read surface is deliberately write-only for secrets: it reports
presence and the kind tag, never the credentials — not even redacted ones.
To rotate credentials, simply PUT the replacement (with a proof of its
own — see above). This route needs no step-up: it reveals nothing a token
holder cannot already learn, and gating a read would cost a wallet
signature per poll for no gain.
Remove it — DELETE /v1/account/pin-provider, gated like the PUT:
curl -X DELETE http://127.0.0.1:8180/v1/account/pin-provider \
-H "Authorization: Bearer $JWT" -H "x-sithbit-step-up: $PROOF"
# → 204; future deliveries pin to the server default only
Deleting is idempotent — clearing an already-unconfigured provider still
answers 204. Idempotent on the route, though, not on the proof: the
second call needs a second challenge, because the first spent the one it
carried.
What the server stores, and who can read it
The credentials are sealed (AES-256-GCM) under the operator’s server-wide
credential key and kept in the accounts store — the same mechanism as
stored mail passwords (see
credential_key_file).
Only the kind tag is stored in the clear, so the GET route (and the
operator) can answer which provider without opening the secret.
Be clear-eyed about the trust statement this implies: the operator’s
server can read these credentials — it has to, to pin to your provider
at delivery time. That is the design, and it is the opposite of
Lockbox-style end-to-end secrecy: sealing here
protects the credentials at rest (a stolen database dump without the
credential key reveals nothing), not from the operator. Use a scoped,
revocable credential — a Pinata JWT restricted to pinning, a Filebase key
for a dedicated bucket, an ipfsd auth_token you can rotate — never a
root account key.
Nothing about this setting is on-chain. Contrast the no_ipfs opt-out,
which is an on-chain mailbox flag because foreign senders must see it
before they pin; your pin provider only matters to the one server that
delivers your mail, so it stays operator-local.
Tradeoffs
- Your copy is only as good as your provider. The recipient pin
depends on your provider’s availability and your credentials staying
valid — an expired Pinata JWT or a deleted bucket silently costs you the
extra copies (watch the server’s logs, or spot-check with
GET /ipfs/<cid>against your own gateway). The operator pin remains the authoritative one either way. - No backfill. Configuring a provider affects mail delivered from
then on. To capture your existing history, run
sithbit mail pinonce against the same provider — it takes the identical Pinata/Filebase/remote credential shapes. - The operator holds your provider credentials (sealed at rest, but readable by the running server — see above). Scope and rotate them accordingly.
- You manage your provider’s lifecycle. The server only ever adds pins to your provider: settlement and deletion release the operator’s pin, never yours. A deleted message’s extra copy stays pinned on your provider until you remove it there yourself.
- Opt-out excludes you. A mailbox with the
no_ipfsopt-out never gets any pin, including this one — the two settings answer opposite wishes, and the opt-out wins.
Monitoring and backups
What to back up
The store — the store volume in the compose files, or wherever
[store] points — is the only state that matters, and its pieces have
very different values:
| File | Loss means | Back up? |
|---|---|---|
credential.key | every sealed mail credential is orphaned — users must set new mail passwords | yes, first |
jwt.key (account-api) | every login session invalidated; auto-regenerates, users just log in again | yes |
sithbit.db (+ -wal, -shm) | accounts, mailboxes, message metadata, queued jobs | yes |
blobs/ (local blob store) | message bodies not yet pinned to IPFS | yes |
DKIM key, TLS keys, delegate keypair, mail-grpc’s signing keypair | re-issuable with DNS/CA churn — the delegate too: a lost delegate key is replaced by an ownership-signed postmaster delegate, so back it up for convenience, not survival. The unlosable secrets are the offline ceremony seeds, which never live on a server | yes |
alias_index.db (mail-grpc) | nothing — it re-syncs from chain history on an empty file | no |
Two SQLite copy rules: a live database is only complete with its
-wal and -shm sidecar files, and the distroless images have no
shell — so from a container, docker cp all three files out (a copy
missing the WAL reads as empty or stale). For a consistent snapshot
prefer stopping the service first, or run sqlite3 sithbit.db ".backup ..." from the host against a mounted volume.
Cloud stores (kind = "aws" / "azure", or "postgres" on a managed
instance like Cloud SQL) move this problem to the
provider: durability comes from DynamoDB/S3/Azure Storage/the managed
database (with GCS behind the s3 blob kind on Google Cloud), and only
the key files above still need your own backups.
Logs
Every binary — sithbitd, account-api, domain-sithbit, and
mail-grpc — logs structured tracing lines to stdout — docker logs <service> in the compose stacks. The default level is info; filter
with RUST_LOG using target=level directives:
RUST_LOG=info,mail_spooler=debug,sqlx=warn
The same RUST_LOG filter also shapes what the OTLP export (below)
sends — it sits in front of both the console and the exporter.
Telemetry export (OTLP)
Every binary can push traces and metrics to an OpenTelemetry collector
over OTLP/gRPC. Export is off by default and enabled per service by
the presence of the [observability.otlp] config section (defaults
shown commented in every example config):
[observability.otlp]
# endpoint = "http://127.0.0.1:4317"
# metrics_interval_seconds = 60
mail-grpc is env-configured like the rest of its settings:
OTLP_ENDPOINT (absent or empty = no export) and
OTLP_METRICS_INTERVAL_SECONDS.
The design is push only — no Prometheus scrape endpoint. Prometheus
users run an OTel collector with a Prometheus exporter and point the
services at it. For a working dev example, the
docker-compose.otel.yml overlay boots a collector with the debug
exporter and turns every service’s export on:
docker compose -f docker-compose.yml -f docker-compose.otel.yml up -d
docker compose logs -f otel-collector # spans + metrics print here
Two gotchas:
- The standard
OTEL_EXPORTER_OTLP_ENDPOINT/OTEL_EXPORTER_OTLP_TRACES_ENDPOINT/…_METRICS_ENDPOINTenv vars override the configured endpoint inside the exporter — leave them unset for the config file to be authoritative. - Traces ride the
RUST_LOGfilter: a target silenced for logging is also not exported.
The metrics (all under the sithbit. prefix, labeled as noted):
| Metric | Kind | Labels | Meaning |
|---|---|---|---|
sithbit.sessions | counter | port | accepted SMTP/ IMAP/ POP sessions |
sithbit.sessions.active | up/down | port | sessions currently served |
sithbit.auth.rate_limiter.tracked_pairs | gauge | — | (client address, account) pairs the cross-connection login budget currently tracks; registered once per limiter, and exactly one live limiter reports per process |
sithbit.auth.rate_limiter.refusals | counter | protocol | cross-connection auth refusals (pop3 / imap / smtp), counted where the driver refuses unchecked; both SMTP roles share the one smtp series — MX and submission refusals are byte-identical on the wire |
sithbit.rcpt.refusals | counter | reason | RCPT TO refusals (unknown_mailbox, relay_denied, temporary_failure, custom_<code>) |
sithbit.api.refusals | counter | status, route | account-api refusals — every 4xx/5xx the process answers, sampled once per request: handler errors, extractor rejections (malformed JSON), and axum’s own 404/405 alike. status is the integer status code; route is the matched route template, never the request path, so a wallet or message id in a path segment cannot explode the series — anything that matched no route, including a [[static]] mount’s 404, counts as the single <unmatched> series. No method label — see the two account-mutation refusals |
sithbit.api.compose_quota.refusals | counter | — | compose requests (POST /v1/mail/send) turned away by the outbound quota. The — in the Labels column means what it means elsewhere in this table: no attributes at all, here deliberately, so the series alerts bare with no filter to get right. The same refusal also increments sithbit.api.refusals{status=429, route="/v1/mail/send"} — never sum the two |
sithbit.jobs | counter | queue, outcome | job dispositions (done / retry / bury) |
sithbit.chain.sendmail | counter | outcome | SendMail submissions: sent (landed), deduped (crash-window recovery found it already on-chain), fatal (the chain rejected it — buried without further attempts), retry (transient). Every increment is a call the gateway’s fee payer paid for or refused, so this is the volume signal behind the gateway’s wallet spend |
sithbit.queue.depth | gauge | queue | backlog incl. delayed + claimed jobs |
sithbit.queue.oldest_age_seconds | gauge | queue | age of the oldest queued job (SQLite store only; cloud queues don’t expose it — use CloudWatch/Azure metrics there) |
sithbit.chain.stuck | gauge | — | non-terminal chain copies past the sweep horizon, per reconciler pass |
sithbit.repin.outcomes | counter | kind | repin-and-verify migration outcomes (migrated / already / mismatch / source_missing / skipped); mismatch stabilizing means the migration is done — see the [ipfs.repin] reference |
sithbit.alias_index.staleness_seconds | gauge | — | seconds since the alias index last synced (mail-grpc) |
sithbit.api.cache.hits | counter | cache | account-api summary-cache lookups answered from the cache. cache is summary (the shared plaintext cache, bounded by [cache] summary_capacity) or session_summary (the per-session sealed cache, bounded by its ceiling). Per process, like every account-api cache series: each replica tallies its own lookups, so a fleet dashboard that sums N replicas is reading N private caches under the default [cache] kind = "local" — size from one replica’s series, per Tuning the account-API caches — or, under kind = "redis", N views of the one shared summary cache, whose sum is then the fleet’s hit rate against it (the shared backend). Cumulative since process start (an observable reading of the cache’s own tally, sampled each export), so rate it over a window rather than dividing lifetime totals |
sithbit.api.cache.misses | counter | cache | lookups the cache could not answer; hits / (hits + misses) over a window is the hit rate, which is derived, never exported. Same label and per-process caveat as hits |
sithbit.api.cache.evictions | counter | cache | entries removed to make room — never expiry, never logout. For session_summary this counts quota victims and every entry a whole-session drop at the ceiling took, so it moves whenever session_drops does; never sum it with session_drops. With [cache] kind = "redis" it reads 0 for cache="summary": a shared server does not report its evictions to one client — read them from Redis itself (INFO stats, evicted_keys) |
sithbit.api.cache.entries | gauge | cache | entries resident now. 0 for cache="summary" under kind = "redis" (the backend does not know it; DBSIZE on the server does) |
sithbit.api.cache.capacity | gauge | cache | the configured bound entries is measured against: summary_capacity for summary (0 under kind = "redis", where the bound is redis_ttl_secs and the server’s own maxmemory, neither of which is an entry count); for session_summary the ceiling, max_cached_sessions × session_summary_capacity — the per-session quota is not reported, since no single series is measured against it |
sithbit.api.cache.session_drops | counter | — | whole sessions the sealed-summary cache dropped at its max_cached_sessions ceiling (each re-decrypts on its next read). No attributes, deliberately, so ceiling pressure alerts apart from the per-session quota pressure evictions{cache="session_summary"} also carries |
sithbit.api.session_secrets.entries | gauge | — | sessions holding a reading secret now |
sithbit.api.session_secrets.capacity | gauge | — | the configured max_session_secrets bound |
sithbit.api.session_secrets.evictions | counter | — | sessions evicted from a full reading-secret stash. Each one logged a user out (they see the sealed refusal and log in again), which is why the stash gets a series of its own: any sustained rate here is the signal to raise max_session_secrets |
Tuning the account-API caches
The account API’s four bounded in-memory caches are sized by the
[cache] section of account_api.toml — see
[cache] — the summary-cache sizes
for what each key bounds, the startup validations, and the farm
starting values. The defaults fit a small self-contained deployment;
the ten sithbit.api.cache.* / sithbit.api.session_secrets.* series
above exist so a bigger deployment can decide its own values from its
own traffic instead of taking ours. Read them one cache at a time:
filter on cache="summary" to size summary_capacity, and on
cache="session_summary" (with session_drops beside it) to size the
sealed cache’s two bounds.
Hit rate is derived, not exported. hit rate = hits / (hits + misses), over a window. Both counters are cumulative since process
start, so take each one’s increase over the window you are judging (a
PromQL increase(), or the collector’s delta temporality) rather than
dividing lifetime totals, which never forget the cold start.
Four states, one action each:
| What the series show | What it means | Do |
|---|---|---|
entries stays below capacity; evictions ≈ 0; misses only after a start | never fills — the working set fits with room to spare, and the misses are cold-start | leave it, or lower the key to reclaim memory |
entries at capacity; evictions climbing; hit rate low | thrashing — the capacity sits below the working set and evicts entries that will be asked for again | raise the key; step up and re-measure (estimate below) |
entries at capacity; evictions climbing; hit rate high | the hot set is captured, the tail is not | a modest raise buys the tail, with diminishing returns — not urgent |
entries at capacity; evictions ≈ 0 | steady state — it fits exactly | leave it |
Which key “the key” is. For cache="summary" the action changes
summary_capacity — under the default kind = "local". Under
kind = "redis" the four-state table does not apply to
cache="summary": entries, evictions and capacity all read 0
(the shared server does not report them to one client), hits and
misses are still tallied per process, and the bound to move is
redis_ttl_secs or the server’s maxmemory, judged from Redis’s own
INFO rather than from these series (the shared
backend). For cache="session_summary", capacity is the
ceiling and session_drops tells the two bounds apart: session_drops
climbing means the ceiling is dropping whole sessions — raise
max_cached_sessions; evictions climbing while session_drops stays
flat means individual sessions are hitting their own quota — raise
session_summary_capacity (rare: the default already holds a full
keyed-search window with headroom). For the reading-secret stash,
sithbit.api.session_secrets.evictions above zero means the bound is
logging users out — raise max_session_secrets.
A sizing estimate, from the measured grid. Under poor locality —
uniform random access across the working set, the pessimistic case the
grid on summary_cache.rs’s DEFAULT_CAPACITY was measured under —
the hit rate tracks capacity over working set: hit rate ≈ capacity /
working set. So a hit rate H observed at capacity C implies a
working set of roughly C / H, and C / H is an upper bound on
the capacity that reaches ~100%: a 45% hit rate at the default
summary_capacity = 4096 says ~9 100; 15% at the same capacity says
~27 000. Real mail access skews hard toward recent messages and
beats that bound, so step up (the next power of two, say), let the
counters run, and re-measure — rather than jumping straight to it.
Sitting just below the working set is the worst place to be: a poor
hit rate and an eviction on nearly every insert. Once the working set
fits, evictions stop altogether. So raising summary_capacity past the
working set makes the cache cheaper in CPU, not dearer; the memory
is what you pay. Watch evictions fall to zero after a raise — if it
does not, the raise was not enough.
Each eviction itself is cheap, and its cost does not grow with the size you choose. Eviction takes its victim from a stamp-ordered index, so it examines one entry whether the capacity is 4 096 or 65 536. This was not always so: eviction used to rank every resident entry — O(cap) — while holding the one lock every request to that cache contends for, which made a large capacity punishing precisely when it was evicting. Sizing this value is now a memory question and a hit-rate question, and not a lock-contention one.
Memory. Budget ~0.5–1 KB per cached summary — an estimate, not a
measurement: a parsed summary holds the rendered addresses, subject,
message-id, references and a 120-character snippet, plus its key. So
capacity × 1 KB is a safe ceiling: summary_capacity = 16384 is ~16 MB,
65536 is ~64 MB, and the sealed cache’s ceiling
max_cached_sessions × session_summary_capacity converts the same way.
Per replica, never the fleet. Every series here is per process, and
each replica holds its own caches — nothing is shared through the
[store]. A dashboard summing N replicas’ entries is showing N
private caches, not one shared one, and a hit rate derived from summed
counters is a fleet average that no single replica sees. Derive the hit
rate and pick the size from one replica’s series, then set that
same value on every replica; the trap is spelled out beside the farm
starting values in
the configuration reference.
The job queues
All background work rides durable job queues: chain (encrypt → IPFS
pin → on-chain SendMail), relay (outbound SMTP), chain_delete
(expunge teardown), dsn (DSN status notifications), and — only while an
[ipfs.repin] migration is configured — repin. Jobs are
at-least-once with a visibility timeout; failures retry with backoff,
and a job that keeps failing is buried to a dead-letter queue with
a reason. The two numbers worth watching are depth (backlog) and
age of the oldest job (a stuck consumer).
SQLite — the jobs and dead_jobs tables:
-- depth and oldest job per queue (timestamps are unix seconds)
SELECT queue, COUNT(*), MIN(created_at) FROM jobs GROUP BY queue;
-- poison jobs, with why they died
SELECT queue, reason, died_at, payload FROM dead_jobs;
AWS — SQS queues named <queue_prefix>-chain, -relay,
-chain-delete, -dsn, and -dead; watch the standard
ApproximateNumberOfMessagesVisible / ApproximateAgeOfOldestMessage
CloudWatch metrics.
Azure — Storage queues under the same <queue_prefix>-* names,
with -dead for buried jobs; watch approximate message counts.
A buried job’s payload is self-describing JSON (a type field plus
the job’s parameters). The interactive way to inspect and re-drive
dead jobs is the sithbit-console TUI (its Queues tab lists depths
and dead letters with confirmed requeue/discard keys — see the
console tutorial); underneath it is an account-api
admin call (a wallet on its admin_wallets allowlist):
GET /v1/admin/dead-jobs lists
buried jobs with their queue, reason, and payload, and
POST /v1/admin/dead-jobs/requeue (echo a listed entry back) fixes it
onto its source queue with attempts reset —
POST /v1/admin/dead-jobs/discard deletes it instead. Queue depths are
GET /v1/admin/queues. On the cloud backends a listing claims each
returned entry for five minutes (the id is a claim token, like a
worker’s receipt handle), so requeue/discard within that window; a
lapsed entry simply lists again later. Without the admin API, the
manual fallback still works: fix the cause and re-insert the payload
(SQLite: copy the row back into jobs with attempts = 0,
visible_at = now; SQS/Azure: send the body to the source queue).
Buried jobs do not pile up forever: the worker role runs an hourly
prune that discards dead-letter entries buried longer ago than
[spooler] dead_retention_days (default 30; 0 disables the prune
entirely — see Configuration).
Every backend stamps the bury time into the dead message itself, so
entries age correctly across restarts; a residual entry with no
readable date (e.g. one buried by an older build) counts as older than
any cutoff and is pruned, not spared. Requeue or discard a poison job
you care about within the retention window.
The console also carries a balances pane (press b on a wallet):
its native SOL balance, its mailbox’s default stamp price and received
mail count, and the prepaid stamps each other loaded wallet holds toward
it. Unlike every other pane, this one reads chain state directly — the
mailbox/frombox figures come gRPC-direct from
mail-grpc’s GetMailbox/GetFrombox, and the
SOL balance from a Solana JSON-RPC getBalance — not through the
account API. It is a read-only spot check; its two endpoints
(gateway_endpoint, rpc_url) are configured alongside the console’s
api_url (see Configuration).
Outbound quotas and suspension
Every authenticated sender carries rolling hour/day counters of the
external (relayed foreign-domain) recipients it has been accepted
for — local, on-chain-stamped mail never counts — enforced against the
age-ramped allowances configured in
[smtp.quota] / [submission.quota]
and the account API’s twin [quota]. Alongside them rides a per-account
suspend flag, honored by every server whether or not quotas are
enabled.
Both are administered over the account API (a wallet on its
admin_wallets allowlist, like every /v1/admin route):
GET /v1/admin/accounts/{wallet}/quota— the wallet’s rolling usage and the allowances actually in force at its current age:{last_hour, last_day, suspended, account_age_weeks, quota_enabled, hourly_allowance, daily_allowance}. 404s for a wallet with no account row (i.e. one that never logged in — counters alone don’t create an account).PUT /v1/admin/accounts/{wallet}/suspendwith body{"suspended": true}(orfalseto lift it) — 204 on success, 404 for a missing account.
What a refused sender sees on the wire, per surface — the reference for debugging “why can’t this account send/collect, or change its credentials”:
| Surface | Condition | Refusal |
|---|---|---|
| SMTP submission, external RCPT | over quota | 452 4.5.3 (transient — retry after the window rolls; local RCPTs in the same transaction are unaffected) |
| SMTP AUTH | suspended | 535 5.7.13 Account disabled (RFC 3463 “user account disabled”) |
| SMTP MAIL | suspension landed mid-session (after AUTH) | 550 5.7.1 Account suspended |
| IMAP login | suspended | NO [CONTACTADMIN] account disabled; contact your administrator (RFC 5530) — the same refusal answers post-login commands if suspension lands mid-session |
| POP login | suspended | -ERR [SYS/PERM] account disabled; contact your administrator (RFC 3206 permanent-failure code) |
Compose (POST /v1/mail/send) | suspended | HTTP 403 |
| Compose | over-quota external recipients | HTTP 429 |
| Account mutations (password, pin-provider, auth-epoch) | over the per-wallet [rate_limit] budget | HTTP 429 + Retry-After (delta-seconds left on the window, floored at 1; the compose 429 above deliberately carries no such header) |
| The same five mutations, step-up gated | no fresh wallet signature on the request — none presented, expired, already spent, or wrong | HTTP 428 + {"error":"step_up_required"}, and deliberately no Retry-After: the remedy is a signature, not a wait. Still charged against the budget in the row above (why) |
What an authenticated SMTP session pins, and for how long. The
mid-session row above is the visible edge of a deliberate split. An
authenticated submission session resolves the sender’s identity exactly
once, at AUTH — the wallet its counters and suspend flag are keyed on, which
for an alias login is whichever wallet that alias pointed at in that moment
— and every later decision in the session asks about that wallet. Nothing
else is carried forward: both verdicts are asked afresh, suspension at AUTH
and again at every MAIL, rolling usage at every external RCPT, so a
suspension or an exhausted allowance that lands mid-session bites on the
next message rather than at the next login. The one operator-visible
surprise is the flip side of that pin: re-point an alias at a different
wallet while a session is open and the open session does not follow it.
Its mail keeps being charged to the wallet it authenticated as, and it is
that wallet’s suspend flag — not the new wallet’s — that stops it, so
suspending the new wallet leaves the session sending while suspending the
old one refuses it at the next MAIL. The window closes when the session
authenticates again; since a second AUTH on an already-authenticated
session is refused (503, RFC 4954 §4), that means the client’s next
connection in practice, so expect a re-pointed alias to move a live sender
one connection late rather than immediately. Compose has no such window at
all — POST /v1/mail/send reads the suspend flag and the counters on every
request.
The account-disabled replies are deliberately distinguishable from a bad-credentials refusal, and deliberately safe to disclose: every one of them is issued only after the presented credentials verified, so only the account holder ever learns the account is suspended — a stranger probing passwords still sees the ordinary bad-credentials refusal.
Quota refusals surface in telemetry as
sithbit.rcpt.refusals{reason="custom_452"} — a growing count means
senders are hitting their allowances, which is the control working, not
an outage.
The compose 429 has a series of its own. That custom_452 line
covers the SMTP surface only; a compose refused by the same quota is
counted by sithbit.api.compose_quota.refusals (above), which meters
exactly one thing — a POST /v1/mail/send turned away by the quota math
— and carries no labels, so it is alertable as it stands, with no filter
to write and get right. The blanket sithbit.api.refusals counter sees
that same refusal as {status=429, route="/v1/mail/send"}, and that
is where a panel here goes wrong: one refused compose increments both
instruments, so never add the two together — a summed “compose
refusals” line reads double. Keep them apart rather than picking one,
because they answer different questions. The blanket series on that
route is not quota-only — any other 429 that route answers lands in it
too, so a rise there is not by itself a quota event — while the
dedicated series is quota-only by construction. That asymmetry is the
whole reason it exists. It also reads differently when enforcement is off: with [quota] enabled = false the quota math never runs, so the dedicated series sits
flat at zero — that flat line means “enforcement off”, not “nobody is
over quota” — while the blanket counter keeps metering whatever else
refuses on that route.
Both account-mutation refusals are metered.
sithbit.api.refusals (above) samples once per refused request, so 428s
and 429s ride the same OTLP export as everything else — no reverse-proxy
or ingress access log is needed to count them. The account API still
logs nothing per request (only internal errors reach the log), so the
counter, not the log, is where these live. Two things to know before
building the panel. The whole gated surface is three route values
— /v1/account/password, /v1/account/auth-epoch, and
/v1/account/pin-provider — because the label is the route template and
carries no HTTP method: the five gated method/path pairs collapse
onto three series per status, and a refused PUT /v1/account/password
is indistinguishable from a refused DELETE of it. And the counter
covers the API’s entire surface, so filter on those three routes unless
you want unrouted 404s and malformed-JSON 400s in the same line.
Alert on the two statuses separately, because they mean opposite
things. A 429 spike is the budget working — one wallet being
hammered, or a client retry loop; the Retry-After it carries is the
whole remedy and no operator action follows. A 428 spike means
clients cannot sign: a disconnected wallet extension, a client build
that never fetches a challenge, or challenges expiring before they are
spent — the step-up nonce lives 300 seconds, stamped on the issuing
replica’s clock and judged on the consuming one, so skew between API
replicas eats into that window.
The two interleave for one wallet, and that is a shape worth recognizing rather than a second fault: the budget is charged in front of the gate, so a bare (proof-less) attempt spends a slot on its way to its 428, and a client that retries it blindly burns the wallet’s whole window and finishes on a 429. That ordering is visible in the metric, and it is the one way a dashboard misleads: once the window is spent the limiter refuses in front of the handler holding the gate, so the 429 series climbs while the 428 series goes quiet. Under sustained abuse of a gated route the two replace each other rather than rising together — sum both statuses over the three routes for an honest “sensitive-mutation refusals” signal, and keep the split for diagnosis. Isolated 428s are not worth a page — a client that tries the mutation first and steps up on the cue produces one per successful change, which is the flow working. What deserves the alert is 428s for a wallet that are never followed by a success.
Complaint handling is deliberately manual for now: on an abuse report, suspend the wallet via the admin API above and lift the flag once resolved — suspension stops SMTP, IMAP, POP, and compose in one switch. Automated ARF (abuse-report) ingestion is deliberately deferred.
Chain states
Every delivered message copy tracks its progress to the chain in
messages.chain_state:
| State | Meaning | Terminal? |
|---|---|---|
local | local-only copy (e.g. a sent-folder copy); the pipeline ignores it | yes |
received | delivered, waiting for the chain worker — the resting state when the chain pipeline is disabled (dev stacks) | no |
pinned | body encrypted and pinned to IPFS; SendMail pending | no |
sent | on chain | yes |
no_key | the recipient cannot receive encrypted mail (e.g. off-curve address); the local copy stays readable, a warning is logged, no bounce | yes |
chain_failed | gave up permanently; the reason is in the logs and usually a buried chain job | yes |
SELECT chain_state, COUNT(*) FROM messages GROUP BY chain_state;
A copy sitting in a non-terminal state (received, pinned) for more
than 15 minutes is picked up by the reconciler, which sweeps every
5 minutes and re-enqueues a chain job for it — duplicates are
harmless by design. A growing received count on a
chain-enabled deployment therefore means the pipeline itself is
unhealthy: check the chain queue depth, the dead-letter queue, and
connectivity to mail-grpc and IPFS.
Liveness
Every binary serves two HTTP health endpoints on a loopback health
listener (the [health] section in every binary’s TOML config;
enabled = false disables). Default ports, one per binary so a dev
host can run them all:
| Binary | Health listener |
|---|---|
sithbitd | 127.0.0.1:8190 |
account-api | 127.0.0.1:8191 |
domain-sithbit | 127.0.0.1:8192 |
mail-grpc | 127.0.0.1:8193 |
pop-server | 127.0.0.1:8194 |
smtp-server | 127.0.0.1:8195 |
imap-server | 127.0.0.1:8196 |
sithbit-ipfsd | 127.0.0.1:8197 |
sithbit-gateway | 127.0.0.1:8198 |
GET /healthz— 200 while the process serves (liveness).GET /readyz— 200 once startup finished (listeners bound); 503 lists what’s still waiting (readiness).
Because the runtime images are distroless (no shell, no curl), every
binary also takes a --health-probe flag: it loads the same
config, GETs its own /readyz, and exits 0/1. The compose files use
exactly that as their healthcheck: (see docker-compose.yml), and
docker/smoke.sh waits for the services to report healthy. One
caveat: mail-grpc runs host networking in the chain profile, so
its probe port 8193 lives on the host — move it with
[health] bind_addr (or MAIL_GRPC_HEALTH__BIND_ADDR) if something
else holds it.
Also useful:
- SMTP/IMAP/POP answer with a protocol banner on connect —
docker/smoke.shscripts exactly this for the dev stack. mail-grpc’sListAliasesRPC answersUNAVAILABLE(“still backfilling”) until the alias index has completed its first sync — a finer-grained readiness signal for the index than/readyz, which only tracks the gRPC listener (thesithbit.alias_index.staleness_secondsmetric covers ongoing sync health).mail-grpc’s/readyzalso carries afee_payerflag: it goes not-ready when the signing wallet’s balance falls belowfee_payer_floor_lamports, which is also when the write RPCs start answeringUNAVAILABLE. Alert on it — a gateway that cannot pay stops the whole fleet’s chain writes, and the fix (fund the wallet) is entirely operational. The flag is ready-by-definition when the floor is disabled.
sithbit-migrate: moving a store between backends
sithbit-migrate copies an existing SithBit store
into another one — the tool you run to graduate a single-box SQLite
deployment onto a cloud backend (DynamoDB + SQS + S3, Azure Tables +
Queues + Blob, Postgres — including Cloud SQL, with GCS as the S3
bucket — Turso, or Cloudflare)
without losing a single
account, message, or queued job.
It is a one-shot, offline copy, not a live replicator: point it at a
quiescent source (stop sithbitd and account-api first), let it
run to completion, then bring the servers back up against the new store.
What it moves
In one pass, in this order:
- Accounts — every wallet row: timezone, the sealed mail-password secret (copied verbatim — see the caveats), the do-not-disturb exclusion set and its exposure opt-in, the outbound suspend flag, and the rolling outbound-usage totals (as observed at the migration instant — see the caveats).
- Mailboxes and messages — the full mailbox tree per wallet and
every message copy, each carrying its exact chain-pipeline
state (
received/pinned/sent/settled/no_key/chain_failed/ local) plus the cid and on-chain message id it had reached. - Blob bytes — every stored message body, byte-for-byte, including any orphaned blob referenced by no message.
- The job queue — outstanding live jobs are drained onto the target, and dead-lettered jobs are re-enqueued there as fresh live work.
Configuration: two stores in one file
Unlike every other binary — which carries a single [store] section —
the migrator reads one store and writes another, so it holds two
independent store sections under [source] and [target]. Both inherit
the same dev-friendly defaults, so an empty file migrates the local
SQLite store onto itself (a harmless no-op).
Every key is documented on the configuration reference:
sithbit-migrate settings.
That page also covers the layering (in-code defaults →
sithbit_migrate.toml or the file named by SITHBIT_MIGRATE_CONFIG →
.env → .env.$APP_ENV → real environment) and the
SITHBIT_MIGRATE_TARGET__AWS__TABLE-style environment overrides. The
annotated mail_migrate/sithbit_migrate.example.toml is the canonical
per-key copy.
A SQLite-to-AWS sithbit_migrate.toml:
# The store you are leaving (typically the local SQLite one).
[source]
kind = "sqlite"
database = "sithbit.db"
credential_key_file = "credential.key"
[source.blobs]
kind = "local"
path = "blobs"
# The store you are moving to.
[target]
kind = "aws"
# The same credential key MUST come across (see caveats).
credential_key_file = "credential.key"
[target.aws]
region = "us-east-1"
table = "sithbit"
queue_prefix = "sithbit"
[target.blobs]
kind = "s3"
endpoint = "https://s3.us-east-1.amazonaws.com"
bucket = "sithbit-mail"
access_key = "…"
secret_key = "…"
The target’s tables, queues, and lease store are created idempotently at
open time, exactly as they are when a server first boots against them.
The one exception is an S3 blob bucket: like the servers, the
migrator assumes an S3 bucket already exists (kind = "azure" blob
containers are auto-created; S3 buckets — including GCS buckets used
through the interop endpoint — are not). Create the bucket
before you run.
Dry-run, then commit
The migration only happens with --commit. Without it, the tool does a
dry run: it opens both stores, reads and enumerates the entire
source (proving every field, mailbox, blob, and job is reachable), and
writes nothing to the target. Use it to validate connectivity and
credentials, and to see the counts before you commit:
# 1. Rehearse. Opens both stores, touches nothing on the target.
sithbit-migrate
# 2. For real. Copies everything.
sithbit-migrate --commit
Both modes print a one-line summary of what was seen (dry-run) or moved (commit):
source=Sqlite target=Aws mode=commit, accounts: 1 (0 suspended), mailboxes: 2, \
messages: 3, blobs: 4 (119 bytes), jobs: 2 live, 1 dead re-enqueued (0 skipped)
The account/mail/blob copies are idempotent — a re-run overwrites rather than duplicates — so a migration interrupted partway can simply be re-run. The job-queue drain is the one exception (it consumes the source); see the caveats.
v1 caveats — read before you commit
This is a faithful data copy, not a perfect clone. The known, deliberate lossy points for the first version:
- The credential key is not re-sealed. The sealed mail secret is
copied as ciphertext, still encrypted under the source’s
credential.key. That key file must be carried to the new deployment and configured on the target — the migrator does not re-seal under a new key. It does, however, refuse to run (dry run and--commitalike, before any migration step) when the[source]and[target]credential_key_filesettings resolve to different keys, so a mismatch is an error at the console rather than every stored mail password orphaned for users to discover at login. The target store is opened before that check fires, so a target key path that does not exist yet is generated fresh, refused as a mismatch, and left behind as a stray key file — copy the source key over it and re-run. Back the key up first, migrate it alongside the store. - Timestamps are not preserved.
created_at/updated_atmetadata is reset to the migration time on the target; messageinternaldateand chain state are preserved, account/row bookkeeping timestamps are not. - Outbound-usage bucket timing is not preserved. The suspend flag comes across verbatim, and the rolling hour/day outbound-recipient totals are replayed as observed at the migration instant — but the underlying per-hour buckets are not visible through the store surface, so on the target the totals age relative to the migration time rather than the original send times. Practical effect: a sender’s remaining allowance is right at cutover, and the copied usage rolls off within the following hour/day windows.
- UID identity is reallocated. Each mailbox is rebuilt fresh, so its
uidvalidityanduidnextare assigned anew on the target. Message UIDs are re-issued in ascending order (preserving relative order), but a client’s cached UIDs from the old store are invalidated — expect clients to resync, exactly as they would after auidvaliditybump. - Dead-lettered jobs come back as live. Each dead job’s payload is re-enqueued as a fresh live job on the target; its dead status, failure reason, and attempt history are not carried over (full dead-letter fidelity was declined for v1). A dead payload that no longer deserializes is counted “skipped” and left on the source, not moved.
- The live-job drain is at-least-once, and assumes a quiescent source. Live jobs are consumed from the source and enqueued on the target; a crash mid-move re-drives a job rather than dropping it, so a job caught in flight can land on the target twice (the workers tolerate a duplicate). This only holds if no workers are running against the source during the migration — stop the source’s servers first.
- Wallets with mail but no account row are skipped. Enumeration is driven by the account table, so a stray mailbox/message belonging to a wallet that has no account row is not seen. In a healthy store every mailbox has an owning account, so this affects only orphaned rows.
- Login nonces are not migrated. They are single-use, short-TTL login challenges with no value after a cutover; in-flight logins simply retry.
Runbook
- Back up the source, especially
credential.key. - Stop
sithbitdandaccount-apiagainst the source store. - Provision the target’s prerequisites (S3 bucket if using one; credentials/region for the backend).
- Write
sithbit_migrate.tomlwith[source]and[target], carrying the credential key across. sithbit-migrate(dry run) — confirm the counts and that both stores open.sithbit-migrate --commit.- Repoint
sithbitd/account-api[store]at the new backend and start them. - Verify (log in, list mail, watch the chain workers drain), then retire the old store.
Scaling out
A single sithbitd process on SQLite is the zero-config
default and the
right shape for one operator on one host (see
Running a mail server for getting that far). To run multiple instances of
the mail services (SMTP/IMAP/POP listeners, account-api, spooler workers)
behind a load balancer or an orchestrator, every box below must be ticked —
each one is an invariant the single-process default provides for free.
The instances need not be identical, either: the same section toggles
split a fleet by role — see Role-split
topologies below.
The checklist
-
A shared store.
[store] kind = "postgres","aws", or"azure"(postgresis also the Google Cloud shape, against Cloud SQL). SQLite is one process per store, full stop: its write pool is a single connection over a local file, and its blob/watch defaults are process-local.kind = "turso"follows the same one-process rule: it is a local libSQL file, optionally an embedded replica that syncs to a remote Turso/libSQL primary. The replica keeps a synced-to-cloud copy for durability and local-speed reads, but reads still hit the local file (which lags the primary by up tosync_interval_secs), so it does not give the cross-instance lease/queue consistencypostgres/aws/azuredo — treat turso like SQLite for scaling, not as a shared store.kind = "cloudflare"(D1 + Queues + Workers KV + R2) is a true shared store: Cloudflare Queues give cross-instance queue coordination, keyed leases are strict single-statement SQL on D1’sleasestable, and IMAP-uid allocation (uidnext) is a server-side atomicUPDATE … RETURNING— D1 executes each statement atomically on its per-database SQLite writer, so N daemons delivering into the same mailbox over one D1 database allocate distinct uids. The historical one-writer delivery caveat (uid allocation was once serialized only by an in-process mutex) was removed 2026-07-19; D1’s lack of a multi-statement transaction no longer constrains scaling, because nothing counter-critical spans statements anymore. -
A shared blob store.
[store.blobs]must point at S3 (which covers GCS via its S3-interop endpoint) or Azure Blob. The local-directory backend only works multi-instance on a shared volume, which is discouraged. -
The same key files everywhere.
credential.key(the mail-password seal), the account API’s JWT key, and any DKIM signing key must be identical on every replica — distribute them as secrets, and back them up: losingcredential.keyorphans every stored password. -
IDLE polling on IMAP instances.
[imap] watch_poll_seconds = N(e.g. 2–5). Delivery push is in-process; an instance that didn’t do the delivering only learns of new mail by polling. Instances that combine the spooler and IMAP still push their own deliveries instantly. An instance running IMAP with both SMTP roles off adopts 5 s automatically when the setting is left at its 0 default — see Role-split topologies. -
PROXY protocol or source-IP preservation at the balancer. DNSBL checks and per-client connection limits key on the peer address. Behind an L4 balancer that rewrites sources, either preserve client IPs (e.g. Kubernetes
externalTrafficPolicy: Local) or enableproxy_protocol = truein each listener’sserversection and the balancer. Enabling it requires aproxy_trustedCIDR allowlist naming the balancer — startup refuses the switch without one, since the preamble is trivially spoofable by anything else that reaches the port. Never enable it on a listener that clients can reach directly. -
Replica-aware limits.
max_connections/max_per_peerare enforced per process; the fleet-wide effective cap is the per-instance value times the replica count. Those slots recycle themselves under a flood of connections that go quiet:limits.handshake_timeout_secs(30 s) bounds how long a peer can hold one without finishing TLS, andlimits.write_timeout_secs(60 s) bounds one that stops reading altogether, so an exhausted listener recovers on its own instead of waiting for the kernel to give up on the sockets. Both are per-listener settings in the[*.server]section.
What already just works
-
Job queues (chain/relay/DSN/delete) claim atomically under concurrent workers on all backends; jobs are at-least-once and every handler tolerates duplicates.
-
Per-recipient SendMail ordering is serialized across instances by a store lease (
send/{wallet}), so concurrent spoolers cannot double-send or double-spend stamps on one mailbox. -
POP maildrop exclusivity is a store lease with self-expiry (
pop/{wallet}, a 15-minute TTL) — a crashed session on one instance cannot wedge the maildrop for the rest, because the next login steals the lapsed lease. That stealing cuts both ways, which is what renewal is for: a session renews its lease before every mutation, and a renew that comes back lost stops that deletion instead of expunging mail this instance no longer owns. Two daemons can therefore serve one wallet’s POP without either one deleting the other’s messages.A lapsed lease is not automatically a lost maildrop, though, and since 2026-08-07 it is not treated as one. When the renew refuses, the session gets one re-acquire, and keeps the maildrop only if two conditions both hold. First, the key was free: acquisition wins an absent or expired lease and nothing else, so the store handing one back is the proof that nobody else held one — there is no separate “who owns this?” read, and therefore no window between asking and acting. Second, the listing has not moved: the mailbox must still hold the same messages, at the same sizes, in the same order, that this session opened on, and a store that cannot answer the question counts as moved. The lease is taken before the listing is re-read, so nothing can slip in between; and if the listing gate refuses, the just-taken lease is handed straight back rather than squatting on a maildrop the session has disowned.
What that buys is the quiet case, which on a single-instance server is the only case: a session that idled past the TTL on a maildrop nobody else touched now lands its QUIT-time deletions and signs off
+OK. A genuinely stolen maildrop, a lapsed one whose contents moved underneath it, and any store that cannot answer are all still refused, exactly as before — see Known seams.Renewal and acquisition are one atomic conditional write each on all six backends, and the behavioral contract is covered by the shared conformance suite — but with the same provenance caveat the role-split topologies carry: they are executed in every build on SQLite, Turso and D1, while the DynamoDB, Azure Tables and Postgres implementations are exercised only when their test endpoints are configured. The roster of those gate variables lives in
mail_store/README.md’s Tests section (outside this book) rather than being restated here — a test in that crate scans its sources and fails if a gate variable is missing from that section, so the crate’s list is the one that cannot fall behind. On those three backends, read the lease round trips as reviewed against the contract rather than as proven in the default build.The client is told, because POP3 (RFC 1939) has no untagged channel to warn on and the final reply is the whole signal: a session that lost the maildrop answers QUIT with
-ERR [SYS/TEMP] some deleted messages were not removedand the server closes, instead of the usual+OK POP3 server signing off (N messages left). A client that would have dropped its local copies on+OKkeeps them. A session that marked nothing for deletion still signs off+OKwhatever the lease is doing — nothing was mutated, so nothing was lost.Re-proving is policy, not a background timer: nothing renews on a clock. The first mutation of a session always renews, and later ones renew only when the standing proof has gone stale — the coded threshold is half the TTL (7.5 minutes), chosen so a renew’s fresh full TTL leaves slack rather than landing on the expiry second. A read-only session — LIST, RETR, QUIT with nothing marked — never renews at all, and never needs to.
-
The reconciler may run on every instance; duplicate re-enqueues are absorbed by the chain worker’s state guards.
-
account-apireplicas share nonces and credentials through the store; any replica can answer any request.
Known seams
\Recent(IMAP) is best-effort across instances: two concurrent SELECTs of one mailbox on different instances may both see a message as recent.- IDLE latency on a poll-fed instance is bounded by
watch_poll_seconds, not instant. - An IMAP session’s selected-mailbox snapshot is taken at SELECT: an
idler woken by a cross-process delivery gets its untagged
EXISTS, but FETCHing the new message takes a re-SELECT first (NOOP-driven refresh is deferred work). - A POP session that idles past the TTL on a busy maildrop still loses its
deletions. The re-acquire recovers a lapsed
lease only while the mailbox stood still. A session that spends more than the
15-minute TTL without mutating anything — a client left sitting in an open
POP session, reading and then deleting at the end — has nothing left to renew
by the time it marks a message, and if the maildrop no longer lists what that
session opened on, its QUIT-time deletions all fail with
-ERR [SYS/TEMP] some deleted messages were not removed. New mail arriving is enough: delivery into INBOX does not take the maildrop lease, so on a mailbox that receives anything during the lapse the listing has moved and the session refuses, single instance or fleet. It is the safe direction to fail — the messages are intact, the client keeps its copies, and no session is ever told+OKfor a deletion that did not land — but it is user-visible, and the re-acquire is not a promise that a long enough session always keeps its maildrop. - The account API’s plaintext summary cache can be shared; its other
two caches are per replica. By default each
account-apiprocess holds its own plaintext summary cache, sealed-summary cache and reading-secret stash — nothing is shared through the[store]— so every replica behind the load balancer warms its own. Since v0.110.0[cache] kind = "redis"points every replica at one Redis server for the plaintext cache, fail-open (a down server means slower, never wrong) — see A shared backend for a fleet. The sealed-summary cache and the reading-secret stash stay per replica by design: they hold decrypted content and credentials that must die with the session. Sizing follows the split:session_summary_capacity,max_cached_sessionsandmax_session_secretsare always sized by the concurrent sessions one replica sees, never the fleet total, and so issummary_capacityunder the defaultkind = "local"— under"redis"that key is unused, andredis_ttl_secs(plus an optional server-sidemaxmemory-policy allkeys-lru) bounds the shared cache instead. The farm starting values (summary_capacity = 16384or65536,max_cached_sessions = 32or more) are on the configuration page; decide the real ones from one replica’ssithbit.api.cache.*series with Tuning the account-API caches. - Cloudflare leases are strict (since 2026-07-19). Lease acquisition
(
send/{wallet}SendMail serialization,pop/{wallet}maildrop locks) is the same single-statement compare-and-swap upsert the SQLite/Turso stores run, executed on D1’sleasestable — atomic server-side, no TTL floor, expiry a plain integer comparison. The original Workers KV lease (read-then-write, no CAS, ~60 s minimum TTL, eventually-consistent expiry) is retired; every backend’s leases are now strictly atomic. The daemon’s in-process write mutex remains purely a REST-contention reducer, not a correctness dependency.
Role-split topologies
The checklist reads as if every replica were identical,
but nothing requires that: each sithbitd listener and the background
workers are independent config toggles, so a fleet can split by role
— inbound MX edges, an authenticated-submission edge, an IMAP/POP pickup
tier, and headless workers — all over one store. The split is the
many-instance cloud-store story, so every checklist item applies
unchanged; in particular item 1: a role split is a multi-process
deployment, and SQLite stays one process per store, full stop. This is a
sithbitd story — the standalone smtp-server/imap-server/pop-server
binaries remain dev shells, not the production split.
Five toggles produce the roles:
| Role | [smtp] (MX) | [submission] | [imap] | [pop] | [spooler] enabled |
|---|---|---|---|---|---|
| All-in-one (the default shape) | on | off | on | on | on |
| MX edge | on | off | off | off | off |
| Submission edge | off | on | off | off | off |
| Pickup (IMAP/POP) | off | off | on | on | off |
| Workers | off | off | off | off | on |
The defaults match the first row ([smtp], [imap], [pop], and
[spooler] on; [submission] off), so every preset below writes only
the lines that differ — the usual store/TLS/hostname settings from the
Configuration reference come on top.
[spooler] enabled = false skips all the background workers as one
unit: relay, DSN, the chain pin/send + delete pipeline, the auto-settle
sweeper, the reconciler, and the repin migration. Mail is still accepted
and spooled — the jobs sit in the shared queues until a worker-enabled
sibling drains them. Two things deliberately stay outside the switch:
the DMARC RUA/RUF reporting workers keep their own section
switches,
and the embedded IPFS swarm runs whenever it is configured — DHT
participation is the node’s job, not a spooler worker.
# MX edge — accept inbound mail and spool it; a worker sibling drains it.
# [grpc] alone gives this edge RCPT-time postage verification (and
# at-rest sealing key reads) without an [ipfs] provider it never uses —
# the verification-only posture; the pipeline runs on the worker tier.
[grpc]
endpoint = "http://mail-grpc:50051"
[imap]
enabled = false
[pop]
enabled = false
[spooler]
enabled = false
# Submission edge — authenticated client sends only.
[smtp]
enabled = false
[submission]
enabled = true
[imap]
enabled = false
[pop]
enabled = false
[spooler]
enabled = false
# Pickup tier — IMAP + POP readers.
[smtp]
enabled = false
[spooler]
enabled = false
# [imap]
# watch_poll_seconds = 5 # auto-adopted on an IMAP-only instance; see below
# Workers — no listeners, all the background workers (the [spooler]
# default). The chain pipeline runs where the workers run, so the
# [grpc] + [ipfs] sections belong on this instance; the SMTP edges
# carry [grpc] alone, for verification only.
[smtp]
enabled = false
[imap]
enabled = false
[pop]
enabled = false
mail-grpc itself is not a fleet member: it stays a single
private-network service the worker tier points at, and a many-instance
fleet can safely share one gateway because the store lease serializes
each wallet’s chain writes. The reasoning is recorded in
the gateway topology design note.
IDLE on a split-out pickup tier
Delivery push is in-process, and nothing delivers on a pickup instance —
so its IDLE wakes come only from store
polling: the IMAP backend polls the mailbox change counter every
watch_poll_seconds and pushes the untagged EXISTS to idlers. Stated
plainly:
- New-mail latency is the poll interval, not instant. An idler on a
pickup instance learns of a delivery up to
watch_poll_secondsafter the worker tier lands it. - Leaving the setting at its
0default (“trust in-process push”) would leave idlers asleep forever on an instance where nothing delivers, sosithbitdapplies a safety rider: IMAP on + both SMTP roles off +watch_poll_seconds = 0auto-adopts 5 seconds, with an info log saying so. An explicit value is always the operator’s choice, and0keeps meaning in-process push whenever an SMTP role is co-resident. - The known seams above bind with full force here:
\Recentis best-effort across instances, and a woken idler re-SELECTs before FETCHing the new message.
Which stores support which split
The store rules are the checklist’s, mapped onto roles. On
postgres/aws/azure/cloudflare, any role may run N-wide — queues,
leases, and counters are all cross-instance. sqlite and turso allow
no split at all — one process per store.
Both split topologies are proven in-tree. An always-on test walks one
message across three role instances — real SMTP into an MX edge, chain
pin + SendMail on a workers instance, poll-fed IDLE wake then FETCH
and POP RETR on a pickup instance — over one SQLite store
(mail_spooler’s one_message_crosses_the_role_split_topology; each
instance gets its own store handle inside one test process, since real
SQLite deployments stay single-process). The same walk runs as a true
multi-writer split on postgres, each role booting its own store stack
from [store] kind = "postgres", gated on a configured postgres test
endpoint (one_message_crosses_the_role_split_topology_on_postgres);
the gate variable is named, with the rest of the store-backend roster,
in mail_store/README.md’s Tests section (outside this book).
Adding a storage backend
Six backends (SQLite, Postgres, DynamoDB+SQS, Azure Tables/Queues,
Turso/libSQL, and Cloudflare D1/Queues/KV/R2) share one behavioral
contract, and the plumbing is deliberately small. A new backend touches
exactly six places, all but a one-line forwarding entry in mail_store:
Cargo.toml— a cargo feature gating the backend’s SDK deps. Declare it inmail_storeand keepallcomplete; each of the five store-consuming binaries (mail-spooler,account-api,ipfs-daemon,ipfs-gateway,mail-console) forwards it in its own[features]table (see Slim-build features);config.rs— aStoreKindvariant plus its[store.<kind>]settings struct (never feature-gated: configs parse in every build);- a backend module implementing the four repo traits (
AccountRepo,MailRepo,JobQueue,KeyedLease) — blobs are orthogonal and stay behindAnyBlobStore; stores.rs— a<Kind>Storesalias with anopenconstructor (plus itsBackendDisabledstub alias), one arm in thewith_backend!macro (the workspace’s single backend dispatch point;sithbitdandaccount-apiboth route through it), and the enabled/disabled__with_backend_<kind>helper pair;lib.rsexports, feature-gated;tests.rs— an env-gated conformance registration deriving from the canonical test list (skips must be named, with a reason), feature-gated.
The contracts to honor are written where they bind: the counter-
allocation rules (atomic, monotonic, gap-tolerant) on the MailRepo
trait doc with the three known implementation strategies; the job
identity-vs-claim-token split on JobQueue; and the two frozen
composite-key codecs in mail_store::keys (pick unit_sep if the
store allows control bytes in keys, percent if not — never invent a
third). The conformance suite proves all of it against a live instance
before the backend ships.
A backend that creates its own cloud resources requests
provider-managed encryption at rest when it does so — key configuration
is optional, never required. The AWS backend enables server-side
encryption on the DynamoDB tables (AWS-owned key) and SSE-SQS on the
queues it creates, or a customer-managed KMS key for both when
[store.aws] kms_master_key_id is set (see
Running a mail server for
detail); Azure Storage/Tables and Cosmos are always encrypted at rest
by the platform, so no code is needed there.
IPFS: the shared-bucket cluster
The self-hosted IPFS node scales by the same principle as the stores:
the bucket is the truth, the nodes are stateless. N embedded nodes
(or sithbit-ipfsd daemons) point at one S3/GCS/Azure bucket
([ipfs.blobs] / ipfsd’s [blobs]) and enable [ipfs.cluster] /
[cluster] — that’s the whole join procedure: membership heartbeats
live in the bucket next to the blocks and pin manifests, so a node
needs nothing but the bucket credentials. There is no gossip transport,
no bootstrap list, no consensus.
What the cluster coordinates:
- Any-node pin/unpin. Pin manifests are last-write-wins objects in
the bucket; every node sees every pin (the
reprovide sweep
re-reads them), and any node can serve any pinned block over
bitswap or
GET /ipfs/{cid}— the data has exactly one billed copy, in the bucket. - Partitioned DHT announces. With
[swarm] provide = true, live members split the reprovide keyspace by rendezvous hashing — each root is announced by exactly one member. A member that misses heartbeats formember_ttl_secsis dead; survivors notice at their next heartbeat tick and immediately resweep, taking over its share (remote DHT records carry a ~24 h TTL, so a dead node’s announces stay resolvable while the takeover lands). - GC. The sweep deletes blocks no manifest references, but only
once they are roughly
gc_grace_secsold — a pin writes its blocks before its manifest, so in-flight pins are never collected. The grace is not a hard floor. The sweep subtracts the block’s recorded write time from the sweeping node’s own clock, and both are whole seconds, so a grace ofNgives onlyN - 1seconds even where one host supplies both. On the shared bucket a cluster runs over they are not one clock at all: the write time is the object store’sLastModified, so skew between a node and the store moves the boundary in either direction — a store stamping ahead of the node lengthens the margin, while a node whose clock runs fast, or a store stamping behind, shortens it and can erase it outright. Plan for the shortening direction: keep the default’s slack rather than tuning the value down against a pin’s measured upload time. Sweeps are idempotent; several nodes sweeping concurrently is safe, just redundant.
Failure economics: a dead node costs nothing but its share of DHT
announces until a survivor’s next heartbeat tick. Try it:
docker compose -f docker-compose.cluster.yml up -d boots two daemons
over one minio bucket, and docker/cluster-smoke.sh pins on node 1,
kills it, and fetches through node 2.
Glossary
A single-page reference for the vocabulary the rest of these docs assume: Solana account mechanics, the SithBit postage economics, the sealed-box crypto, the self-hosted IPFS node, the mail protocols, the pluggable storage backends, and the operational surface. Where a term has a chapter or appendix of its own, the entry here is a one-liner that points at it; where a word is overloaded (SithBit reuses seal, authority, remote, and provider for several distinct things), each meaning gets its own disambiguated entry.
Solana & on-chain
PDA (Program Derived Address)
A deterministic account address a program owns, derived by hashing a set of seeds together with the program ID. SithBit’s mailboxes, fromboxes, message accounts, the postoffice, and domains are all PDAs — see the Program & PDA reference for the exact seeds.
Rent / rent-exemption
The refundable one-time SOL deposit every Solana account must hold to exist, sized to the account’s byte length rather than to any value it represents. It is returned in full when the account is closed — see Closing accounts.
Lamport
The smallest unit of SOL: one lamport is 1e-9 (one-billionth) SOL. Every price, fee, and balance in the protocol is denominated in lamports.
Basis points (bps)
One basis point is 1/100th of a percent; 10 000 bps is 100%. The domain
operator’s cut is OPERATOR_SHARE_BPS = 1 000 bps (10% of postage).
Cross-Program Invocation (CPI)
One on-chain program calling another within the same transaction — Solana’s mechanism for composing programs.
SithBit’s three programs never CPI into each other. Their only CPIs target the System program, for creating accounts and for moving wallet lamports — e.g. the alias claim fee is a System transfer from the payer’s wallet into the mail program’s postoffice account. Cross-program coupling is read-only instead (the alias and domain programs read the postoffice and domain accounts the mail program owns, without invoking it), and settlement between program-owned accounts is direct lamport arithmetic with no CPI at all.
Upgrade authority
The keypair allowed to redeploy new bytecode to a program’s fixed ID. Who holds it, and the freeze/multisig/DAO trajectory, is covered in Program upgrade authority.
BPFLoaderUpgradeable
Solana’s upgradeable-program loader (a fixed, well-known program) under which the three SithBit programs are deployed: the program ID is permanent, but the holder of the upgrade authority can replace the bytecode behind it.
Off-curve address
A 32-byte address that is not a valid Ed25519 point — a PDA is the canonical
example. It has no private key, so no X25519 conversion exists for it and it
can never receive sealed mail; a send to one settles into the
no_key chain state.
Authority (three kinds)
SithBit uses “authority” for several distinct powers, and they are held separately in a serious deployment: the domain authority (an MX operator’s wallet, per domain), the upgrade authority (the key that can rewrite program bytecode), the delegate (the hot operational admin wallet), and the postmaster (the hidden ceremony-committed owner). Compromising each is severe in a different way.
surfpool
The local Solana test validator the integration suite and the dev/chain
profile target on 127.0.0.1:8899; the test harness boots one, deploys the
three programs, and seeds fixture state.
SithBit protocol & economics
Stamp
Prepaid postage for one email from one sender (“from” address) to one recipient wallet; sending decrements the count. Its full lifecycle and pricing is in Economics.
Postage
The per-message price the recipient sets. A frombox’s required_postage
defaults from the recipient mailbox’s default_postage, and only the
recipient may change it — see Economics.
Frombox
A recipient-owned prepaid-stamp account, one per (sender “from” address, recipient “to” wallet) pair, holding the bought stamps and the price for that sender — see Fromboxes.
Mailbox
The 1:1 account for a wallet address, holding its domain, encryption key, and default stamp price — see Mailboxes.
PostOffice
The singleton admin account: it records the standing delegate, the ownership commitment root, and the tunable fee values, and collects the protocol’s fee revenue — see Economics.
Delegate
The postoffice’s standing operational admin wallet: it authorizes and
deactivates domains, tunes the capped fees, publishes the root KSK, and
gets the bulk-alias fee waiver. A hot key by design, revocable by an
ownership-signed delegate — see
The Postmaster.
Postmaster
The postoffice’s owner — not a pubkey on-chain, but a Merkle commitment root over a hidden key set from an offline key ceremony. Ownership operations (revenue sweep, delegate rotation, ownership handover) reveal one committed key with a membership proof and rotate the whole set. Custody guidance is in Postmaster key custody.
Wallet mail password
The credential a mail app stores to log in as a wallet: base58 of a 64-byte
ed25519 signature over a fixed challenge, presented with the wallet’s own
base58 address as the username. It is derived, never chosen — the
servers keep no secret for the account and verify the signature against the
public key that username spells, so a stock SASL PLAIN/LOGIN, POP
PASS or IMAP LOGIN client authenticates against an account that was never
provisioned with a password at all. It is not the
stored mail password,
which is a secret the operator holds, encrypted under the credential
seal key. A login may append the session reading secret
after a . separator so the session can open
at-rest-sealed mail; authentication ignores that
half.
Because the challenge is fixed bytes, the derivation is deterministic: one
wallet at one auth epoch always yields the same string. That is
the point — it is what lets an ordinary mail client save the value once, as the
single static password it was built to hold. The one live challenge — v2 — is
"SithBit mail auth v2:" ‖ the wallet’s raw 32-byte public key ‖ the epoch as
a big-endian u64, 61 bytes in all; a bump changes those bytes, so rotating
means re-deriving and re-pasting the password wherever it was saved.
There is no second version to choose between any more. The epoch-less v1
challenge — the same prefix reading v1:, then the key, 53 bytes — was once
accepted for any account still at epoch 0; that window is closed, no
server builds or accepts those bytes at any epoch, and no client library
derives them. Both
sithbit mailbox credentials
and the webmail settings page
derive v2, as they always did; a v1 password still in a mail app re-derives.
Auth epoch
A per-account u64 counter, 0 until the first rotation, mixed into the
challenge a wallet signs to derive its mail password: bumping it changes the
bytes every valid password signs over, so one bump retires every outstanding
wallet-derived password at once, on every SMTP, IMAP and POP listener. The
signed message is "SithBit mail auth v2:" ‖ the wallet’s raw 32-byte public
key ‖ the epoch as a big-endian u64 — 61 bytes. The epoch-less v1 form that
epoch 0 once also accepted is refused at every epoch now: it was
domain-separated from v2 by prefix and by length, so retirement was abrupt.
The lever is the armed, two-step Rotate the wallet mail password control on
the webmail settings page
— the same control the Thunderbird, Outlook and Chrome panes carry — and
underneath it the one call
POST /v1/account/auth-epoch;
sithbit mailbox credentials --epoch <N>
derives the replacement offline. Its scope is narrower than “revoke
everything”: a bump does not clear a
stored mail password,
does not sign the session out (the JWT is stateless and
unrevoked), and does not revoke a SASL EXTERNAL client-certificate
login — that proves identity from the certificate rather than from a signature
over the epoch, so only the operator’s client_cert_auth closes it.
Bumping it is
step-up gated:
the account API refuses the call with 428 Precondition Required until the
request carries a wallet signature made seconds ago, so a live session on its
own cannot retire an account’s passwords. A refused bump changes nothing — the
epoch stays where it was and the password in use keeps working.
Deactivation timelock
The two-step, 7-day delay the delegate must pass through to deactivate a domain: request starts the clock (domain stays active), finalize takes effect only after it elapses, and cancel aborts it meanwhile. Reactivation stays instant. A hardening measure against a compromised delegate — see Deactivate a domain.
Mailbox close timelock
The two-step, 7-day delay a mailbox owner must pass through to close their mailbox: request starts the clock (mailbox stays open and keeps receiving mail, and no rent is refunded), finalize closes it and refunds both rents only after the clock elapses, and cancel aborts it meanwhile. The mailbox-key close stays instant. A separately tunable setting from the deactivation timelock above, aimed at a different abuse: free identity-cycling by spammers — see Close a mailbox.
Domain authority
The wallet registered as a domain’s operator. It is the only signer that can
relay SendMail into that domain (inbound MX mail), and it earns the
operator share on every settled message for its mailboxes.
Sender attestation
An on-chain record, minted by DNSSEC proof for a one-time fee, in which a DNS domain vouches for a wallet as its legitimate sender — the protocol’s verifiable trust mark for organizational senders. It confers no serving rights, and the attested wallet may revoke it at any time — see Verified-sender attestation.
Operator share
OPERATOR_SHARE_BPS = 10% of a message’s postage, paid to the recipient’s
domain authority when the message settles via
DeleteMail. It is waived on a RefundMail — a refund is not a revenue
event.
Settlement
Splitting a parked message account’s balance to its participants. DeleteMail
settles to the recipient (rent back to the sender, the operator share to the
domain authority, the postage to the recipient); the
auto-settle sweeper does it in the background.
Auto-settle sweeper
The [spooler.settle] background worker: an hourly scan that fires
DeleteMail after_days (default 30) past confirmed delivery, reclaiming the
on-chain stamp value while keeping the local IMAP/POP copy. On by default; it
also unpins the sealed IPFS body unless keep_pin = true — or unless the
CID carries a live pinning lease.
Pinning lease
A per-(CID, holder) on-chain account
(sithbit mail lease)
escrowing a reclaimable deposit that asks operators to keep a mail body
pinned past the default retention. Its existence is the lease: no expiry,
no renewal fee; closing it returns deposit and rent. The one-time creation
fee splits with the recipient’s domain authority at the operator share.
Alias
A globally-unique, case-insensitive human-readable name that resolves to a wallet address, cross-domain and lowercased/domain-stripped at creation — see Aliases.
Stamp fee surcharge
STAMP_FEE_SURCHARGE_LAMPORTS = 10 000 (2 × the 5 000-lamport base fee), a
per-stamp add-on paid at purchase that prefunds the two settlement signature
refunds (SendMail and DeleteMail).
Per-stamp protocol fee
A flat postoffice fee (default POSTOFFICE_STAMP_FEE_LAMPORTS = 100 000)
charged per stamp on third-party purchases and transferred straight to the
postoffice; waived when the fee payer is the recipient wallet itself.
Cryptography
Sealed box (crypto_box_seal)
libsodium’s anonymous public-key encryption to a recipient’s key alone — no sender keypair involved. SithBit seals every mail body this way; the full walkthrough is in How sealed-box encryption works.
libsodium
The C cryptography library that defines the crypto_box_seal construction
SithBit’s sealed boxes implement — a
specification and reference implementation, not a dependency: SithBit links no
libsodium, only the pure-Rust crypto_box crate that produces the same bytes
(which is what lets the same sealing code compile to WebAssembly). Because the
formats match, a libsodium.js or tweetnacl client opens a SithBit sealed box —
see Where libsodium fits.
X25519
The Curve25519 key-exchange form sealed boxes use. A wallet’s Ed25519 key is converted to it on the fly, or a signing-only wallet publishes a delegated key — see How sealed-box encryption works.
Ed25519
The signature curve a Solana wallet address is a public key on. It is one-way convertible to X25519 for encryption — see How sealed-box encryption works.
Delegated key
A self-generated X25519 public key a signing-only wallet (hardware/browser) publishes on-chain so MX servers seal to it instead of converting the wallet key — see Mailbox Keys.
blake3
The fast hash SithBit uses wherever it needs a fixed-size fingerprint in place of a raw value: the frombox’s “from” address, the alias name and the claimed domain (all PDA seeds), the bountied message a reply names, and the postoffice commitment set’s Merkle leaves. See How blake3 hashing works and the Program & PDA reference.
Seal (two meanings)
For mail, sealed means encrypted so that only you can read it, with your key: the crypto_box sealed box locks a message body to the recipient’s wallet (or published reading key) and to nothing else.
That mail sense covers both sealing at send time (bodies sealed to the
recipient before they are ever stored or pinned) and
at-rest sealing (delivered copies sealed to the
account’s reading key in the operator’s store). An unrelated second use
shares the word: the credential seal key (credential.key) that encrypts
stored mail passwords at rest in the store — a symmetric key that must be
identical on every replica and is unrecoverable if lost, so back it up first
(see Monitoring and backups).
At rest (at-rest sealing)
“At rest” means stored on the mail server’s disk — the copy of your mail the operator keeps between delivering it and your client fetching it, as opposed to mail in transit on the network. At-rest sealing seals each delivered body to the account’s reading key, so the operator’s storage holds only ciphertext.
Automatic for password-less accounts on chain-connected deployments; what it does and does not protect against is covered in What your operator holds.
Reading key
The key that decrypts a password-less account’s at-rest-sealed mail: the wallet keypair itself, or the delegated X25519 key a signing-only wallet publishes. The account API’s “log in again with your reading key” refusal means the session never supplied it — the reading secret travels only at login and is held in memory just for that session.
Related: Seal (two meanings), Sealed box, and What your operator holds.
IPFS & the self-hosted node
IPFS
The content-addressed, decentralized store SithBit pins encrypted mail bodies to instead of holding them on-chain or on one provider’s servers — see IPFS storage: benefits to users.
CID
A content identifier: a hash-derived address, so the same bytes always yield the same CID and any change alters it (a built-in tamper check). The on-chain message envelope stores the body’s CID. For a plain-language explanation aimed at non-technical readers, see What is a CID?; for the wider storage rationale, IPFS storage: benefits.
Pin / unpin
To pin a CID is to retain its blocks against garbage collection; to unpin is
to release them. Settlement unpins the sealed body by default (keep_pin = false). For a plain-language explanation aimed at non-technical readers, see
What does pinning mean?.
bitswap
IPFS’s block-exchange protocol. A SithBit node serves its pinned blocks over
bitswap (/ipfs/bitswap/1.2.0) to any peer that asks, but never fetches
foreign CIDs — serving is one-way.
DHT (Kademlia)
The Kademlia distributed hash table IPFS uses as its content/peer index. With the swarm running, a node learns peers via identify and can announce the roots it holds.
Provider record / provide / reprovide
A provider record is the DHT announcement that a node holds a given root
CID; provide ([swarm] provide = true) publishes them; the reprovide
sweep periodically re-announces (every reprovide_interval_secs, default 22 h)
so stock Kubo peers can still discover the node as the content’s provider.
UnixFS import profile
The fixed importer parameters (CIDv1, sha2-256, raw leaves, 256 KiB balanced dag-pb) that make SithBit’s CIDs byte-identical to Kubo’s for the same bytes.
multiaddr
A self-describing network address naming transport and port, e.g.
/ip4/0.0.0.0/tcp/4001 or /ip4/0.0.0.0/udp/4001/quic-v1; swarm listen and
bootstrap addresses are multiaddrs.
PeerId
The stable libp2p identity derived from the node’s ed25519 key. Persist the
identity_file, or the PeerId — and every provider record naming it — goes
stale on each restart.
libp2p / swarm
libp2p is the peer-to-peer networking stack; the swarm is the running
instance that joins the IPFS network (identify + DHT + bitswap). Omit
[ipfs.swarm] and no swarm runs.
Kubo
The reference Go implementation of IPFS. SithBit’s embedded node stays byte-compatible with it (same CIDs, same protocols) so stock Kubo peers interoperate.
CAR (Content Addressable aRchive)
A trustlessly-verifiable bundle of IPFS blocks. The path gateway can emit one
with ?format=car so a client verifies the content itself rather than
trusting the server.
Path gateway
A read-only HTTP surface — GET /ipfs/{cid} — that lets non-IPFS clients
fetch content over plain HTTP. SithBit’s sithbit-gateway serves only local
content and never fetches foreign CIDs.
Shared-bucket cluster
The IPFS scaling model: N stateless nodes point at one S3/Azure bucket and coordinate purely through membership heartbeats written into that bucket — no gossip, no bootstrap list, no consensus. See Scaling out.
Rendezvous hashing
The assignment that gives each root’s reprovide to exactly one live cluster member; when membership changes, the survivors resweep and take over a dead node’s share.
remote (two meanings)
Two unrelated “remotes”. (1) [ipfs] kind = "remote" delegates pinning to a
shared sithbit-ipfsd daemon instead of embedding a node. (2) A Turso
embedded replica’s remote primary is the libSQL
server it syncs from. Different subsystems, different config.
Provider (three meanings)
(1) An IPFS pinning provider — Filebase, Pinata, or the embedded node — where sealed bodies are stored. (2) A DHT provider record, the announcement that a node holds a CID. (3) A generic hosting/cloud provider (AWS, Azure, Cloudflare). Read which from context.
Node-delegation cert
A short-lived, authority-signed binding of a per-node key to a
{domain, proto, expiry} tuple (NodeDelegation → SignedDelegation in the
node_cert crate). It lets a POP/IMAP node prove the domain’s on-chain
authority blessed this key to serve this protocol,
without the root authority key ever touching the node — the trust chain is
on-chain MailDomain.authority → delegation → node key. Self-delivered (in a
service record or a self-auth TLS cert), never registered
on-chain; revocation is expiry/rotation. See Decentralized service
discovery.
Service record
A node’s signed advertisement of the endpoints it serves for one
(domain, proto), published on the Kademlia DHT under
hash("sithbit/service-record/v1" ‖ domain ‖ proto) — a keyspace separate from
provider records. Carries the node’s
delegation and is validated on get (node signature,
delegation consistency, expiry, and a short advertise TTL). Kept fresh by a
minutes-scale TTL + heartbeat republish (service_record_ttl_secs /
service_heartbeat_interval_secs). Discovered with sithbit discover — see
Decentralized service discovery.
Mail protocols (SMTP/IMAP/POP)
RFC
A Request for Comments: a numbered specification published by the IETF’s RFC Editor, the canonical form in which internet protocols like SMTP, IMAP, and POP are defined. When SithBit documentation cites “RFC 5321”, it means the published standard every interoperating mail server is expected to honor — Standards and RFC coverage enumerates the ones SithBit implements.
Sans-io
A protocol-implementation style in which the code that speaks the protocol
never touches the network: a pure state machine consumes bytes and events
and emits actions, while a thin driver owns the sockets, TLS, and timeouts.
SithBit’s SMTP, IMAP, and POP cores (smtp_session, imap_session,
pop3_proto) are all sans-io, which is what lets every protocol
conversation be tested as a script with no connection open.
MX
The DNS mail-exchanger record that tells other servers where to deliver a domain’s mail — and, on the server, the inbound SMTP listener (port 25) that accepts it.
Submission vs MX
Two SMTP roles: submission (port 587, authenticated outbound from a user’s own client) versus MX (port 25, inbound from other mail servers). SithBit runs them as separate listeners with different policies.
STARTTLS / STLS / implicit TLS
Two ways to get TLS: STARTTLS (SMTP/IMAP) and STLS (POP) upgrade a
plaintext connection in place, while implicit TLS wraps the socket in TLS
at connect (implicit_tls = true).
SASL
The authentication framework the servers speak; SithBit supports the PLAIN, LOGIN, CRAM-MD5, and APOP mechanisms.
IDLE
The IMAP command that pushes new-mail notifications to a client. Delivery push
is in-process; a split-deployment instance that didn’t do the delivering only
learns of new mail by polling every watch_poll_seconds.
MailboxNotify / await_change
The cross-process seam (MailRepo::await_change) that lets an IDLE
client on one node learn of mail delivered on another node — the piece that
makes discovered-node failover safe. Its default is poll-backed over the
mailbox’s change_seq (the DEFAULT_WATCH_POLL interval, 2 s), so it works on
every backend including SQLite; native per-backend push (Postgres
LISTEN/NOTIFY, DynamoDB Streams) is a deferred drop-in behind the same seam.
Only meaningful over a shared cloud store — SQLite is
single-node by design. See Decentralized service
discovery.
Expunge
The IMAP client action that permanently removes deleted messages. On SithBit it
triggers the on-chain teardown (chain_delete job) and releases the IPFS pin.
Maildrop
POP3’s single-spool view of a mailbox. Exclusive access is guarded by a
self-expiring store keyed lease (pop/{wallet}, 15-minute TTL)
so a crashed session cannot wedge it. A session renews that lease before it
mutates anything, and one that has lost it fails its QUIT-time deletions
rather than expunging mail it no longer owns — see POP maildrop
exclusivity.
DSN
A Delivery Status Notification (RFC 3464) — the bounce or delay report the spooler generates when outbound mail fails or is retried.
Relay / spooler
The relay worker forwards outbound mail to remote MX servers; the
spooler is the worker tier that drives the whole chain → relay → DSN
pipeline. Both run inside sithbitd.
Smarthost
A fixed, authenticated relay ([spooler.smarthost]) that all outbound mail is
routed through instead of MX resolution — the workaround when port 25 is
blocked.
EHLO
The SMTP greeting in which a server or client identifies itself by hostname.
Set it to match your DNS/PTR (hostname), or many receivers score the
mismatch as spam.
sender_auth
The MX setting choosing inbound from-domain checks: "spf" (reject on
hardfail, the default), "dmarc-lite" (DMARC alignment, reject only on
p=reject), or "none".
SPF
A DNS TXT record listing which hosts are allowed to send mail for a domain; receivers check it against the connecting IP.
DKIM
A cryptographic signature over outgoing mail, verified against a public key in
DNS. sithbitd signs authenticated submissions (rsa-sha256, RFC 6376) when
[spooler.dkim] is configured.
DMARC
The policy that ties SPF/DKIM results to the visible from
domain and tells receivers what to do on failure, specified by RFC 9989
(which obsoletes RFC 7489). dmarc-lite mode rejects
only when the sender publishes p=reject; the full dmarc mode evaluates
the domain’s entire published policy (p=reject bounces, p=quarantine
files into Junk, sp=/np= decide subdomains) and can emit both RFC 9990
aggregate (rua) reports and per-failure RFC 9991 forensic (ruf) reports
back to sending domains — the latter headers-only by default, full-message
on explicit opt-in, gated by an external-destination check on every target.
Enforcement is all-or-nothing: 9989 retired the pct= sampling tag, so a
published pct= no longer softens anything.
PTR / reverse DNS
The DNS record mapping an IP back to a hostname. Several large receivers refuse mail from an outbound IP whose PTR doesn’t match the EHLO name; it is set with the hosting provider, not in your zone.
DNSBL
A DNS blocklist queried by the connecting peer’s IP (e.g.
zen.spamhaus.org, configured as dnsbl_zone) to reject known-bad senders at
connect.
DBL
A domain block list — the DNSBL’s domain-keyed sibling. Where a DNSBL
scores the connecting IP, a DBL scores the sender domain; the SMTP server
queries it at EHLO and MAIL FROM and refuses a listed domain with
554 5.7.1. Configured as dbl_zone, the value being the full Spamhaus DBL
zone — the current DQS form <key>.dbl.dq.spamhaus.net (a free
per-customer Data Query Service key) rather than the deprecated public
dbl.spamhaus.org, which is blocked from the large public resolvers.
PROXY protocol
A preamble a load balancer prepends to carry the real client IP through to the
listener. Enable it (proxy_protocol = true) only behind an L4 balancer —
never on a directly reachable listener, where the preamble is trivially
spoofable. Turning it on requires a proxy_trusted CIDR allowlist of the
peers permitted to speak it; startup refuses an empty list.
MAILER-DAEMON / Reporting-MTA
The identities the spooler stamps on generated bounces and DSNs: the null-sender
MAILER-DAEMON envelope and the Reporting-MTA header naming the reporting
host (both derived from the spooler hostname).
Storage backends & durable queues
Store backend
The swappable layer behind the four repo traits (accounts, mail, job queue,
keyed lease); [store] kind selects sqlite, postgres, aws, azure,
turso, or cloudflare. See Scaling out.
Embedded replica
A Turso/libSQL local file that syncs to a remote primary. Reads hit the local
file and lag the primary by up to sync_interval_secs, so it scales like
SQLite (one writer), not like a shared store — see Scaling
out.
libSQL
The SQLite fork Turso builds on; the turso backend is a local libSQL file,
optionally an embedded replica.
Multi-statement transaction
A single atomic transaction spanning several SQL statements. Cloudflare D1’s
HTTP query API lacks it, so anything counter-critical on D1 is a single
atomic statement instead (uid allocation is an UPDATE … RETURNING, lease
acquisition a one-statement CAS upsert) — since
2026-07-19 this is no longer a reason D1 needs a
single writer.
Single writer / one-writer daemon
On stores without cross-daemon atomic counter allocation (sqlite/turso),
only one daemon may allocate IMAP uids and deliver mail; the read frontends
still scale freely. (cloudflare graduated out of this list 2026-07-19 —
its uid allocation is server-side atomic.) See Scaling
out.
Job queue
A durable work queue for background work (chain, relay, DSN, delete). Delivery is at-least-once with a visibility timeout: a claimed job is hidden while a worker holds it and retried if the worker dies, so every handler tolerates duplicates. See Monitoring.
Claim token / receipt handle
The identifier for a temporarily-claimed queue entry, distinct from the job’s own identity — the same idea as an SQS receipt handle. Requeue or discard a dead job within its claim window before the token lapses.
Dead-letter / buried job
A job that has failed too many times is buried to a dead-letter queue with a reason, for later inspection and requeue or discard — see Monitoring.
Keyed lease
A store-backed mutex keyed on a string — e.g. send/{wallet} to serialize a
mailbox’s SendMail, or pop/{wallet} for maildrop exclusivity. Atomic on
every backend: SQLite/Turso/Cloudflare share one single-statement
CAS upsert (Cloudflare runs it on D1’s leases
table), Postgres row-locks, Dynamo conditional-puts, Azure ETag-CASes.
Leases self-expire, so a holder that means to outlive its TTL has to renew. A renew sets the expiry absolutely — a full TTL from now, not added to whatever is left — and it is fenced the same way acquisition is: one atomic conditional write on every backend, which either extends the lease or reports it lost. “Lost” covers all three shapes at once: another holder stole the key after it lapsed, it lapsed without anyone stealing it (there is nothing live left to extend), or the key was never held. Only a live lease renews, and a holder told it lost the key must stop touching whatever the key guards.
Reconciler
The idempotent sweep that re-enqueues chain jobs for copies stuck in a non-terminal chain_state past a 15-minute horizon; duplicate re-enqueues are absorbed by the chain worker’s state guards.
chain_state
The per-copy progress field in messages.chain_state: local, received,
pinned, sent, no_key, or chain_failed. received is the resting state
when the chain pipeline is disabled — see Monitoring.
Cloudflare D1 / Workers KV / R2 / Queues
The Cloudflare edge services that back the cloudflare store: D1
(SQLite-over-HTTP) for accounts + mail + keyed leases, Cloudflare Queues
for the job queue, and R2 (S3-compatible) for blobs. Workers KV
formerly held the leases; it was retired 2026-07-19 (no
CAS, so its leases were only best-effort). Its
kv_namespace_id config key was kept as accepted-but-ignored for a
migration window and has since been removed outright — a config still
naming it now fails to load, rather than reading as a live setting.
Durable Object
Cloudflare’s single-threaded stateful primitive. It was once pencilled in as
the route to strict uid allocation and strictly-atomic leases on Cloudflare;
superseded 2026-07-19 by plain atomic D1 statements (UPDATE … RETURNING
allocation, one-statement lease CAS), which need no
Worker-side code at all.
Compare-and-swap (CAS)
An atomic conditional write (write only if the value is unchanged). Workers KV lacks it — the reason the retired KV lease was only best-effort; the D1 lease does it in one SQL statement.
Blob store
The object storage holding mail bodies — local, s3 (which also covers
GCS), Azure Blob, or R2 — orthogonal to the
tables/queues/leases backend and selected by [store.blobs] (or R2’s own
section on Cloudflare).
GCS (Google Cloud Storage)
Google Cloud’s object storage. SithBit needs no GCS-specific code: the
service speaks the S3 XML API on an interoperability endpoint
(https://storage.googleapis.com) against HMAC credentials, so a GCS
bucket is just the s3 blob store with that endpoint and
region = "auto" — see
Hosting on Google Cloud.
minio
An S3-compatible object storage server that is easy to self-host. SithBit’s docker demos and the store conformance tests run it as the shared S3 bucket the blob store and the shared-bucket cluster nodes point at — standing in for AWS S3 or Cloudflare R2 in local development, with no cloud account required. See min.io.
Operations & infrastructure
sithbit CLI
The command-line client (and library) these docs use throughout to build,
sign, and submit every on-chain instruction — wallets, mailboxes, fromboxes,
aliases, and domains all go through it. Built from the
sithbit-solana repository;
see CLI Quickstart for install and first use. A
separate tool from the
Solana CLI,
which sithbit config and sithbit wallet create fully substitute for in
this workflow.
OTLP / observability
OpenTelemetry push of traces and metrics over OTLP/gRPC. It is off unless
the [observability.otlp] config section is present — see
Monitoring.
Health probe (healthz / readyz)
Every binary serves GET /healthz (liveness) and GET /readyz (readiness) on
a loopback health listener, plus a --health-probe flag that GETs its own
/readyz and exits 0/1 — how the shell-less distroless images healthcheck. See
Monitoring.
distroless
A minimal container base with no shell and no curl. Debug it with docker logs/docker cp and the --health-probe
flag, not docker exec.
RUST_LOG / tracing
The target=level filter (e.g. info,mail_spooler=debug,sqlx=warn) that shapes
the structured tracing logs on stdout and what the OTLP export sends — a
silenced target is also not exported.
Multisig (N-of-M)
An on-chain vault that requires N of M signers to approve a transaction. It is the recommended custody for the upgrade authority; postoffice ownership has its own split-custody scheme, the key ceremony.
Zero-config default
The contract that an empty or missing config file yields a runnable loopback
dev instance: SQLite store, dev ports, and the chain pipeline off (delivered
copies rest in received). See the configuration
reference.
The _solana.authority TXT record
The DNS proof tying a domain to its claimed authority key: the domain owner
publishes their base58 ed25519 public key at
_solana.authority.<domain>, which domain-sithbit verifies before the
delegate authorizes the domain on-chain. See
DNS setup.
base58
The text encoding Solana uses for wallet addresses, keys, and signatures (and the delegated encryption key published on-chain).
RPC endpoint / JSON-RPC
The Solana JSON-RPC node the CLI, gateway, and account API talk to a
cluster through; resolved from the Solana CLI config or a
JSON_RPC_URL override. Solana clusters and RPC
endpoints covers the public clusters and their URLs.
JWT (JSON Web Token)
The signed bearer token the account API issues on
a successful wallet-challenge login. It carries the caller’s wallet identity, is
presented on every authenticated /v1/... route (the account, mail, and chain
surfaces), and expires. The API signs it with the jwt.key_file
key source,
which auto-generates a local key when none is present.
QUIC
A UDP-based transport libp2p can listen on for the IPFS swarm, alongside TCP —
e.g. /ip4/0.0.0.0/udp/4001/quic-v1.
Icon legend
These docs use a small set of inline term icons — one glyph per recurring domain word, plus one per shipped GUI client — so a reader scanning a page can spot where a mailbox, a frombox, a stamp, a pin, a domain, or the postmaster is being discussed, and which client a passage applies to, without re-reading the sentence. The set is deliberately small: a term joins it either by recurring widely across the book or by a deliberate editorial call to mark it wherever it appears — the frequency data below is the ranking behind the first route, and the paragraphs beside it are the record of the second. Both routes are kept narrow, so the icons stay meaningful rather than becoming decoration.
An author drops an icon in Markdown with a single inline <span> — mdBook
renders inline HTML as-is:
<span class="ticon ticon-mailbox" role="img" aria-label="mailbox"></span>
The base ticon class sizes and aligns the glyph; the ticon-<term> modifier
picks which one. Always include role="img" and an aria-label so screen
readers announce the term.
Legend
Every icon, its modifier class, and what the term means. (The first column shows the glyph as it renders on this page — in the surrounding text color.)
| Icon | Term | Meaning |
|---|---|---|
| address | A Solana wallet address, which doubles as a SithBit email address. | |
| mailbox | The 1:1 account for a wallet, holding its domain, encryption key, and default stamp price. | |
| frombox | A recipient-owned prepaid-stamp account, one per (sender “from” address, recipient) pair. | |
| stamp | Prepaid postage for one email from one sender to one recipient; sending decrements the count. | |
| alias | A globally-unique, case-insensitive human-readable name that resolves to a wallet address. | |
| domain | A DNS mail domain authorized on-chain, whose authority relays that domain’s inbound mail. | |
| mail / message | An email — on-chain, a parked message account referencing the sealed body’s IPFS CID. | |
| IPFS | The content-addressed network sealed mail bodies are stored on, instead of on-chain or on one provider’s servers. | |
| pin | Retaining a mail body’s blocks on IPFS against garbage collection; unpinning releases them. | |
| postoffice | The singleton admin account: it holds the delegate, the ownership root, and the fee values, and collects protocol fees. | |
| postmaster | The postoffice’s owner — a hidden, ceremony-committed key set, not a single on-chain pubkey. | |
| POP | POP3, the single-spool mail-retrieval protocol SithBit serves. | |
| IMAP | IMAP4rev1, the folder-based mail-access protocol SithBit serves. | |
| daemon | A long-running server process — e.g. sithbitd, the combined SMTP/IMAP/POP + spooler binary. | |
| hash | blake3, the fast hash used for two PDA seeds (the frombox “from” address and the alias name). | |
| SOL | Solana’s native token; all postage, fees, and rent are denominated in it (in lamports). | |
| campaign | A bountied outreach to opted-in participants: an advertiser reaches wallets that published a participation beacon, paying postage and reply bounties. | |
| beacon | A wallet’s public opt-in sign: the coarse topics it will accept campaign mail on, reachable at the mailbox’s own default postage. |
The GUI clients
The four shipped end-user clients. These are drawn in the same monochrome line style as the rest of the set rather than as vendor logos: the icons render as CSS masks in the surrounding text color (see How it renders), so a brand mark would lose the very colors that make it a brand — and a filled logo silhouette would sit oddly beside fifteen stroked glyphs. Each is an original mark evoking the client, used to refer to that product.
| Icon | Client | Meaning |
|---|---|---|
| webmail | The browser webmail PWA — a full client with no install, signing in the page via wasm. | |
| Chrome | The Chrome extension, which adds SithBit panes to the browser. | |
| Outlook | The Outlook add-in, which brings the same panes into Outlook. | |
| Thunderbird | The Thunderbird add-on, over the same shared Alpine + wasm panes. |
Why these terms
Most of the domain terms were picked from how often each word actually appears across the book, so the icons buy the most scanning value. The table below counts distinct Markdown pages (out of the 66 content pages measured when the set was chosen) that mention each term; the count also scoped the book-wide application sweep that followed — the higher the count, the more pages that sweep touched.
That table is a dated snapshot, and it is frozen on purpose. Its figures were measured when the set was chosen, and they landed with this page on 2026-07-12, at docs v0.2.1, against the book as it stood that day — the denominator its header states. No row of it has been re-measured since, and none is re-measured here: the book has grown a long way past that denominator, so refreshing some rows and not others would leave a table mixing two of them, which states nothing. Read it as evidence from that date for how the core of the set was fixed, not as a count of the book you are reading now. Terms measured after it was taken are stated separately, against the book of their own day instead — which is why the campaign and beacon figures below carry a date and a docs version of their own. This dating was recorded on 2026-08-16 at docs v0.73.16 and moved no figure: the date was what was missing, not the measurement.
Frequency is a reason to admit a term, not the only one. A term carries an icon because it recurs widely enough across the book to be worth marking, or because admitting it was a deliberate editorial call — and the paragraphs below are the record of the calls. There is no page count that cleanly separates the icons from every other word: the table’s own bottom rows sit below a third of the 66 pages — POP and IMAP at 19, SOL at 17, daemon at 12 — and carry icons regardless. Read the table as the ranking that fixed the core of the set, not as the rule that admits each member of it. It is not the membership list either: stamp, pin and IPFS were all measured after it was taken, and all three clear a third of the book when re-measured — stamp and pin on 40 of today’s 106 pages each, IPFS in the note below.
The client icons are the largest of the deliberate admissions. They enter as a complete set of four, not on frequency: webmail (28 pages), Thunderbird (24) and Outlook (23) would each qualify on their own, but Chrome (10) would not. Admitting three of four would be worse than admitting none, because the icons answer “which clients does this apply to?” — and a client that is silently unmarked reads as excluded rather than merely less common. The set is closed at the four clients that ship; a fifth would join only by shipping.
campaign and beacon are deliberate admissions as well, and no frequency
argument is being made for either. Both post-date the table, so their figures
are a measurement of their own, taken on 2026-08-16 at docs v0.73.15:
campaign appeared on 13 content pages and beacon on 10, out of the 106 the book
then held. Reproduce either term from the repository root with
grep -rlwi <term> mail_docs/src --include='*.md' --exclude=SUMMARY.md | wc -l,
and the denominator with
find mail_docs/src -name '*.md' ! -name SUMMARY.md | wc -l — SUMMARY.md is
excluded from both because it is a table of contents rather than a content page,
which is the convention every count on this page is stated in. The -w is what
makes those numbers mean what they say: it matches whole words only, so beacon
does not match beacons and POP does not match POP3.
Like the table, that is a snapshot rather than a live tally, and
like the table it is not why either term carries an icon — both figures sit an
order below the terms the table ranks. They carry icons because marking them was
judged worth it wherever they appear, which is a call, not a count; this
paragraph is where that call is recorded.
| Term | Pages (of 66) |
|---|---|
| 60 | |
| address | 51 |
| mailbox | 48 |
| domain | 46 |
| alias | 40 |
| message | 40 |
| postoffice | 28 |
| frombox | 24 |
| postmaster | 24 |
| hash | 23 |
| POP | 19 |
| IMAP | 19 |
| SOL | 17 |
| daemon | 12 |
IPFS joined later, and on frequency — not as one of those calls. It is absent from the table above only because the table is that one snapshot: re-measuring it against a book that has since grown to 106 Markdown pages would move every row, and a table mixing two denominators states nothing. Measured on its own with the same command, IPFS appears on 47 of those 106 pages (44%) — a third again past the one-third mark, and squarely inside the range the terms above occupy when they are re-measured the same way. The glyph is drawn on the same terms as the client icons: a line trace of the mark’s isometric-cube hexagon rather than the real logo, whose several tones would flatten to one under the mask, and without the logo’s inner ring of cubes, which closes into a smudge at inline size.
How it renders
The icons are not <img> elements. Each icon-*.svg is used as a CSS
mask-image, and the masked shape is painted with background-color: currentColor. Because the glyph takes the surrounding text color, it adapts to
the light and dark mdBook themes automatically, with no per-page markup. Each
icon is sized to roughly 1em and sits on the text baseline, so it flows inline
with the words around it.
Program & PDA reference
This is a reference page — for behavior and pricing, see the topic pages and Economics. It’s here for readers who want to see exactly what’s on-chain.
Program IDs
| Program | Address |
|---|---|
| Mail program | MaiLyqjRuHp8SSQHjiLMPmhBcuLitSta4YdoTiibXu4 |
| Alias program | ALiasg6qDnwcY8HfyeC1AjXRFjyqpXxW4omtwF1i125q |
| Domain program | DmaiNcmXsPw2juV9JoZSC47V5epAysQi3DJVk3fiBuUv |
The domain program carries the whole domain registry — domain lifecycle, DNSSEC-proof authorization/reclaim, and the domain marketplace — split out of the mail program (which originally hosted those instructions; see the retired discriminants below). The postoffice singleton stays a mail-program account: the alias and domain programs read it cross-program for the fees, the root KSK fingerprint, and the delegate gate.
PDA seeds
| Seed constant | Bytes | Owning program |
|---|---|---|
POSTOFFICE_SEED | postoffice | |
MAIL_MESSAGE_SEED | emailmessage | |
PUB_ENCRYPTION_KEY_SEED | encryption_key | |
FROMBOX_SEED | frombox | |
PARTICIPANT_BEACON_SEED | participant_beacon | |
PENDING_MAILBOX_CLOSE_SEED | pending_mailbox_close | |
SENDER_REPUTATION_SEED | sender_reputation | |
MAIL_DOMAIN_SEED | maildomain | domain |
PENDING_DEACTIVATION_SEED | pending_deactivation | domain |
PENDING_RECLAIM_SEED | pending_reclaim | domain |
PROOF_WITNESS_SEED | proof_witness | domain |
DOMAIN_LISTING_SEED | domain_listing | domain |
SENDER_ATTESTATION_SEED | sender_attestation | domain |
ALIAS_ESCROW_SEED | alias_escrow | alias |
ALIAS_LISTING_SEED | alias_listing | alias |
ALIAS_BID_SEED | alias_bid | alias |
Mailbox and alias accounts don’t use a named seed constant — they derive
directly from the owner’s wallet address (mailbox) or the alias name’s
blake3 hash (alias); the participant beacon combines
PARTICIPANT_BEACON_SEED with the owner’s wallet address, so each wallet
carries at most one beacon and it can never collide with the bare-seeded
mailbox. The sender-reputation account — the record behind
reputation-scaled first-contact
pricing —
likewise combines SENDER_REPUTATION_SEED with the sender’s wallet
address: one cumulative-spend record per wallet, owned by the mail
program. The mailbox close timelock’s
transient pending account combines PENDING_MAILBOX_CLOSE_SEED with that
same wallet address, and the prefix is doubly load-bearing there: the
Mailbox PDA is bare-seeded on the address, and PendingMailboxClose,
PendingDeactivation and PendingReclaim all serialize to the same eight
bytes — so a size-filtered account scan cannot tell the three apart and
only the derivation can. The alias-transfer escrow and
alias-listing accounts combine ALIAS_ESCROW_SEED / ALIAS_LISTING_SEED
with that same blake3 alias hash; the domain-listing account combines
DOMAIN_LISTING_SEED with the domain name’s blake3 hash, and the
deactivation timelock’s transient
pending-deactivation account likewise combines
PENDING_DEACTIVATION_SEED with the domain hash. Seeding a
listing on the asset itself means each alias or domain can carry at most
one listing at a time, structurally. The verified-sender
attestation combines
SENDER_ATTESTATION_SEED with the domain’s blake3 hash and the
attested wallet address — two variable seeds, so a domain carries one
attestation per wallet, any number of wallets. Every domain-side account —
MailDomain itself, the listing, the two timelock accounts, the
proof-witness buffer, and the sender attestation — derives under and is
owned by the domain program.
MailInstruction variants
Each variant is the on-chain instruction a sithbit command ultimately
submits:
| Instruction | Emitted by |
|---|---|
SendMail | mail send |
DeleteMail | mail delete |
CreateMailbox | mailbox create (with --for, a sponsored create: the payer must be the named domain’s authority, and the owner rides the instruction data) |
UpdateMailbox | mailbox update |
CreateFrombox | frombox stamp / frombox update (create-if-absent). Carries the purchaser’s optional max_price_lamports slippage ceiling, checked against the effective first-contact price; over-ceiling refuses with custom error 107 PriceExceedsMax |
UpdateFrombox | frombox update |
AddStamps | frombox stamp — same optional max_price_lamports ceiling, here checked against the frombox’s stored required_postage (error 107) |
InitPostoffice | postmaster init |
InstallCommitment | postmaster commitment (rides the variant position TransferPostmaster held before the delegation cutover — same discriminant, new ownership semantics) |
SetMailboxKey | mailbox key set |
WithdrawPostoffice | postmaster withdraw |
CloseFrombox | frombox close |
CloseMailbox | (disabled since v0.26.0 — refuses with custom error 94 InstantCloseDisabled; discriminant 16 kept so indexers resolve history) |
CloseKey | mailbox key close |
SetStampFee | postmaster fee stamp |
RefundMail | mail refund (recipient refuses; postage + rent go back to the sender) |
SetDomainFee | postmaster fee domain (a postoffice mutation, so it stays mail-side despite the name) |
SetAliasFee | postmaster fee alias |
SetAliasTierFees | postmaster fee alias-tiers (the four premium claim fees for 1–4 character names; each slot capped, over-cap refuses with custom error 98 AliasTierFeeAboveCap) |
SetRootKsk | postmaster ksk set (likewise a postoffice mutation — the anchor the domain program reads cross-program) |
ClaimBounty | mail claim-bounty |
RefundBounty | mail refund-bounty |
RotateDelegate | postmaster delegate |
AdminCloseAccount (discriminant 38) | postmaster reclaim (delegate-signed devnet/reset tool, --features reclaim on the CLI and the program — a launch build compiles the handler out and refuses the instruction with custom error 108 AdminCloseDisabled); a reclaim-capable build still refuses a target holding value above its rent-exempt minimum with custom error 106 AdminCloseEscrowPresent |
CreateParticipantBeacon (39) | campaign create — participant-pool opt-in (item 43); owner-signed, requires the wallet’s mailbox |
UpdateParticipantBeacon (40) | campaign update — wholesale profile rewrite (tags + sealed-detail CID; None clears the CID) |
CloseParticipantBeacon (41) | campaign close — opt-out: closes the beacon, refunding its rent to the owner |
RequestCloseMailbox (42) | mailbox close — opens the close timelock; mailbox stays open, no rent refunded |
CancelCloseMailbox (43) | mailbox close --cancel — drops the pending record, refunding its rent |
FinalizeCloseMailbox (44) | mailbox close --finalize — past the timelock, closes the mailbox; the pending account’s rent refunds to the owner and the mailbox’s rent to its recorded funder (a 4th funder account is required when it differs from the owner) |
SetSettlementBps (46) | postmaster fee settlement (both settlement bps rates in one instruction: the operator share of settled value, over-cap refuses with custom error 99 OperatorShareBpsAboveCap, and the stamp-purchase fee rate, error 100 StampFeeBpsAboveCap; a zero rate stores the “unset” sentinel and resolves to its protocol default) |
SetSenderAttestationFee (47) | postmaster fee attestation (the flat one-time verified-sender attestation fee; over-cap refuses with custom error 101 SenderAttestationFeeAboveCap, and a zero fee stores the “unset” sentinel and resolves to the protocol default; the setter grows a legacy postoffice account to the 184-byte layout in place) |
SetReputationFloor (48) | postmaster fee reputation-floor (the floor of reputation-scaled first-contact pricing, in bps of a mailbox’s default postage; over-cap refuses with custom error 102 ReputationFloorAboveCap, a zero rate stores the “unset” sentinel and resolves to the protocol default, and the setter grows a legacy postoffice account to the 192-byte layout in place) |
CreatePinLease (49) | mail lease create — mints the per-(CID, holder) pinning-lease account with the deposit escrowed above its rent (below-minimum refuses with custom error 104 PinLeaseDepositBelowMinimum); the program hashes the passed message’s CID into the lease derivation itself, so a mismatched lease address refuses with 17 InvalidDerivedAccount, and the one-time fee splits with the recipient’s domain authority at the operator share |
ClosePinLease (50) | mail lease close — holder-signed drain returning deposit + rent; carries the CID hash rather than a message id, so it works after the message account itself has settled and deallocated |
SetPinLeaseFee (51) | postmaster fee pin-lease (the one-time lease creation fee; over-cap refuses with custom error 103 PinLeaseFeeAboveCap, a zero fee stores the “unset” sentinel and resolves to the protocol default, and the setter grows a legacy postoffice account to the 200-byte layout in place) |
ReclaimFromboxStamps (52) | frombox reclaim — the sender’s withdrawal of its own unspent prepaid postage. Payload-free: the program recomputes the “from” hash from the signer’s address bytes, so reproducing the frombox derivation is the authorization and no separate authority field exists. Drains the balance above rent and zeroes the stamp count, leaving the account alive on its rent; a frombox keyed on an email string hashes text no wallet key can reproduce, so it stays recipient-managed |
Retired mail-side domain discriminants
The sixteen domain-registry instructions originally lived in the mail
program and moved wholesale to the domain program. Their
variants stay in the MailInstruction enum — the borsh discriminant
is wire ABI, so removing or reordering them would renumber every later
instruction — but the mail program no longer carries their processors.
Sending one of these discriminants to the mail program is rejected with
error 84 (InstructionMoved, “This instruction has moved to the
domain program”):
| Retired discriminants | Instructions |
|---|---|
| 9–12 | CreateDomain, DeactivateDomain, CloseDomain, TransferDomainAuthority |
| 22–24 | RequestDeactivateDomain, CancelDeactivateDomain, FinalizeDeactivateDomain |
| 26–28 | AuthorizeDomainByProof, WriteProofWitness, CloseProofWitness |
| 31–33 | ListDomain, BuyDomain, CancelDomainListing |
| 35–37 | RequestReclaimByProof, FinalizeReclaimByProof, CancelReclaimByProof |
SetDomainFee (17) and SetRootKsk (25) are not in the retired
set: despite their domain-flavored names they mutate the postoffice —
a mail-program account — and stay mail-side.
DomainInstruction variants
The domain program’s enum, borsh discriminants 0–18. The CLI surface did
not change with the split — the same sithbit domain commands now
submit these to the domain program, with account-meta lists
byte-identical to the retired mail-side twins (discriminants 17–18, the
verified-sender attestation pair, postdate the split and never had
mail-side twins):
| Instruction | Emitted by |
|---|---|
CreateDomain | domain create |
DeactivateDomain | domain deactivate --false (instant reactivation) |
CloseDomain | domain close |
TransferDomainAuthority | domain transfer |
RequestDeactivateDomain | domain deactivate (opens the two-step timelock) |
CancelDeactivateDomain | domain deactivate --cancel |
FinalizeDeactivateDomain | domain deactivate --finalize |
AuthorizeDomainByProof | domain authorize (permissionless DNSSEC-proof authorization) |
WriteProofWitness | domain authorize / domain reclaim (stages the RRSIG-chain witness buffer) |
CloseProofWitness | domain authorize --close-witness (reclaims a leftover witness buffer) |
ListDomain | domain sell |
BuyDomain | domain buy |
CancelDomainListing | domain sell --cancel |
RequestReclaimByProof | domain reclaim (opens the reclaim timelock by DNSSEC proof) |
FinalizeReclaimByProof | domain reclaim --finalize — permissionless to crank, but the pending record stores the wallet that funded the request and the rent refund is pinned to it, so a stranger turning the crank cannot capture the requester’s deposit |
CancelReclaimByProof | domain reclaim --cancel |
AdminCloseAccount (discriminant 16) | postmaster reclaim (delegate-signed devnet/reset tool, --features reclaim on the CLI and the program); same gating and value guard as its mail-side twin — launch builds refuse with error 108 AdminCloseDisabled, over-rent targets with error 106 AdminCloseEscrowPresent |
AttestSender (17) | domain attest-sender (permissionless DNSSEC-proof verified-sender attestation; the attested wallet rides the payload, no MailDomain account is involved, and the one-time fee lands on the postoffice) |
RevokeSenderAttestation (18) | domain revoke-attestation (holder-signed close of the (domain, wallet) attestation; the PDA re-derives from the signer, so a non-holder never reaches it, and the rent refunds to the attested wallet) |
Domain instructions authenticate against the mail program’s postoffice read cross-program: the delegate gate, the authorization fee, and the root KSK all come from that account, and fees still settle into it — the split moved the registry, not the treasury.
Postoffice admin gates
Since the delegation cutover the postoffice stores no postmaster pubkey — only the standing delegate wallet and a 32-byte ownership commitment root (see The Postmaster). The admin instructions — across all three programs — split accordingly:
- Delegate-gated (operational): every domain-program instruction
including
CreateDomain(the deactivation two-step,CloseDomain,TransferDomainAuthority, …), the mail-side fee setters (SetStampFee,SetDomainFee,SetAliasFee,SetAliasTierFees,SetSettlementBps,SetSenderAttestationFee,SetReputationFloor,SetPinLeaseFee— everypostmaster feecommand),SetRootKsk, the alias program’s fee-waived bulk reservation. A non-delegate signer is refused with code 66 (NotDelegate). The threeAdminCloseAccountreclaim twins are delegate-gated too, with their own refusal code (AdminCloseUnauthorized) — and they exist only in builds compiled with thereclaimfeature; a launch build refuses the instruction outright with code 108 (AdminCloseDisabled). On top of the authority check, a value guard: a target holding lamports above its rent-exempt minimum is refused with code 106 (AdminCloseEscrowPresent), so the tool reaps only rent-empty leftover state and can never seize escrowed postage, a bounty, or a live bid. - Ownership-gated (chain-key proof):
InstallCommitment,WithdrawPostoffice, andRotateDelegate. The signer is a revealed ceremony chain key presenting a Merkle membership path against the current commitment root, and the instruction installs the successor generation’s root (rotate-on-use). A proof that does not verify is refused with code 67 (InvalidCommitmentProof).
One naming footnote for error readers: the account-slot error
PostmasterAccountInfo (code 9) now labels the delegate/chain-signer
account slot — the variant name is kept for ABI stability (the enum’s
numeric position is the on-chain error code, so variants are never
renamed in place).
AliasInstruction variants
| Instruction | Emitted by |
|---|---|
CreateAlias | alias create (and automatically by mailbox create) — charges the length-tiered claim fee: the premium tier for 1–4 character names, the flat fee from 5 up; waived for the delegate |
TransferAlias | (disabled since v0.7.0 — refuses with custom error 85 UnilateralTransferDisabled; discriminant kept for history) |
CloseAlias | alias close |
OfferTransferAlias | alias transfer init (--fee defaults to 0 — a free hand-off) |
AcceptTransferAlias | alias transfer accept |
CancelTransferAlias | alias transfer cancel |
ListAlias | alias sell |
BuyAlias | alias buy |
CancelAliasListing | alias sell --cancel |
SellAlias | alias sell --auction (opens an ascending-bid auction) |
BidAlias | alias bid |
SettleAuction | alias settle-auction |
AdminCloseAccount (discriminant 12) | postmaster reclaim (delegate-signed devnet/reset tool, --features reclaim on the CLI and the program); same gating and value guard as its mail-side twin — launch builds refuse with error 108 AdminCloseDisabled, over-rent targets with error 106 AdminCloseEscrowPresent |
Removed domain-scoped alias discriminants
Discriminants 13–15 were RegisterDomainAlias, RemoveDomainAlias, and
UpdateDomainAlias — a per-domain alias namespace, removed outright because
it let a domain authority capture mail for a global alias holder resident on
that domain (see the threat model).
Unlike the retired mail-side domain
discriminants above, these were
deleted rather than kept as tombstones: they were the tail of
AliasInstruction, so removing them renumbered nothing. Their error codes
81 (DomainAliasAccountInfo), 82 (DomainAuthorityMismatch), and
83 (NoDomainAlias) do remain in SithBitError — those sit mid-enum,
where the numeric position is the on-chain code, so deleting them would
renumber every error below.
Trap for the next append. The next variant added to
AliasInstructioninherits discriminant 13. A replayed pre-removalRegisterDomainAliastransaction would then decode as that new instruction. The payloads differ, so borsh will almost certainly reject it — but confirm no such transaction can still be replayed against a live cluster before appending, or burn 13–15 with placeholder variants first.
Marketplace listing accounts
An open marketplace listing is staged in a
program-owned account — AliasListing (alias program) or DomainListing
(domain program) — created by ListAlias/ListDomain and closed when the
listing completes. Both share one fixed 56-byte borsh layout, so a
listing’s rent is a constant and replacing a listing in place never
changes it:
| Field | Bytes | Meaning |
|---|---|---|
holder | 32 | The seller — the alias’s current holder, or the domain’s current authority; paid on purchase, and the only address that may cancel. |
price_lamports | 8 | The fixed price any buyer pays. |
created_at | 8 | Unix time the listing was staged; anchors the binding window. |
expires_at | 8 | Unix time the listing lapses; purchase is legal through this instant, refused after. |
Six pre-existing instructions grew a read-only listing account at
the tail of their account lists to guard against conflicting state:
CloseAlias and CloseDomain refuse while a listing is open (reaping the
asset would strand the listing’s rent), OfferTransferAlias refuses
while a listing is open (an alias can’t carry both a private transfer
offer and an open listing — and since v0.7.0 the offer is the only alias
transfer path), TransferDomainAuthority refuses while a listing is
open (repointing a name under a live listing would leave a stale holder
recorded as the seller, able to collect the sale proceeds — the holder
must cancel first; the alias-side TransferAlias carried the same guard
until it was disabled outright in v0.7.0), and RequestDeactivateDomain
refuses while a listing is open (a deactivation
timelock finalizing under a live
listing would flip the domain’s state under the buyer — the authority
must cancel, or the sale complete, first). In each case the listing slot
must be an uninitialized PDA for the instruction to proceed. BuyDomain carries the mirror-image guard:
a read-only pending-deactivation slot at the tail of its account
list, which must be uninitialized — a purchase is refused while a
deactivation timelock is in flight.
BuyDomain also re-checks, against the mail-domain account it already
carries, that the domain is still active at the moment of purchase:
an inactive domain stays listable (the flag is stable and plainly
readable on-chain), but the sale settles only once the domain is
reactivated — so a deactivation finalized under a live listing (reachable
only in pre-guard history) can never sell a dead name.
BuyAlias needs no extra slot for its guard: it re-checks the listing’s
recorded holder against the alias account it already carries.
Marketplace account lists
The full account lists of the guard-reshaped mutation instructions, in slot order (the CLI’s builders pin these byte-for-byte):
| Instruction | Accounts, in slot order |
|---|---|
TransferDomainAuthority (4) | delegate (signer, writable) · postoffice (readonly) · mail domain (writable) · domain listing (readonly) |
RequestDeactivateDomain (7) | delegate (signer, writable) · postoffice (readonly) · mail domain (readonly) · system program (readonly) · pending deactivation (writable) · rent payer (signer, writable) · domain listing (readonly) |
BuyDomain (9) | buyer (signer, readonly) · system program (readonly) · mail domain (writable) · domain listing (writable) · current authority (writable) · postoffice (writable) · price payer (signer, writable) · pending deactivation (readonly) · pending reclaim (readonly) |
TransferAlias (5) | holder (signer, writable) · alias (writable) · postoffice (writable) · system program (writable) · alias listing (readonly) |
BuyAlias (7) | buyer (signer, readonly) · system program (readonly) · alias (writable) · alias listing (writable) · current holder (writable) · postoffice (writable) · price payer (signer, writable) |
A self-funded buy passes the buyer again in the price-payer slot; the runtime merges the duplicate metas into one writable signer.
Marketplace error codes
SithBitError variants convert to ProgramError::Custom(code), with the
variant’s numeric position as the on-chain code (the enum is append-only
for exactly this reason). The marketplace tail:
| Code | Variant | Message |
|---|---|---|
| 57 | ListingPriceZero | A listing’s price must be positive; zero-price hand-offs use the transfer paths |
| 58 | ListingExpiryInvalid | A listing’s expiry must be in the future |
| 59 | ListingStillBinding | The listing is still in its binding window |
| 60 | NoListingForAlias | No listing is open for this alias |
| 61 | NoListingForDomain | No listing is open for this domain |
| 62 | ListingExpired | The listing has expired |
| 63 | AliasHasPendingListing | The alias has an open listing; cancel it before closing or transferring |
| 64 | DomainHasPendingListing | The domain has an open listing; cancel it before closing, transferring, or deactivating |
| 65 | ListingHolderMismatch | The listing holder no longer owns the listed name |
BuyDomain’s pending-deactivation refusal reuses code 29
(DeactivationAlreadyPending, “A deactivation is already pending for
this domain”) — the same error RequestDeactivateDomain and
ListDomain raise; the error enum is append-only, so guards reuse
existing codes where one fits. On the same principle,
RequestDeactivateDomain’s open-listing refusal reuses code 64
(DomainHasPendingListing), and BuyDomain’s inactive-domain refusal
reuses code 25 (InactiveDomain, “Domain is not registered or is
deactivated”).
All three programs share the single SithBitError enum, so a given code
means the same thing whichever program raised it. The program split
appended one variant:
| Code | Variant | Message |
|---|---|---|
| 84 | InstructionMoved | This instruction has moved to the domain program |
Raised by the mail program for the sixteen retired domain discriminants.
The participant-beacon tail (item 43):
| Code | Variant | Message |
|---|---|---|
| 86 | ParticipantBeaconAccountInfo | Failed to retrieve participant-beacon account info |
| 87 | ParticipantBeaconAlreadyExists | The wallet already has a participant beacon |
| 88 | ParticipantBeaconNotInitialized | No participant beacon exists for this wallet |
| 89 | ParticipantBeaconRequiresMailbox | A participant beacon requires the wallet’s mailbox to exist |
| 90 | DetailCidTooLong | The sealed-detail CID exceeds the maximum length |
The mailbox close timelock tail — the mailbox-scoped twins of the domain-deactivation errors, appended as one block:
| Code | Variant | Message |
|---|---|---|
| 91 | MailboxCloseAlreadyPending | A close is already pending for this mailbox |
| 92 | NoPendingMailboxClose | No close is pending for this mailbox |
| 93 | MailboxCloseTimelockNotElapsed | The mailbox-close timelock has not yet elapsed |
| 94 | InstantCloseDisabled | Instant CloseMailbox is disabled; use RequestCloseMailbox and FinalizeCloseMailbox after the timelock elapses |
Code 94 gets its own variant rather than reusing a close error, mirroring
item 30’s UnilateralTransferDisabled (85): a client hitting a disabled
instruction needs to hear that, not be told to open a pending close it
cannot then finalize instantly.
The sponsored-mailbox errors (payer ≠ owner creates and their funder-refund close) follow:
| Code | Variant | Message |
|---|---|---|
| 95 | SponsoredMailboxRequiresDomain | A sponsored mailbox (payer ≠ owner) must name a domain |
| 96 | UnauthorizedDomainSponsor | Only the named domain’s on-chain authority may sponsor a mailbox for another owner |
| 97 | FunderAccountInfo | Failed to retrieve the funder account the mailbox rent refunds to |
The verified-sender attestation appended one variant (its fee-cap twin of codes 98–100, which are noted inline on their setters’ instruction rows above):
| Code | Variant | Message |
|---|---|---|
| 101 | SenderAttestationFeeAboveCap | The sender-attestation fee exceeds its protocol cap |
Reputation-scaled first-contact pricing appended one more, the floor setter’s cap twin:
| Code | Variant | Message |
|---|---|---|
| 102 | ReputationFloorAboveCap | The reputation floor exceeds its protocol cap |
A purchase presenting a present-but-invalid attestation account in the
reputation tail is refused rather than silently repriced — wrong owner
raises the runtime’s IllegalOwner, an attestation for a different wallet
raises code 19 (WrongAddressForInstruction), and a wrong derivation
raises code 17 (InvalidDerivedAccount) — codes clients can match on.
Pinning leases added a missing-account code alongside the two fee codes noted inline above, the money-path hardening added two more — one guarding the admin reclaim tool, one the purchaser’s slippage ceiling — and the reclaim compile-out added the launch-build refusal:
| Code | Variant | Message |
|---|---|---|
| 105 | PinLeaseAccountInfo | The pin-lease account is missing from the instruction |
| 106 | AdminCloseEscrowPresent | The admin-close target still holds escrow above its rent-exempt minimum |
| 107 | PriceExceedsMax | The stamp price exceeds the purchaser’s maximum |
| 108 | AdminCloseDisabled | Admin-close is not compiled into this build (reclaim feature off) |
Code 106 is the reclaim-capable build’s value guard: in all three
programs AdminCloseAccount refuses any target whose balance sits above its
rent-exempt minimum, so the reclaim tool can only reap rent-empty leftover
state — never a sender’s escrowed postage, a reply bounty, or a live auction
bid. That guard cannot make the tool safe against live accounts, though: a
live mailbox, alias, or domain normally holds exactly its rent-exempt
minimum, indistinguishable on-chain from leftover state. Code 108 is the
answer to that: a build compiled without the reclaim feature carries no
admin-close handler at all and refuses the instruction outright, so a launch
build simply has no instruction that can destroy a user’s account
(see the reclaim lifecycle).
Code 107 is the purchaser’s side of the 106 principle: the recipient sets
the price and may raise it at any moment, so a buyer who supplied a ceiling
gets a revert instead of a silent overpayment.
Apple Mail extensibility (MailKit)
SithBit ships integrations for the hosts that let a third party extend them — the Thunderbird extension, the Outlook add-in, and the Chrome extension. Apple Mail is the most-used mail client by a wide margin, so it is worth recording exactly what its extension surface allows, and why there is no Apple Mail client here today.
The supported path: MailKit
Since macOS 12 (Monterey), the only sanctioned way to extend Apple Mail is a MailKit app extension — a bundle shipped inside an ordinary macOS app that Mail loads out of process over XPC, so it cannot crash Mail or read its internals. The surface is exactly four extension points, and nothing more:
| Extension point | What it can do |
|---|---|
MEComposeSessionHandler | Inspect and annotate an outgoing message; add headers; block the send |
MEMessageActionHandler | Act on an incoming message — move, flag, set a colour |
MEContentBlocker | Block remote content (scripts, styles, images) in the message view |
MEMessageSecurityHandler | Sign, encrypt, and decrypt message bodies — the S/MIME-style crypto hook |
The old mail bundles — undocumented plug-ins that loaded straight into Mail’s own process and could do essentially anything — were removed entirely in macOS 14 (Sonoma). MailKit is now the only path, on macOS.
Why there is no Apple Mail client (yet)
The interesting hook for SithBit is MEMessageSecurityHandler: it is the
defined place to plug in a custom decryption scheme, so a MailKit extension
could in principle unseal a SithBit sealed body inline as
Mail renders it. Two constraints keep it off the near-term roadmap:
- macOS only. There is no MailKit on iOS or iPadOS — the Apple client that dominates the mobile open statistics is not extensible at all. The iPhone story stays the standalone webmail app, not a Mail extension.
- No arbitrary UI. A MailKit extension gets the four points above, not the
free rein the old bundles had. SithBit’s other clients share one rich
account-management pane (
webclients/shared/); Apple Mail cannot host that pane, so it would be a separate, much thinner integration — decrypt-on-display and little else.
In short. A read-only “unseal my SithBit mail in Apple Mail on the Mac” extension is technically possible via
MEMessageSecurityHandler; a full-featured Apple Mail client, or anything on iPhone/iPad, is not.
See Apple’s MailKit documentation for the framework reference and Build Mail app extensions for the extension-point walkthrough.
Brand & identity
SithBit has one visual identity shared across every surface: the mdBook docs, the four web shells (webmail, marketplace, onboarding, and this book), and the two browser plugins (Thunderbird, Outlook). This page records the palette, the logo, and where the design tokens live so the look stays consistent as the clients evolve.
Palette — “dark-side”
| Token | Light | Dark | Use |
|---|---|---|---|
| background | #FAFAFB | #0B0B0F | page background |
| surface | #FFFFFF | #16161D | cards, compose, popups |
| text | #1A1A1F | #E7E7EA | body text |
| primary (violet) | #6D28D9 | #7C3AED | links, primary buttons, selection |
| accent (crimson) | #E11D48 | #E11D48 | emphasis only — used sparingly |
The dark theme is the signature look; the light theme keeps the same violet primary on a near-white background.
Logo & mark
- Mark — a geometric
Smonogram (an envelope-flap chevron folded into the letterform) on a dark rounded tile, with a single crimson “sealed” spark. It is the favicon, the plugin/extension icon at every size, and sits left of each client’s header title. - Wordmark — the mark plus a lowercase geometric
sithbitin the brand violet, for the full lockup.
Where the tokens live (single source of truth)
The palette is defined once as CSS custom properties and consumed everywhere:
webclients/shared/brand.css— the--sb-*tokens the web shells and plugins consume (each value alight-dark()pair, so one reference works in both themes). The canonical artwork lives beside it inwebclients/shared/brand/(mark.svg,logo.svg, favicon PNGs).mail_docs/css/brand.css— the same hex values mapped onto mdBook’s per-theme CSS variables (violet accent for the light.light/.rustthemes; the full dark-side palette for the.coal/.navy/.ayudark themes). mdBook can’t reachwebclients/, so the values are mirrored, not shared — keep the two files in sync when the palette changes.
The plugin/favicon PNGs are rasterized from mark.svg (via cairosvg); the
hermetic webclients/shared/test/brand.test.js guards the token set, the SVG
well-formedness, and the favicon sizes.
Manifest-majors dependency audit — 2026-07
Superseded by the 2026-08 audit. That pass read “latest” from the live crates.io sparse index rather than the local index cache this one used, covers all 121 workspace entries rather than only the major-version-relevant ones, and adds the MSRV dimension this audit has no coverage of — the on-chain
build-sbftoolchain is nine Rust versions behind the host, so an MSRV bump can break the programs while the workspace gate stays green. The rows below are kept as-written: they are the evidence their verdicts were reached on, not a live status board.
Scope: an assessment (not a bump) of every major-version-relevant entry in the
root Cargo.toml [workspace.dependencies]. It answers, per dependency: is a
major bump available, and is it warranted right now? No manifest or lockfile
was changed by this audit — the actual bumps are a deferred downstream wave.
Method: resolved versions read from Cargo.lock; “latest published” read from
the local crates.io index cache (~/.cargo/registry/index) — so “latest” here
means latest the index has seen at last fetch; entries flagged (offline?)
could not be confirmed against a live registry. cargo-outdated is unusable in
this tree (the azure_core_legacy package alias plus the yanked core2 0.4.0
transitive via ipfs-cid/cid 0.10 in mail_client make it error out), so
every row below was derived manually.
Not wired into
SUMMARY.mdto avoid colliding with the concurrent docs lanes. It can be linked later under Appendix: Reference (alongsideappendix/program-reference.md) if a permanent home is wanted.
Verdict legend
- bump-now — safe, low blast radius, warranted this cycle.
- assess-later — a real major exists and is worth taking, but needs its own scoped adaptation wave (API churn), not a drive-by bump.
- blocked — cannot move until an upstream event (no action possible now).
- hold — already at latest, or intentionally pinned; no bump wanted.
Summary table
| Dependency | Manifest | Resolved | Latest (index) | Verdict | Blast radius |
|---|---|---|---|---|---|
| opentelemetry | 0.32 | 0.27.1 | 0.33.0 | assess-later | mail_observe only (1 file) |
| opentelemetry-otlp | 0.32 | 0.27.0 | 0.32.0 | assess-later | mail_observe only |
| opentelemetry_sdk | 0.32 | 0.27.1 | ~0.33 (train) | assess-later | mail_observe only |
| opentelemetry-semantic-conventions | 0.32 | 0.27.0 | 0.32.1 | assess-later | mail_observe only |
| tracing-opentelemetry | 0.33 | 0.28.0 | 0.33.0 | assess-later | mail_observe only |
| libp2p | 0.56 | 0.56.0 | 0.56.0 | hold | ipfs_swarm (already latest) |
| libp2p-stream | 0.4.0-alpha | 0.4.0-alpha | 0.4.0-alpha | hold | ipfs_swarm (latest alpha, pinned by design) |
azure_core_legacy (azure_core 0.21) | 0.21 | 0.21.0 | 1.1.0 (GA line) | blocked | mail_store azure tables backend |
| azure_data_tables | 0.21 | 0.21.0 | 0.21.0 | blocked | mail_store azure tables backend |
| azure_storage | 0.21 | 0.21.0 | 0.21.0 | blocked | mail_store azure tables backend |
| azure_core (GA) | 1.1 | 1.0.0 | 1.1.0 | hold | GA blob/queue/kv stack (minor only) |
| azure_identity | 1.0 | 1.0.0 | 1.0.0 | hold | GA stack (already latest) |
| azure_storage_blob / _queue | 1.0 | 1.0.0 | 1.0.0 | hold | already latest GA |
| azure_security_keyvault_secrets | 1.0 | 1.0.0 | 1.0.0 | hold | key_source (already latest GA) |
| mail-auth | 0.11 | 0.11.0 | 0.11.1 | assess-later / pin now — superseded 2026-08-04: adapted, caret kept; see the per-dependency note | smtp_server, mail_spooler, mail_submit, mail_client |
| reqwest | 0.13 | 0.13.4 | 0.13.4 | bump-now (pin floor) | wide (host crates) |
| thiserror | 2 | 2.0.18 | 2.0.18 | bump-now (pin floor) | wide |
| serde | 1 | 1.0.228 | 1.0.x | bump-now (pin floor) | wide |
| anyhow | 1 | 1.0.103 | 1.0.x | bump-now (pin floor) | wide |
| aes-gcm | 0.11 | 0.11.0 | 0.11.0 | bump-now (pin floor) | mail_crypto |
| hex | 0.4 | 0.4.3 | 0.4.3 | bump-now (pin floor) | wide |
| blake3 | 1 | 1.8.5 | 1.x | bump-now (pin floor) | program_common/solana_common |
| chrono | 0.4 | 0.4.45 | 0.4.x | bump-now (pin floor) | wide |
| base58 | 0.2 | 0.2.0 | 0.2.0 | bump-now (pin floor) | mail_client |
| num-traits | 0.2 | 0.2.19 | 0.2.19 | bump-now (pin floor) | model/programs |
| num-derive | 0.5 | 0.4.2 | 0.4.2 | bump-now (pin floor) | model/programs |
How to read the columns (their as-of dates differ). The Manifest column
is live: it tracks the root Cargo.toml [workspace.dependencies] as it stands
on 2026-08-04, after the bumps recorded in “Where this list stands” below. The
Resolved, Latest (index) and Verdict columns are the July 2026 audit
snapshot and are deliberately not refreshed — they are the evidence the
verdicts were reached on. Two consequences to expect while reading:
- Six rows — the five OpenTelemetry entries and GA
azure_core— now carry a Manifest requirement ahead of the Resolved version beside them, because those bumps have since been taken (audit items 3 and 4). - Eleven rows,
reqwestthroughnum-derive, carried a bare*when the audit was written; that is the hazard their “pin floor” verdict describes. The caret requirements shown are the fix, already landed (item 2), and for ten of the eleven the resolved version did not move — only the requirement’s notation did.
Per-dependency notes
OpenTelemetry stack (0.27 line + tracing-opentelemetry 0.28)
All five entries sit one coordinated release train behind by ~5–6 minor-major
steps: opentelemetry 0.27 → 0.33, -otlp 0.27 → 0.32, _sdk 0.27 → ~0.33,
-semantic-conventions 0.27 → 0.32.1, tracing-opentelemetry 0.28 → 0.33. In
OpenTelemetry’s 0.x SemVer each 0.x is a breaking major, and they only
inter-operate as a matched set (SDK, otlp, semantic-conventions, and the
tracing-opentelemetry bridge must all move together).
- Consuming surface is small but the API churn is not. The only workspace
consumer is
mail_observe/src/telemetry.rs(~150 lines, single file). But the 0.27→0.30+ API broke the exact symbols it uses:sdktrace::TracerProvider(renamedSdkTracerProvider),Resource::new(vec![KeyValue…])(replaced byResource::builder()),opentelemetry_sdk::runtime::Tokio+ thert-tokioruntime feature (the runtime seam was reworked / removed),PeriodicReader:: builder(exporter, Tokio), and theTraceError/MetricErrortypes (#[from]sources inObserveError) were restructured. - Coupled entry outside this manifest:
mail_observe/Cargo.tomlalso pinsopentelemetry-proto = "0.27"directly (not via workspace). It must move in lockstep with the workspace set. Flagging it here; the actual edit belongs to the bump wave, not this audit. - Duplicate old major is transitive, not ours.
Cargo.lockalso carriesopentelemetry 0.17.0+tracing-opentelemetry 0.17.4, pulled bytarpc 0.29.0(a transitive test/dev dep, reachable via the Solana program-test / surfpool stack). It is not manifest-addressable and is a harmless separate-major coexistence — the otel bump will not remove it.
Verdict: assess-later. Warranted (5+ majors behind, active telemetry crate)
but should be a dedicated small wave rewriting telemetry.rs against the target
train (pick the latest matched set: opentelemetry/_sdk/tracing-opentelemetry
0.33, -otlp 0.32, -semantic-conventions 0.32.1, -proto to match) with the
health/OTLP smoke path re-exercised.
libp2p 0.56 / libp2p-stream 0.4.0-alpha
The index cache tops out at libp2p 0.56.0 — i.e. the manifest is already on
the latest published release (offline? could not confirm a newer 0.57
against a live registry, but none is cached). libp2p-stream 0.4.0-alpha is
likewise the latest alpha, and is a recorded pinned-alpha adoption that rides
the exact libp2p-swarm/-core versions the lockfile holds. Verdict: hold.
No bump available/wanted; revisit only if a live check shows 0.57+.
Azure legacy 0.21 tables stack (blocked)
azure_data_tables 0.21 and azure_storage 0.21 are each the last published
release of the legacy pre-GA line — there is no newer major to take; the
successor is the not-yet-shipped Cosmos/Tables GA crate (planned ~late 2026).
azure_core_legacy is the intentional package = "azure_core", version = "0.21" alias these two drag in; the GA azure_core has since reached 1.1.0,
and the two majors coexist by design behind the mail_store trait seam.
Verdict: blocked for all three — no action until the GA tables crate ships;
re-open this row when it does.
- GA companions are effectively current:
azure_core 1.0has a minor 1.1.0 available (not a major — hold/optional);azure_identity,azure_storage_blob,azure_storage_queue,azure_security_keyvault_secretsare all at their latest 1.0.0 GA. Verdict: hold.
The *-versioned entries (aes-gcm, anyhow, hex, serde, thiserror, blake3, chrono, base58, num-traits, num-derive, reqwest)
Every * entry currently resolves to the latest published major already
(thiserror 2.x, serde 1.x, aes-gcm 0.11.0, reqwest 0.13.4, hex 0.4.3,
etc.), so there is nothing to bump up to. The finding here is the opposite: a
bare * grants Cargo license to adopt any future major on the next
cargo update, silently and non-reproducibly.
- This has already happened once in-tree:
reqwest = "*"resolved to the 0.13 major, while the rest of the graph (azure_core 0.21, solana-rpc-client 4.x, reqwest-middleware) still pullsreqwest 0.12.28. The*jumped a 0.x-major on its own. A futureserde 2.0orthiserror 3.0would be adopted the same way and could break the build for the next contributor who runscargo update, with no manifest signal that it was ever intended. - The duplicate
aes-gcm 0.10.3in the lock is transitive (viasnow 0.9.6, the Noise handshake under libp2p); our direct*is at 0.11.0. Not under our control. thiserror 1.0.69also coexists transitively; our direct*is 2.0.18.
Verdict: bump-now — but the “bump” is pinning a floor, not raising a version.
Replace each * with a caret floor at the current major so the lock stays
reproducible and surprise majors are opt-in:
aes-gcm = { version = "0.11", ... }
anyhow = "1"
hex = "0.4"
serde = { version = "1", features = ["derive"] }
thiserror = "2"
blake3 = "1"
chrono = "0.4"
base58 = "0.2"
num-traits = "0.2"
num-derive = "0.4"
reqwest = { version = "0.13", features = ["blocking"] }
No behavior change today (the lock already pins these exact versions); this only removes the silent-major hazard. Belongs to the downstream bump wave.
Outcome (2026-08-04): landed. All eleven entries carry a caret floor and the
Manifest column above reflects them. The one departure from the block: the
manifest took num-derive = "0.5" rather than the "0.4" proposed here, so
that row is a genuine major bump and not only a floor pin.
mail-auth — 0.11.0 vs 0.11.1 (the decision)
Manifest: mail-auth = { version = "0.11", default-features = false, features = ["ring", "report"] }. Lock: 0.11.0. 0.11.1 is published and
is an API-breaking patch (SemVer-illegal on a 0.11.x patch bump).
What actually broke in 0.11.1 (diffed from the two cached .crates): it lands
RFC 9989/9990/9991 DMARC support and, in doing so,
- removes
DmarcParameters::with_domain_suffix_fnfromdmarc/verify.rs— the organizational-domain folding hook the workspace relies on. It is called at three sites insmtp_server/src/policy.rs(lines ~393, ~547, ~1102), each.with_domain_suffix_fn(organizational_domain)for DMARC relaxed alignment (thepslpublic-suffix path). This is a hard compile break. - reworks the
report::Report/report/dmarcsurface (newDiscoveryenum; newwith_discovery_method/with_generator/with_npbuilders;npfield).mail_spooler/src/pipeline/dmarc_report.rsbuildsReportvia the fluentwith_*chain, so it must be re-validated against the new required/ optional fields even where it still compiles. - restructures
dmarc/mod.rs,dmarc/parse.rs,dmarc/verify.rsand the ARF parser — the verify semantics changed under RFC 9989.
Blast radius: mail-auth is a direct dep of four crates (smtp_server,
mail_spooler, mail_submit, mail_client); the DMARC verify + report surface
is exactly what 0.11.1 churned.
Recommendation (do this now, in the bump wave): pin mail-auth = "=0.11.0"
in the manifest (with the existing default-features = false, ["ring", "report"]). Rationale: the lock is already on 0.11.0 by luck of resolution;
making the pin explicit prevents a cargo update from silently pulling the
breaking 0.11.1 and snapping the three with_domain_suffix_fn call sites. The
0.11.1 adaptation (find the 0.11.1 organizational-domain replacement API, port
the three smtp_server sites, re-validate the mail_spooler DMARC report
builder against the RFC 9990/9991 fields, re-run the DMARC/SPF/DKIM policy
tests) is a real, DMARC-scoped refactor across two crates — schedule it as its
own item, not as part of a version bump. Only take 0.11.1 when the RFC
9989/9990/9991 features are actually wanted.
Outcome (2026-08-04): this recommendation was declined, deliberately.
The RFC 9989/9990/9991 features were wanted, so the adaptation was run as
its own wave and the manifest kept the caret requirement mail-auth = "0.11", which now resolves 0.11.1 — no exact pin. What that wave found, and
why the caret was the answer:
- The audit’s premise that a replacement folding hook exists was wrong.
with_domain_suffix_fnhas no successor: 9989’s DNS tree walk does the organizational fold insideverify_dmarc, and the port was a deletion of the three call sites, not a re-pointing. - Two of the four crates never touched the churned surface, so the “four
crates” blast radius overstated the work: the port landed in
smtp_serverandmail_spooleronly. - Pinning
=0.11.0would have frozen the workspace on the obsoleted RFC 7489 line — the opposite of where this protocol is going — for a hazard that a caret plus a committedCargo.lockalready contains.
The residual exposure is real and is accepted, not solved. A caret
requirement lets a future cargo update take another patch release from an
upstream that has already shipped one SemVer-illegal patch: 0.11.1 broke three
call sites while claiming to be a patch bump. The lockfile is what holds the
line between updates, so treat any cargo update touching mail-auth as a
change that needs the DMARC suites re-run, not as routine maintenance.
Prioritized recommendation list
- Pin
mail-auth = "=0.11.0"(highest urgency — a straycargo updatebreaks the build via the removedwith_domain_suffix_fn). Cheap, defensive. - Convert every
*entry to a caret major floor (see the block above). No behavior change; closes the silent-major hazard that already bitreqwest. Cheap, mechanical. - OpenTelemetry coordinated bump wave (0.27/0.28 → 0.33 train, incl.
mail_observe’s directopentelemetry-proto). Real value, single-file rewrite oftelemetry.rs, but needs its own scoped wave. Medium effort. - Azure GA
azure_core1.0 → 1.1 minor — optional, low value; hold unless a fix is needed. - Azure legacy tables stack — no action; blocked on the Cosmos/Tables GA crate (~late 2026). Track upstream.
- libp2p / libp2p-stream — hold; already at latest published/alpha.
Where this list stands (2026-08-04). Items 1–4 have all been answered, so read them as history rather than as work:
- Item 1 — declined. The 0.11.1 adaptation was taken instead and the
manifest carries the caret
0.11; see the outcome note above for the reasoning and the residual exposure. - Item 2 — done. No
*requirement remains in[workspace.dependencies]; all eleven entries carry a caret major floor, and the summary table’s Manifest column shows them. One went further than the proposal above:num-deriveis on0.5, not the0.4the block suggests. - Item 3 — done. The OpenTelemetry stack is on the 0.32 line with
tracing-opentelemetry0.33 (its minor runs one ahead of core). - Item 4 — done.
azure_core(GA) is at1.1; the legacy 0.21 tables stack is untouched and still blocked, as item 5 says.
Anything urgent
Nothing on this list is urgent any more (see the status block above). As the
audit was written, item 1 (mail-auth) was the only time-sensitive entry, and
it was a hazard from inaction — a future cargo update — rather than a live
breakage. Everything else was planned, deferrable maintenance.
Dependency audit — 2026-08
Scope: every one of the 121 third-party entries in the root
Cargo.toml [workspace.dependencies], plus the literal-version declarations in
member manifests that the root table does not control. It answers two questions:
what can move right now without breaking anything, and what is blocked, on
what.
Unlike the 2026-07 audit, which this supersedes, the safe tier was not only identified but landed and verified — see What was landed.
Method (and why it differs from the 2026-07 audit)
- “Latest” was read live from the crates.io sparse index
(
https://index.crates.io/...) — the same source Cargo itself resolves against. The 2026-07 audit read the local index cache (~/.cargo/registry/index), so its “latest” meant “latest that cache had seen at last fetch”, and it flagged rows (offline?) where it could not confirm. Several of those rows had drifted. (The crates.io JSON API is not usable here: it refuses these requests under its data-access policy. The sparse index has no such restriction.) - Resolution was proven with
cargo update --dry-run, and then the result was actually built. That distinction earned its keep — see the wincode split, which resolves cleanly and fails to compile. - MSRV was read per version from each index entry’s
rust_versionfield. This is the dimension the 2026-07 audit had no coverage of at all. cargo-outdatedis still unusable in this tree. The 2026-07 audit blamed theazure_core_legacypackage alias plus a yanked transitive; the yanked transitive is the real cause and it now has a name — see Theipfs-cidwildcard.
Two toolchains, nine Rust versions apart
This workspace is built by two different Rust toolchains:
| Build | Toolchain | rustc |
|---|---|---|
Host — cargo build, scripts/gate.sh | system | 1.98.0 |
On-chain — cargo build-sbf --tools-version v1.53 | ~/.cache/solana/v1.53/platform-tools | 1.89.0-dev |
A dependency that raises its MSRV past 1.89 breaks cargo build-sbf while
the entire workspace gate stays green. scripts/gate.sh excludes the
build-sbf legs deliberately (they apply only when a program crate changed, and
live in the onchain-program-build skill instead), so nothing in the routine
gate can catch an SBF-only MSRV break. Any dependency work touching the
on-chain closure has to run the three build-sbf legs as its own positive
control.
What limits the blast radius is that the on-chain closure is small by
construction. Each of mail_program, alias_program and domain_program
declares exactly five production dependencies:
mail-model pinocchio pinocchio-log pinocchio-system program-common
build-sbf builds only the lib/cdylib target, never the test harness — so
rsa, rand 0.8, bs58, sha2, p256, ed25519-dalek and the whole
agave/solana-program-test cluster are dev-only and never reach the bytecode.
program_common additionally puts sha2, solana-big-mod-exp and
solana-address’s curve25519 feature behind
cfg(not(any(target_os = "solana", target_arch = "bpf"))), so those are
host-only too.
Result for this audit: MSRV blocked nothing. Across every package a full
cargo update would move, exactly 14 declare a rust_version above 1.89 —
all of them the AWS SDK / smithy cluster at 1.94.1:
aws-config aws-runtime aws-sdk-appconfigdata aws-sdk-dynamodb
aws-sdk-secretsmanager aws-sdk-sqs aws-sdk-sts aws-smithy-http-client
aws-smithy-query aws-smithy-runtime aws-smithy-runtime-api
aws-smithy-types aws-smithy-xml aws-types
None is in the on-chain closure — they reach only mail_store’s aws feature,
key_source’s asm, and app_config’s AWS AppConfig tier. Nothing anywhere
exceeds the host’s 1.98. What did block the update was something else
entirely.
The wincode split — why the solana stack cannot move piecemeal
A blanket cargo update resolves cleanly and then fails to build. This is
the finding worth carrying forward, and no amount of dry-running would have
surfaced it.
The solana crates are mid-migration between two majors of wincode, the
serialization-schema crate whose derive macros generate SchemaRead/
SchemaWrite impls. As of this audit the split runs straight through the
dependency graph:
| Side | Crates | Requires |
|---|---|---|
| moved | solana-address 2.7.0, solana-hash 4.6.0, solana-message 4.5.0, solana-reward-info 6.3.0, solana-short-vec 3.3.0, solana-signature 3.5.2, solana-transaction 4.3.0 | wincode ^0.6 |
| not moved | solana-transaction-status-client-types — at 4.1.2 and at its newest 4.2.1 alike | wincode ^0.5 |
Cargo is happy to put both in one graph, because two semver-incompatible
majors of a crate are allowed to coexist. But wincode::SchemaWrite is a
trait, and the two copies are different types. solana-transaction-status-client-types
derives SchemaWrite for its own structs against wincode 0.5, while
CompiledInstruction — which comes from the moved side — implements wincode
0.6’s trait. The build dies with ten instances of:
error[E0277]: the trait bound `CompiledInstruction: wincode::SchemaWrite<__WincodeConfig>`
is not satisfied
note: there are multiple different versions of crate `wincode` in the dependency graph
Consequence: the solana stack must move as one coordinated set, and today it
cannot move at all — the standalone crates have crossed to wincode 0.6 and the
status-types crate has not followed. Until it does, cargo update must leave
every solana-* package alone.
That is the shape of the tier below: it is not “everything semver-compatible”, it is “everything semver-compatible except the solana stack”.
Verdict legend
Carried over from the 2026-07 audit so the two read alike:
- free — semver-compatible, lockfile-only, no manifest edit. (new tier)
- ready — a real bump is available and the adaptation is understood and small.
- decision — available, but taking it changes behaviour someone must sign off on.
- blocked — cannot move until an upstream event; no action possible now.
- reject — available but must not be taken.
- hold — already at latest, or intentionally pinned.
Tier 1 — free (landed)
43 packages, all non-solana, lockfile-only. Direct workspace entries advanced:
| Entry | Change |
|---|---|
aes-gcm | 0.11.0 → 0.11.1 |
async-trait | 0.1.91 → 0.1.92 |
aws-config and the AWS SDK / smithy cluster | 12 packages |
blake3 | 1.8.5 → 1.8.7 |
futures (+ its 8 sub-crates) | 0.3.33 → 0.3.34 |
google-cloud-secretmanager-v1 (+ auth/gax/wkt) | 1.11.0 → 1.12.0 |
mail-auth | 0.11.1 → 0.11.2 |
mail-parser | 0.11.5 → 0.11.8 |
psl | 2.1.219 → 2.1.226 |
rcgen | 0.14.8 → 0.14.9 |
rusty-s3 | 0.10.1 → 0.10.2 |
thiserror (+ -impl) | 2.0.19 → 2.0.20 |
toml (+ toml_parser) | 1.1.3 → 1.1.4 |
uuid | 1.24.0 → 1.25.0 |
One addition: base64 0.23.1 enters as a transitive, because rusty-s3 0.10.2
requires ^0.23. The workspace’s own base64 = "0.22" is untouched, so the two
majors now coexist — which is an argument for the Tier 3 base64 bump, since
that would collapse them back to one.
Only two of the 43 reach the on-chain bytecode — blake3 and thiserror,
both patch bumps, both MSRV well under 1.89. Everything else is host-side.
pinocchio 0.11.2, pinocchio-log 0.5.1, pinocchio-system 0.6.1 and borsh
1.8.0 were already at latest.
Two entries that a naive reading would expect here and which are absent:
wasm-bindgen0.2.126 → 0.2.127 does not move. It is only reachable by dragging the solana graph’sjs-sys/web-syschurn along with it, which this tier refuses to do.cargo update -p wasm-bindgenon its own is a no-op.- Every
solana-*package, for the wincode reason above.
mail-auth 0.11.1 → 0.11.2 — the hop that needed scrutiny
The 2026-07 audit left a standing rule: mail-auth has already shipped one
SemVer-illegal API-breaking patch (0.11.1 removed with_domain_suffix_fn
and snapped three call sites), so “treat any cargo update touching
mail-auth as a change that needs the DMARC suites re-run, not as routine
maintenance.” This tier touches it, so the two versions were diffed directly
from their cached .crate archives rather than trusted to a changelog:
- No public API changed. Not one
pub fn/pub struct/pub enum/pub trait/implline differs between the two source trees.Report::parse_rfc5322keeps its single-argument signature — themax_sizeparameter the changelog describes lands in 0.12, not here. - Ten files differ, and nearly all of it is lint-driven refactoring with
byte-identical output:
[b' ', b'\t'].contains(&c)becomesb" \t".contains(&c)indkim/canonicalize.rsandcommon/headers.rs;&self.pbecomesself.pinsidewriteln!inreport/dmarc/generate.rs. DKIM1 canonicalization bytes and DMARC report XML bytes are unchanged. - The three real behaviour changes are all in code this workspace neither
compiles nor calls: DKIM2 header classification (
common/message.rs,dkim2/*), ARC sealing (arc/seal.rs,arc/verify.rs), andcommon/crypto/rust_crypto.rs— the last behind therust-cryptofeature, while the manifest takesdefault-features = false, features = ["ring", "report"]. There is no reference tomail_auth::arcormail_auth::dkim2anywhere in the workspace.
The DMARC/DKIM/SPF suites were re-run anyway, per the standing rule.
mail-parser 0.11.7 introduced an rkyv layout regression that 0.11.8 fixes;
this tier lands on 0.11.8, and nothing in the workspace uses rkyv directly — it
is purely a transitive of smtp-proto and mail-auth. Non-issue.
Tier 2 — the solana stack
solana-program-test, solana-rpc-client, solana-rpc-client-api,
solana-cli-config, solana-system-program and
solana-transaction-status-client-types hold at 4.1.2 with 4.2.1
available, alongside the standalone solana-* crates listed in the wincode
table above.
Taken on its own the 4.2.1 move looks cheap: ProgramTest and BanksClient’s
public API and the .so search order are unchanged between the two tags, and
none of the spl/zk churn that Agave 4.2 carries internally
(spl-token-interface 2→3, solana-zk-sdk dropped for solana-zk-sdk-pod)
reaches solana-program-test.
But it cannot be assessed independently of the wincode split, because
solana-transaction-status-client-types 4.2.1 still requires wincode ^0.5.5
— the same side of the divide as 4.1.2. Whether a coordinated all-solana move
resolves to a single wincode major, or reproduces the same E0277, is the
question that gates this tier. Verdict: blocked pending that determination,
and in any case its own item with its own gate run, never a ride-along on a
lockfile refresh. Note also that solana-sbpf moves to =0.21.1 at 4.2.1,
which means every .so must be rebuilt.
Tier 3 — needs a manifest edit
| Entry | Manifest | Available | Verdict |
|---|---|---|---|
base64 | 0.22 | 0.23.1 | LANDED 2026-08-23 |
mail-auth | 0.11 | 0.12.1 | LANDED 2026-08-23 |
jsonwebtoken | 10 | 11.0.0 | LANDED 2026-08-23 |
smtp-proto | =0.2.3 | 0.2.3 | LANDED 2026-08-25 |
mail-builder | 0.4 | 0.5.0 | blocked |
sha2 | 0.10 | 0.11.0 | blocked |
bincode | 1 | 3.0.0 | reject |
All three “ready” rows landed on 2026-08-23, each as its own gated commit, and taking them corrected two claims this audit had stated as fact. Both corrections are recorded in the workspace manifest beside the entries they concern, and neither changes a verdict:
- The
base64bump collapses no duplicate.0.22stays in the tree regardless —sqlx-coreandazure_coreboth require it — while0.23was already resolved viarusty-s3. Fourbase64majors (0.13 / 0.21 / 0.22 / 0.23) are present either way; the bump only moves first-party code onto the copy already being compiled. - The
mail-authbump drops no duplicateed25519-dalek. The claim below that “0.12 moves to^3” mis-reads the manifest: 0.11.2 already required^3, and the dependency is optional, reached only through mail-auth’srust-cryptofeature — which this workspace never enables, since it buildsdefault-features = false, features = ["ring", "report"].ed25519-dalek2.x is required independently by five other packages (ed25519-dalek-bip32,libp2p-identity,solana-keypair,solana-signature, andjsonwebtoken11), so nothing about it moved.
A third fact the audit missed is not a correction but a trap worth carrying:
since 11, jsonwebtoken’s crypto backend is a process-wide CryptoProvider
resolved from cargo features, and it resolves correctly only while exactly
one of rust_crypto / aws_lc_rs is enabled anywhere in the graph. Because
cargo unifies features across a workspace, a second consumer enabling
aws_lc_rs would select a stub provider whose every entry point panics at
run time, with no compile error. account_api is the only consumer today.
base64 0.22 → 0.23 — ready, and now more attractive than it was. The
whole workspace uses only Engine::encode/decode with BASE64_STANDARD;
there is no custom alphabet, no GeneralPurposeConfig, and no use of the
deprecated free functions. The one breaking change
(DecodeError::InvalidLastSymbol gained a payload) is not matched on anywhere
here. MSRV rises to 1.71 — comfortably under both toolchains. Since Tier 1
pulled base64 0.23.1 in as a rusty-s3 transitive, bumping the workspace
entry would collapse a duplicate rather than create one. Note 0.23 adds a
default-on simd-unsafe feature.
mail-auth 0.11 → 0.12 — ready, and smaller than it looks. The verifiers
(verify_dkim, verify_spf, verify_dmarc, verify_arc) and the
MessageAuthenticator type are unchanged. The only signature break is
Report::parse_rfc5322 and TlsReport::parse_rfc5322 gaining a max_size
argument that bounds decompressed report size — a hardening fix. Behavioural
changes are DMARC identifiers folded to A-label form and alignment becoming
case-insensitive. It would also drop the duplicate Struck 2026-08-23: wrong on both halves — see correction 2
above. The landed bump moved exactly one package in the lockfile.ed25519-dalek 2.x from
the tree, since 0.12 moves to ^3 — matching what the workspace already
declares.
What max_size became. The bound is
dmarc_rua::MAX_REPORT_SIZE = 25 MiB, matching [smtp] max_message_size
rather than introducing a second size vocabulary. Only one production call
site exists (mail_spooler/src/adapters/dmarc_rua.rs); TLS-RPT reports are
emit-only here, so TlsReport::parse_rfc5322 is reached from tests alone.
jsonwebtoken 10 → 11 — ready, moderate. One file,
account_api/src/jwt.rs, HS256 only, about six API items. The rust_crypto
feature name survives. The exposure is Header.extras becoming a struct and the
removal of EncodingKey::inner / DecodingKey::as_bytes; neither is used
today, so this is close to a version-number change.
smtp-proto =0.2.1 → =0.2.3 — LANDED 2026-08-25, by user decision.
This audit framed the change correctly; the workspace manifest and HANDOFF had
compressed it into “0.2.2 appends a trailing CRLF”, which reads as though 0.2.2
added something spurious. It is the other way round. 0.2.2 changed
request/receiver.rs from buf.truncate(len - 3) to buf.truncate(len - 1),
and at the moment that line runs the buffer always holds the body’s own final
CRLF plus the terminator’s stray CR — so 0.2.1 was deleting a byte pair that
belonged to the message, and every stored body lost its last line break. The
CRLF before the terminating dot is content, not terminator (RFC 5321 §4.5.2),
so 0.2.2 fixed a real defect and 0.2.3 rightly did not revert it; 0.2.3 only
reworked FUTURERELEASE to parse RFC 3339 datetimes, which is inert here because
unsupported_mail_param already refuses HOLDFOR/HOLDUNTIL with 555 5.5.4.
What the bump moves, and what it does not. Only the stored copy changes —
the bytes sealed to IPFS and served over IMAP/POP now keep the sender’s final
CRLF. Relayed mail is byte-identical either way, because dot_stuff
(smtp_session/src/client.rs) already re-adds a trailing CRLF only when one is
absent. The DKIM exposure this audit flagged turned out benign and is now
measured rather than reasoned about: under relaxed body canonicalization
(RFC 6376 §3.4.4) both shapes produce the same bh=, fenced by
a_trailing_crlf_does_not_move_the_dkim_body_hash in mail_submit/src/dkim.rs.
The pin stays exact, because this crate decides delivered-mail bytes with no
compiler error when it changes. mx_transaction_delivers_with_trace_headers
was silently pinning the truncation — its ends_with("line one\r\n.leading dot")
assertion encoded the missing CRLF — and now asserts the CRLF is kept. A
dedicated fence, data_body_keeps_its_final_crlf, pins a trailing blank line
end-to-end; it was positive-controlled by re-pinning 0.2.1, where it fails.
mail-builder 0.4 → 0.5 — blocked. mail-auth 0.12.1 still requires
^0.4, so taking 0.5 puts two copies of the crate in the tree. Unblocks when
mail-auth 0.12.2 publishes (it is in the changelog, not yet released).
sha2 0.10 → 0.11 — blocked. 0.11 rides digest 0.11 and const-oid
0.10, while rsa 0.9.10 requires digest ^0.10.5 and const-oid ^0.9. The
AssociatedOid trait therefore differs between them, so
Pkcs1v15Sign::new::<Sha256>() will not accept a 0.11 Sha256 — and that
pairing is exactly why the oid feature is enabled in program_common,
domain_program and mail_program. Unblocks when rsa 0.10 leaves rc.
bincode 1 → 3 — reject; 3.0.0 is a tombstone, not a release. The project
was archived in 2025 and 3.0.0 ships a lib.rs containing only a
compile_error!, published solely to signal abandonment because crates.io has
no way to mark a crate archived. The index metadata corroborates it: 3.0.0
declares zero dependencies and zero features, where 1.3.3 declares
serde ^1.0.63 — there is no serialization library in it. Stay on
bincode = "1". Should it ever have to move, the successors are bincode-next
or wincode, and the real break was 1.x → 2.0 (default config stopped being
byte-compatible), which makes it a persisted-format migration gated by
mail_wasm’s parity suite rather than a version bump.
Tier 4 — hold
imap-codec=2.0.0-alpha.9,imap-types=2.0.0-alpha.7,imap-next=0.3.4— all three are the newest published version on their line. The exact pins remain correct.azure_core_legacy/azure_data_tables/azure_storage0.21 — still the last published releases on the legacy line; still blocked on the Cosmos/Tables GA crate.azure_storage_blob/azure_storage_queue1.0 — only a 1.1.0-beta exists.libsql0.9.30 — only a 0.10.0-pre exists.libp2p0.56.0 andlibp2p-stream0.4.0-alpha — at latest. This settles the (offline?) flag the 2026-07 audit left on the libp2p row: there is no 0.57.
Correcting a count. HANDOFF.md carried the estimate “~18 direct deps
behind latest-incompatible await a semver-major pass.” Measured against a live
index, the real figure is 7 entries that are actionable someday and 6
that are blocked or exist only as pre-releases.
The ipfs-cid wildcard
mail_client/Cargo.toml declared ipfs-cid = "*" in [dependencies] — the
only wildcard in a production dependency table anywhere in the workspace. The
2026-07 audit’s item 2 removed every * from the root manifest but did not
scan member manifests, so this one survived it.
It resolved to 1.0.0 purely by accident: ipfs-cid 2.0.0 requires
cid ^0.10.1, which requires core2 ^0.4, whose 0.4.0 is yanked. That
unresolvable chain was the only thing standing between the workspace and a
silent major bump on the next cargo update. It is also the specific reason
cargo-outdated cannot run here — the 2026-07 audit observed the symptom
without naming the cause.
Only one API is used, generate_cid() followed by .to_string(), at six call
sites under mail_client/src/commands/. Fixed: hoisted into
[workspace.dependencies] as ipfs-cid = "1", with mail_client inheriting
it. Notation only — the resolved version did not move.
Note also that ipfs-cid drags in a second, older CID stack (cid 0.10)
alongside the workspace’s own cid = "0.11" used by bitswap_proto and
ipfs_repo. Consolidating the two is worth its own item.
Follow-up: the remaining wildcards
mail_client’s [dev-dependencies] carried assert_cmd = "*",
predicates = "*", regex = "*" and tempfile = "*" — the last of which
shadowed the root’s tempfile = "3". They are dev-only, so they cannot reach a
shipped artifact, but they carried the same silent-major hazard. LANDED
2026-08-23: all four pinned — assert_cmd = "2", predicates = "3",
regex = "1", and tempfile.workspace = true (inheriting the root’s
tempfile = "3"). Cargo.lock byte-identical.
What was landed
Two commits:
ipfs-cidpinned — rootCargo.tomlgainsipfs-cid = "1";mail_client/Cargo.tomlswitches toipfs-cid.workspace = true.Cargo.lockbyte-identical.- Tier 1 lockfile update — 43 non-solana packages, no manifest edit,
Cargo.lockthe only changed file.
How to reproduce the tier
cargo update has no --exclude, so the tier is expressed positively: every
non-solana, non-path entry in [workspace.dependencies], passed as explicit
-p specs. Specs must be version-qualified (aes-gcm@0.11.0) wherever the lock
holds more than one version of a name, or cargo rejects the spec as ambiguous.
Verification
The .so files come first, because the program suites assert against the
bytecode on disk and a stale .so is the classic misdiagnosis here:
cargo build-sbf --arch v3 --tools-version v1.53 --manifest-path mail_program/Cargo.toml
cargo build-sbf --arch v3 --tools-version v1.53 --manifest-path alias_program/Cargo.toml
cargo build-sbf --arch v3 --tools-version v1.53 --manifest-path domain_program/Cargo.toml
bash scripts/gate.sh --mail-store
Those three legs are the only positive control that exists for the MSRV
finding, since the workspace gate structurally cannot fail on an SBF-only MSRV
break. --mail-store is required because the AWS SDK cluster moved and
mail_store’s cloud backends are feature-gated out of the default test leg.
The program_common/src/modexp.rs endianness shim was re-read, as HANDOFF.md
requires on any SDK bump. It is untouched by this tier: solana-big-mod-exp
stays at 4.0.0 and solana-define-syscall stays at 5.1.0, because no solana-*
package moves.
RFC-updates recon — 2026-08
Scope: the shared recon artifact for the RFC-updates adoption program. For every base RFC the stack implements, the RFC Editor lists published update RFCs; this page maps each update onto every repo surface it touches, records an audited status (with the method used to reach it), and closes each row with an adopt/decline recommendation. The recommendations are inputs to future planning decisions, not decisions — nothing on this page changes code, and no update is adopted by appearing here.
The cluster inventory (which updates exist per base) comes from the updated-by graph verified against rfc-editor.org metadata on 2026-08-05; it is not re-derived here. Ten clusters exist in total — SMTP core, enhanced status codes, email TLS, DSN/MDN, SPF, DKIM, ARF, IMAP, POP3, and message format/MIME — and all ten are audited below. The page closes with the cross-cluster overlap map (which clusters can never run as concurrent lanes) and the decision inventory (the adopt/decline questions each future cluster wave must put to the user at its planning).
Method: every “audited status” was established by reading this repo’s sources
and, where a behavior lives in an adopted dependency, the vendored crate
sources under the local cargo registry (~/.cargo/registry/src): mail-auth
0.11.1, smtp-proto 0.2.1, imap-types 2.0.0-alpha.7, mail-parser 0.11.5,
mail-builder 0.4.4, and reqwest 0.13.4. Each row names its grep target or
the function read, plus file:line cites as of 2026-08-05 — line numbers
drift, so the function names are the durable half of each cite. Audit depth is
deliberately prose-with-cites: executable fences (tests asserting an update’s
behavior) ride each future adopting-cluster wave, not this page.
Wired into
SUMMARY.mdunder Appendix: Reference in wave-set #47 (alongsidedependency-audit-2026-07.md, this page’s structural sibling).
The coverage-assertion sync sites. Whenever a cluster wave adopts an
update, three places assert RFC coverage and must move together:
Standards and RFC coverage, the
protocol-conformance appendix, and the
owning server crate’s lib.rs “RFCs implemented” block.
Verdict legend
- done — the update’s behavior is already implemented and credited; no action.
- adopted in-flight — being adopted by a wave currently running; the row records the fact, not an open question.
- adopt (citation/fence) — the behavior is already present (natively or via an adopted dependency); the remaining work is citing the RFC and fencing the behavior with a test or doc assertion, not implementing it.
- adopt (implement) — the behavior is absent and adoption is recommended; the row scopes the (small) implementation.
- decision required — adoption turns on a question the owning cluster wave must put to the user at its planning; the row records evidence only, and the question is stated — not answered — in the user decision inventory closing this page.
- decline — recommend not adopting; the rationale rides the row.
- defer (EAI) — only meaningful if the stack ever adopts internationalized email (RFC 6530–6533); park behind that decision. That decision is now recorded (wave-set #51 lane C): deferred with criteria — the EAI program stays unopened until a concrete demand signal arrives (an operator or user needing non-ASCII addresses, or interop with an EAI sender); no implementation now. The rows keep this verdict tag, with decision-inventory entry 7 as their governing record.
Cluster 1 — SMTP core (RFC 5321)
Owning surfaces: the sans-io smtp_session state machine (wire grammar over
the adopted smtp-proto 0.2.1) driven by smtp_server; outbound, the relay’s
SMTP client (smtp_session::client used by mail_spooler). Coverage
assertions: the SMTP table in standards.md and
smtp_session/src/lib.rs’s RFC block.
| Update | What it adds to 5321 | Audited status | Verdict |
|---|---|---|---|
| RFC 7504 | 521 (host never accepts mail) / 556 (domain publishes null MX) reply codes | already implemented, previously uncredited | adopted in-flight (wave-set #47 lane B) |
- RFC 7504 — adopted in wave-set #47 lane B, running concurrently with
this recon, as a citation/fence pass rather than an implementation: the
behavior was found already present but uncredited. The relay’s
null_mx_outcome()(mail_spooler/src/pipeline/relay.rs:923) already produced the 556 / 5.1.10 outcome — RFC 7504 §2.2’s prescribed use of the code in the failure DSN for a null-MX domain — while citing only RFC 7505; and the client outcome classifieris_permanent()(smtp_session/src/client/outcome.rs:50) treats anycode >= 500generically, so an inbound 521/556 from a remote server already terminates retries as 7504 intends. There is deliberately no live 521/556 emission path on the server side: SithBit’s posture is “don’t run the server if you don’t want mail”, so a mode that answers every connection with 521 has no product meaning here. Lane B’s pass adds the citations and fences; no behavior changes.
Cluster 2 — Enhanced status codes (RFC 3463)
Owning surfaces: smtp_session/src/reply.rs — EnhancedCode (reply.rs:18)
and Reply::enhanced (reply.rs:48), where the class digit is derived from
the reply code so a class mismatch is unrepresentable (fenced by the inline
test at reply.rs:131); advertised via ENHANCEDSTATUSCODES (RFC 2034).
Three of this cluster’s updaters are SMTP extensions that merely register
new codes, so each needed an adopt/decline verdict on the extension itself,
not just on code strings.
| Update | What it is | Audited status | Verdict |
|---|---|---|---|
| RFC 3886 | message/tracking-status (MTRK companion) | absent; MTRK param structurally refused | decline |
| RFC 4468 | BURL submission extension | absent; command falls to the catch-all | decline |
| RFC 4865 | FUTURERELEASE (scheduled send) | absent; HOLDFOR/HOLDUNTIL structurally refused | decline |
| RFC 4954 | SMTP AUTH | implemented and credited | done |
| RFC 5248 | the enhanced-status-code IANA registry (BCP) | no code surface; emitted codes are registry-listed; credit landed | adopted (wave-set #48 lane A) |
- RFC 3886 (with RFC 3885, cluster 4). The adopted
smtp-protogrammar parses theMTRKMAIL parameter (MailFrom.mtrk, vendoredsmtp-proto-0.2.1/src/lib.rs:120), butunsupported_mail_param(smtp_session/src/session/ready.rs:300-302) refuses it with555 5.5.4because the extension is never advertised — the deliberate pattern for every extension the config does not enable. Method: grep forMTRK/Mtrkacrosssmtp_session,smtp_server,mail_spooler, and the vendoredsmtp-proto. Recommendation: decline — the MTRK family (RFC 3885/3886/3887) saw effectively no real-world deployment, and SithBit’s message tracking already lives a layer down: on-chain message accounts give senders a stronger delivery record than MTRK ever specified. - RFC 4468 (BURL).
smtp-protoparsesRequest::Burl(vendoredlib.rs:37), but no session state handles it: the dispatch catch-all (ready.rs:92) answers500 5.5.1(unknown_command,session/mod.rs:524-526), andEXT_BURLis never advertised. BURL also requires IMAP URLAUTH (RFC 4467) on the access side, and the adoptedimap-types2.0.0-alpha.7 has no URLAUTH types at all (method: grep forurlauth/GenUrlAuthin the vendored source — zero hits), so the prerequisite is unbuildable without the already-deferredimap-typesfork. Recommendation: decline — near-zero client deployment, a blocked prerequisite, and the compose-without-reupload problem it solves is served by the account API compose endpoint (mail_submit) instead. - RFC 4865 (FUTURERELEASE). Parsed by
smtp-proto(MailFrom.hold_for/hold_until), structurally refused with555 5.5.4atready.rs:303-305; never advertised. It also updates RFC 3464 (DSN wording for held mail — see cluster 4). Recommendation: decline — scheduled send belongs in the composing client or the account API, where the user can still see and cancel the message, not in an SMTP queue extension; the DSN-side changes fall away with it. - RFC 4954 — done. Implemented and credited:
smtp_session/src/lib.rs:19, the standards.md SMTP table, and the §6-conformant failure replies (failure_reply,ready.rs, e.g.535 5.7.8). No action. - RFC 5248. A BCP that turns 3463’s code lists into a living IANA
registry — there is no implementable behavior. Audit: inventoried every
Reply::enhancedcall site insmtp_session/smtp_server(method: grep- sed extraction of the code triples) and spot-checked the notable ones
against the registry: all emitted codes are registry-listed, including the
post-3463 registrations
5.7.8(RFC 4954),5.7.23(RFC 7372), and the DSN-side5.1.10(RFC 7505). Adopted in wave-set #48 lane A exactly as recommended — the wave that next touched standards.md folded the one-line credit onto its enhanced-status-codes row (“every emitted code is listed in the RFC 5248 IANA registry”); nothing was implemented.
- sed extraction of the code triples) and spot-checked the notable ones
against the registry: all emitted codes are registry-listed, including the
post-3463 registrations
Cluster 3 — Email TLS (bases: RFC 3207, 2595, 8314, 4616)
Owning surfaces: the shared server-side acceptors in
server_common/src/tls.rs (tls_acceptor at tls.rs:174 and
tls_acceptor_with_client_auth at tls.rs:197 — all three protocol servers
ride these); the single outbound client config connector() in
mail_spooler/src/smtp_out.rs:214 (covering TlsVerify::Strict,
Permissive, and Dane); and — for the residual finding below — the two
HTTPS fetchers in the spooler. Policy gates (RFC 8314) are documented in
the protocol-conformance appendix.
| Update | What it is | Audited status | Verdict |
|---|---|---|---|
| RFC 7817 | updated TLS server-identity check for email protocols | already satisfied where it applies; now cited at the handshake and credited | adopted (wave-set #48 lane A) |
| RFC 8996 | deprecates TLS 1.0/1.1 | floor held at every rustls site; now fenced, and the two reqwest sites pinned | adopted (wave-set #48 lane A) |
| RFC 8997 | TLS 1.2+ floor for email (updates 8314) | same evidence and fences as 8996 | adopted (wave-set #48 lane A) |
| RFC 4616 | SASL PLAIN (base, updated by 8996) | implemented and credited (server_common auth) | done |
| RFC 8314 | TLS before credentials (base, updated by 8997) | implemented and credited | done |
- RFC 7817 — adopted in wave-set #48 lane A, as the recommended
citation pass: the audit found the behavior already satisfied where it
applies, and no behavior changed. The only email-protocol TLS client in
this repo is the outbound SMTP path — there is no IMAP or POP client — so
7817’s 2595-side duties bind third-party clients, not these servers.
Audited evidence (unchanged by adoption):
TlsVerify::Strict(smarthost and MTA-STS enforce) hands the MX or smarthost hostname to rustls and verifies via rustls-webpki, which matches DNS-ID SANs only, supports single-label wildcards, and has no CN fallback at all — a strict subset of what 7817 permits, satisfying its MUSTs.TlsVerify::Permissiveis opportunistic RFC 7435 (identity checking out of scope by design), andTlsVerify::Danereplaces the identity check per RFC 7672 §3.1.1, which post-dates 7817 and governs its own case. What landed: the RFC 7817 citation athandshake()(mail_spooler/src/smtp_out.rs), recording that underStrictthe verified name is the MX target or configured smarthost, never the recipient domain; and the credit row in standards.md’s transport-hardening table. - RFC 8996 / 8997 — adopted in wave-set #48 lane A, as the recommended
verify-and-fence pass; nothing was implemented. The audited base fact
stands: the three production rustls builder sites all call
with_safe_default_protocol_versions()— the two shared acceptors inserver_common/src/tls.rsand the outboundconnector()inmail_spooler/src/smtp_out.rs(the repo’s fourth builder is a#[cfg(test)]loopback helper) — and rustls has never shipped TLS ≤ 1.1, so no acceptor or connector can negotiate the deprecated versions. What landed: the fencesafe_default_versions_enforce_the_tls12_floornow rides both choke points (server_common/src/tls.rstests for the acceptors, A-P1;mail_spooler/src/smtp_out.rstests for the connector, A-P2), pinning rustlsDEFAULT_VERSIONSto exactly {TLS 1.3, TLS 1.2};server_common’slib.rsRFC block gained the 8996/8997 lines; and the docs credits ride standards.md plus the conformance appendix’s version-floor note. The recorded residual is closed too: the MTA-STS policy fetcher (HttpsPolicyFetcher::production_builder,mail_spooler/src/mta_sts.rs) and the TLS-RPT submitter (TlsRptReportWorker::new,mail_spooler/src/pipeline/tlsrpt_report.rs) now pin the rustls backend via.tls_backend_rustls()— reqwest 0.13.4’s current name foruse_rustls_tls— so the 8996/8997 floor at those two sites is asserted in code rather than inherited from reqwest’sdefault-tls = ["rustls"]feature defaults plus the lockfile; the MTA-STS real-HTTPS smoke tests exercise the pinned builder. That closes decision-inventory entry 6 as option (a) — see below. - RFC 4616 and RFC 8314 — done as bases (SASL PLAIN in
server_common::auth; the TLS-before-credentials gates and their per-protocol cites in the conformance appendix); their respective updaters 8996/8997 are the rows above.
Cluster 4 — DSN / MDN (bases: RFC 3461, 3464, 6522)
Owning surfaces: envelope DSN parameters in smtp_session
(RET/ENVID in session/ready.rs, NOTIFY/ORCPT in
session/mail_txn.rs); DSN generation in mail_spooler/src/pipeline/dsn.rs
(the RFC 3464 worker) fed by the relay (pipeline/relay.rs); the
multipart/report container built via the adopted mail-builder
(dsn.rs:220).
All five verdicts below are recorded (wave-set #50 lane C, decision-recording only — nothing was implemented): every row closes as declined, deferred, or done, so no adopting wave will ever own this cluster. Per the recording’s convention call, declined RFCs get no standards.md rows — this page is the record.
The RFC 6533 deferral is now itself a recorded decision (wave-set #51 lane C): the EAI program is deferred with criteria — parked until a concrete demand signal (an operator or user needing non-ASCII addresses, or interop with an EAI sender); no implementation now. Decision-inventory entry 7 is the governing record for this row and its four sibling rows in clusters 5, 6, 8, and 10.
| Update | What it is | Audited status | Verdict |
|---|---|---|---|
| RFC 8098 | Message Disposition Notifications (obsoletes 3798) | absent everywhere | declined (wave-set #50 lane C) |
| RFC 3885 | MTRK ESMTP extension | absent; structurally refused | declined (with 3886; wave-set #50 lane C) |
| RFC 6533 | internationalized DSN/MDN (via 5337 → 6533) | absent, and out of scope while SMTPUTF8 is off | deferred (EAI) (wave-set #50 lane C) |
| RFC 4865 | FUTURERELEASE’s DSN-side updates to 3464 | falls with the extension | declined (falls with cluster 2, settled there) |
| RFC 6522 | multipart/report (base, updated by 6533) | implemented and credited | done |
- RFC 8098 (MDN). Fully absent, and confirmed absent at every layer:
repo-wide grep for
Disposition-Notification(Rust and webclients) — zero hits; the vendoredmail-parser0.11.5 andmail-builder0.4.4 have no MDN-specific types either (method: grep the vendored sources; the generic MIME builder could of course construct one). Declined at the server layer (wave-set #50 lane C), exactly as recommended — an MDN is generated by the recipient’s mail client on display, never by the MTA, so the server-side stack has no conformant role to add; if read receipts are ever wanted they are a webmail/client feature with real privacy trade-offs, to be decided there. The decline is reinforced by the workspace’s standing “inbound DSN/MDN processing — dropped (no surviving consumer)” record in HANDOFF.md’s deliberate design choices: the stack already deliberately retired the inbound half of the same MDN/DSN surface, so generating what it will never consume would be a one-armed feature. - RFC 3885 (MTRK) — declined (wave-set #50 lane C). Same audited
status and verdict as its 3886 companion in cluster 2: parsed by
smtp-proto, refused555 5.5.4atready.rs:300-302, never advertised — and the cluster-2 rationale carries here unchanged (the MTRK family saw effectively no real-world deployment, and on-chain message accounts already give senders a stronger delivery record). - RFC 6533 (internationalized DSN). The DSN worker emits the ASCII forms
exclusively:
message/delivery-status(dsn.rs:223) andOriginal-Recipient: rfc822; …(dsn.rs:282) — nevermessage/global-delivery-statusor theutf-8;address types (method: grepdsn.rsforglobal/utf8; readrenderand the per-recipient status block). But this is not a gap today, because no UTF-8 envelope can reach the spool: SMTPUTF8 is deliberately disabled — the driver pinssmtputf8: false(smtp_server/src/driver.rs:168) and the session refuses theSMTPUTF8MAIL parameter with555 5.5.4(ready.rs:291-293). Deferred behind the EAI program (wave-set #50 lane C) — 6533 only becomes implementable (or needed) if the stack adopts RFC 6530–6533 internationalized email wholesale; it rides that decision together with cluster 10’s 6532 row and cluster 5’s 8616 row, never alone. - RFC 4865 — declined (falls with cluster 2, where the FUTURERELEASE extension itself was declined and the decision settled): scheduled send stays out of the SMTP queue, so 4865’s DSN-wording updates to 3464 have nothing to attach to. The cluster-2 row carries the rationale; this row records only that the DSN side falls with it.
- RFC 6522 — done as a base (
multipart/reportwraps both DSN and ARF reports; standards.md row); its updater 6533 is the row above.
Cluster 5 — SPF (RFC 7208)
Owning surfaces: SPF evaluation is fully delegated to the adopted mail-auth
0.11.1 (verify_spf, vendored spf/verify.rs:33, TXT lookup at
spf/verify.rs:112), called from smtp_server/src/policy.rs — the v1 Spf
policy (check_envelope, policy.rs:352-368) and the DMARC-family policies
(policy.rs:447, policy.rs:604). Coverage assertion:
standards.md’s RFC 7208 row.
As planned, this cluster’s wave shape is verify the dependency and fence.
The one adoptable row below landed (wave-set #50 lane B): RFC 7372 is cited and fenced at its
5.7.23emission site, exactly as recommended. The 8553 decline (genuinely nothing to do) and the 8616 EAI deferral stand as recorded, so nothing in this cluster remains open.
The RFC 8616 deferral is now itself a recorded decision (wave-set #51 lane C): the EAI program is deferred with criteria — parked until a concrete demand signal (an operator or user needing non-ASCII addresses, or interop with an EAI sender); no implementation now. Decision-inventory entry 7 is the governing record.
The
5.7.26deferred candidate is now taken (wave-set #51 lane A): the DMARC bounce is re-coded from the conventional550 5.7.1to554 5.7.26, the “multiple authentication checks failed” code RFC 7372 §3.3 registers for a DMARC rejection — following the in-repo precise-code idiom the5.7.23SPF-hardfail reply set. The reply still names the offending From domain.
| Update | What it is | Audited status | Verdict |
|---|---|---|---|
| RFC 7372 | email-auth enhanced status codes | the one code with an emission path was already emitted; now cited and fenced | adopted (wave-set #50 lane B; DMARC bounce re-coded to 5.7.26, wave-set #51 lane A) |
| RFC 8553 | DNS AttrLeaf (underscored node names) | no SPF-side code surface | decline (nothing to do) |
| RFC 8616 | email auth for internationalized mail | absent in mail-auth; out of scope while SMTPUTF8 is off | defer (EAI) |
- RFC 7372 — adopted in wave-set #50 lane B, as the recommended
citation/fence pass; no behavior changed. The audited base fact stands:
the SPF hardfail rejection already used 7372 §3.2’s code —
554 5.7.23 SPF validation failed…— and the remaining 7372 codes have no emission site by policy design, not by omission: SPF temperror/permerror never reject — they land inAuthentication-Resultsinstead (the deliberate-softness comment atpolicy.rs:357-359); DKIM failures never reject standalone in anySenderAuthmode (smtp_server/src/config.rs:274-286); and the DMARC bounce then used the conventional550 5.7.1naming the offending From domain (dmarc_reject_reply), which that pass did not disturb. Method: greppolicy.rs/driver.rsforReply::enhancedand read the two reply sites. What landed: the rejection site became the free helperspf_hardfail_rejection(smtp_server/src/policy.rs) carrying the in-code §3.2 cite, fenced bypublished_spf_hardfail_rejects_with_5_7_23(a publishedv=spf1 -allover the offline resolver must reject 554 with enhanced status5.7.23, plus the softfail pass-through half); the credit rides the standards.md RFC 7208 row and the conformance appendix, andsmtp_server’slib.rsRFC block cites 7372. Re-coding the DMARC bounce as5.7.26“multiple authentication checks failed” was considered and initially not taken (5.7.1being the widely-recognized convention) — recorded as a deferred candidate, and since taken (wave-set #51 lane A):dmarc_reject_replynow emits554 5.7.26with the in-code §3.3 cite, still carrying the actionable domain name. - RFC 8553. The AttrLeaf BCP standardizes the global underscored-name
registry. SPF’s record lives at the bare domain —
mail-authqueries TXT at the domain itself (spf/verify.rs:112) — so no underscored node name is involved anywhere in this repo’s SPF path (method: read the lookup path in the vendored source). The underscored names the stack does use (_mta-sts,_smtp._tls,_dmarc,_domainkey,_25._tcp,_solana.authority) are already the registry forms and belong to other features’ surfaces. Recommendation: decline for this cluster — genuinely nothing to change or even cite; revisit only in cluster 6, where 8553 appears again on the DKIM side. - RFC 8616. Absent in the dependency:
mail-auth0.11.1 performs no U-label→A-label conversion — noidnadependency in itsCargo.toml, and noidna/punycode/u_labelhits anywhere in its source (method: grep the vendored crate) — so an internationalized domain would go to DNS unconverted. As with 6533, this cannot bite today: SMTPUTF8 is disabled (driver.rs:168), so no EAI identity ever reaches the verifiers. Recommendation: defer (EAI) — and note that if EAI is ever adopted, 8616 becomes an upstreammail-authquestion first (the same verify-the-dependency shape as this cluster), not repo code.
Cluster 6 — DKIM (RFC 6376)
Owning surfaces: signing in mail_submit/src/dkim.rs — rsa-sha256 with
relaxed/relaxed canonicalization, the signer type pinned to
DkimSigner<RsaKey<Sha256>, …> (dkim.rs:16, dkim.rs:39), one signer per
sending domain, signing once at spool entry; verification fully delegated to
the adopted mail-auth 0.11.1 (verify_dkim) from smtp_server/src/policy.rs
(policy.rs:385, policy.rs:474, policy.rs:629), where a DKIM failure
never rejects standalone in any SenderAuth mode
(smtp_server/src/config.rs:274-286). Coverage assertion:
standards.md’s RFC 6376 row. As planned, the wave
shape is verify the dependency and fence.
Both adoptable rows below landed (wave-set #50 lane B): 8301’s verify-side gap is closed by a repo post-filter, and 8463 is verified and optionally dual-signed — the owning-surfaces snapshot above predates the landing (the signer now also holds an optional
Ed25519Key, and everyverify_dkimresult is post-filtered); the rows and bullets record the current state. The 8553 decline (nothing to do) and the 8616 EAI deferral stand as recorded, so nothing in this cluster remains open.
The RFC 8616 deferral is now itself a recorded decision (wave-set #51 lane C), identically to the cluster-5 note: the EAI program is deferred with criteria — parked until a concrete demand signal (an operator or user needing non-ASCII addresses, or interop with an EAI sender); no implementation now. Decision-inventory entry 7 is the governing record.
| Update | What it is | Audited status | Verdict |
|---|---|---|---|
| RFC 8301 | SHA-1 deprecation, RSA key-size floors | signing side conformant by construction; the verify-side gap is now closed by a repo post-filter | adopted (wave-set #50 lane B) |
| RFC 8463 | Ed25519-SHA256 signatures | verify side fenced; signing side now opt-in dual-signing, default off | adopted (wave-set #50 lane B) |
| RFC 8553 | DNS AttrLeaf (underscored node names) | already the registry forms | decline (nothing to do) |
| RFC 8616 | email auth for internationalized mail | absent in mail-auth; out of scope while SMTPUTF8 is off | defer (EAI) |
- RFC 8301 — adopted in wave-set #50 lane B, going one step past the
recommended citation/fence: the decision-inventory question the audit
raised was answered as option (a), so the verify side gained a small
implementation too. The audited base facts stand: the signing side
conforms by construction — the mandatory signer is
RsaKey<Sha256>(mail_submit/src/dkim.rs), so norsa-sha1signature can ever be emitted (8301 §3.1’s signer MUST); the key-size floors are operational (operator-supplied PEM; the test fixture is 2048-bit, matching §3.2’s SHOULD); andmail-auth0.11.1 still parses (dkim/parse.rs:221) and verifiesrsa-sha1— under the repo’sringfeature viaRSA_PKCS1_1024_8192_SHA1_FOR_LEGACY_USE_ONLY(common/crypto/ring_impls.rs:266,ring_impls.rs:284), whose1024_8192range does enforce §3.2’s 1024-bit verifier floor. Method: read the vendored parse/verify/crypto paths; grepSha1acrossmail-authand the repo. What landed: the post-filterdowngrade_rsa_sha1(smtp_server/src/policy.rs) runs immediately after everyverify_dkimcall in all three sender policies, rewriting a verifiedrsa-sha1pass into a failure before the outputs feedAuthentication-Resultsor DMARC alignment (signature evidence kept so reporting still names the signing domain); fenced both ways, including the deliberate assert thatmail-authstill verifiesrsa-sha1— a future dependency bump that stops doing so reddens it, the signal the filter and fences can retire. The 8301 cite rides the signer’s module doc,smtp_server’slib.rs, the standards.md RFC 6376 row, and the conformance appendix. Decision-inventory entry 3 below records the answer. - RFC 8463 — adopted in wave-set #50 lane B, both halves: the verify
side as the recommended citation/fence, the signing side as the
decision-inventory question answered as option (a) — opt-in
dual-signing. The audited base facts stand:
mail-authparsesa=ed25519-sha256(dkim/parse.rs:230) andk=ed25519key records (dkim/parse.rs:273), verifies via ring’s Ed25519 public key type (Ed25519PublicKey,ring_impls.rs:299-313), and can sign (dkim/sign.rs:106). Method: grepEd25519across the vendored crate; readDkim::from_key_pem. What landed: the verify fencean_ed25519_sha256_signature_verifies_to_pass(smtp_server/src/policy.rs) live-signs with the RFC 8463 appendix-A test keypair (= RFC 8032 §7.1’s TEST 1 vectors), publishes thek=ed25519record shape (p=is the raw 32-byte key base64, not DER SPKI), and pins the pass toEd25519Sha256; and[spooler.dkim]entries gained the optionaled25519_selector+ed25519_key_filepair (both-or-neither, validated at load; default off = rsa-only unchanged), emitting a second ed25519-sha256DKIM-Signatureover the same headers alongside the rsa one — RSA-only verifiers simply keep honoring the RSA signature. Credits ride the standards.md RFC 6376 row, the conformance appendix, and the configuration reference. Decision-inventory entry 4 below records the answer. - RFC 8553. Same shape as the cluster-5 row: DKIM’s lookup names are
already the registered AttrLeaf forms, and they live in the dependency —
mail-authqueries{selector}._domainkey.{domain}(dkim/verify.rs:105) and_report._domainkey.{d}for reporting (dkim/verify.rs:204). No repo code hand-rolls the names. Recommendation: decline — nothing to change or cite; this reconciles with cluster 5’s identical verdict, closing the 8553 overlap. - RFC 8616. Identical evidence to the cluster-5 row, which audited the
same dependency:
mail-auth0.11.1 has noidnadependency and performs no U-label→A-label conversion anywhere, and SMTPUTF8 is disabled (driver.rs:168) so no EAI identity reaches the DKIM verifier either. Recommendation: defer (EAI) — decided with the whole EAI family, never alone.
Cluster 7 — ARF (bases: RFC 5965, 6591)
Owning surfaces: generation only. The DMARC forensic (RUF) path builds one
RFC 6591 auth-failure ARF report per DMARC failure —
build_forensic_report (mail_spooler/src/pipeline/dmarc_forensic.rs:56)
over mail_auth::report::Feedback, serialized by the dependency’s
to_rfc5322 into the RFC 6522 multipart/report container. There is no
ARF ingestion path: nothing in the repo calls Feedback::parse (the
aggregate-report ingester, adapters/dmarc_rua.rs, consumes DMARC XML, not
ARF). Coverage assertions: standards.md’s RFC 5965 and 6591
rows.
| Update | What it is | Audited status | Verdict |
|---|---|---|---|
| RFC 6650 | ARF applicability statement (when reports may be sent) | satisfied by construction — the only generation path is solicited-by-publication; now cited and credited | adopted (wave-set #49 lane B) |
| RFC 6692 | Source-Port report field | implemented — the peer port now threads from the driver seam into with_source_port | adopted (wave-set #49 lane B) |
| RFC 9991 | DMARC failure reporting (updates 6591) | adopted at #44/#45 (DMARCbis) | done |
- RFC 6650 — adopted in wave-set #49 lane B, as the recommended
citation/fence. Its core normative addition to 5965 is consent: feedback
reports go only where the receiving party has asked for them. The repo’s
single generation path satisfies that by construction —
ruf=targets are addresses the policy domain itself published, and out-of-domain targets additionally pass the RFC 9991 §5 external-destination gate (authorized_report_uris,dmarc_forensic.rs:133) before any report is sent. No abuse-type feedback loop exists to regulate, and no reports are received. Method: read the forensic module’s gate; grep for otherFeedbackTypeconstruction sites (there are none). The credit now rides the standards.md RFC 6591 row and the conformance appendix, and the forensic module doc names 6650 as the applicability statement governing theauth-failureARF usage — no behavior change. - RFC 6692 — adopted in wave-set #49 lane B, as the recommended small
implementation. It registers the optional
Source-Portfield so auth-failure reports can attribute a connection behind carrier-grade NAT. The dependency was ready:Feedback.source_portexists (vendoredreport/mod.rs:338),with_source_portsets it (report/arf/mod.rs:202), and the ARF serializer emits it whenever it is nonzero (report/arf/generate.rs:228-229). The missing seam is now threaded: the driver captures the peer’s TCP source port inSenderContext(peer_port,smtp_server/src/driver.rs:606), the forensic datum carries it (DmarcForensicInput::source_port,smtp_server/src/policy.rs:261), andbuild_forensic_reporthands it towith_source_port. Skip-when-zero is deliberate and fenced (zero_source_port_omits_the_field): an input that never learned the port omits the field entirely rather than reporting a fakeSource-Port: 0. The disclosure question was surfaced at the wave’s planning and answered — always emit when known, deliberately no config switch (decision-inventory entry 5 below):Source-IPis already unconditionally disclosed in the same report, so the port adds no new disclosure class. - RFC 9991 — done. Adopted by the DMARCbis waves (#44/#45): the
forensic path is already 9991-shaped —
Identity-Alignment, headers-only content minimization per §7.1, and the §5 external-destination gate (module doc,dmarc_forensic.rs:1-30). The row records the fact; nothing is open.
Cluster 8 — IMAP (bases: RFC 3501, 2342)
Owning surfaces: protocol semantics in imap_session over the adopted
imap-types 2.0.0-alpha.7 AST (the repo enables its starttls,
ext_namespace, and — since wave-set #49 lane A — ext_condstore_qresync
features, workspace Cargo.toml:124), driven by imap_server over the
adopted imap-next 0.3.4 flow layer. The crate’s own RFC block and
deliberate-deferral list live in imap_session/src/lib.rs — IMAP4rev2
(RFC 9051) is deliberately not advertised there, with the rev2 mandatory
baseline (ESEARCH, LIST-EXTENDED) and, since the CONDSTORE landing, the
QRESYNC half of RFC 7162 listed as deferred. Every update row below
therefore starts with an imap-types support check, per the planning note.
Recorded gap — closed in wave-set #48 lane B:
imap_server/src/lib.rsnow carries the “RFCs implemented” block its two sibling drivers already had (pop_server/src/lib.rs:7,smtp_server/src/lib.rs:7). Rather than duplicating the session crate’s RFC list (imap_session/src/lib.rs:10, which exists and is accurate), the driver’s block points at it and credits the driver layer’s own obligations: imap-next wire framing, implicit TLS/STARTTLS overserver_common, SASL execution, storage effects.
The RFC 5738 and 6858 deferrals are now themselves a recorded decision (wave-set #51 lane C): the EAI program is deferred with criteria — parked until a concrete demand signal (an operator or user needing non-ASCII addresses, or interop with an EAI sender); no implementation now. The ready-made
ext_utf8types stay ready, not used. Decision-inventory entry 7 is the governing record.
| Update | What it is | imap-types 2.0.0-alpha.7 support | Audited status | Verdict |
|---|---|---|---|---|
| RFC 4466 | collected extension ABNF | modeled implicitly (e.g. Capability::Other) | grammar infrastructure; no standalone behavior | decline (nothing to do) |
| RFC 4469 | CATENATE | absent (zero hits) | fork-blocked; pairs with declined BURL | decline (final) (wave-set #48 lane B) |
| RFC 4551 | CONDSTORE (obsoleted by 7162) | present behind ext_condstore_qresync (now enabled) | implemented at its RFC 7162 target — CONDSTORE in full, mod-sequences on every store backend; the QRESYNC half stays queued | adopted: CONDSTORE (wave-set #49 lane A; QRESYNC queued) |
| RFC 5032 | SEARCH WITHIN (OLDER/YOUNGER) | absent (no such search keys) | fork-blocked, marginal value | decline (final) (wave-set #48 lane B) |
| RFC 5182 | SEARCHRES (saved search results) | absent (no ESEARCH at all — prerequisite RFC 4731 missing) | fork-blocked; rev2-baseline adjacent | decline (falls with the answered strategy — see below) |
| RFC 5738 | UTF8= (obsoleted by 6855) | present behind ext_utf8 (Utf8Kind::{Accept,Only} — the 6855 shape) | EAI | defer (EAI) |
| RFC 6186 | SRV service discovery | n/a — no protocol surface | operator side already documented; client side superseded by the signed DHT records | decline (answered as superseded, both halves — wave-set #48) |
| RFC 6858 | EAI message downgrading | absent | EAI | defer (EAI) |
| RFC 7817 | TLS server identity check | n/a | covered by the landed cluster-3 adoption | adopted (cluster 3, wave-set #48 lane A) |
| RFC 8314 | TLS before credentials | n/a | implemented and credited (imap_session/src/lib.rs:20-21) | done |
| RFC 8437 | UNAUTHENTICATE | absent (zero hits) | fork-blocked, niche | decline (final) (wave-set #48 lane B) |
| RFC 8474 | OBJECTID (MAILBOXID/EMAILID/THREADID) | absent (zero hits) | fork-blocked; modern-sync bundle | decline (final) (wave-set #48 lane B) |
| RFC 8996 | deprecates TLS 1.0/1.1 | n/a | covered by the landed cluster-3 adoption (shared acceptors) | adopted (cluster 3, wave-set #48 lane A) |
Method for the support column: grep the vendored imap-types source for the
extension’s identifiers (Older/Younger, Catenate, UNAUTHENTICATE,
ObjectId/MAILBOXID, ESEARCH, rev2/9051) — each “absent” above is a
zero-hit result, 2026-08-05 — and read the Capability enum
(response.rs:1058) plus the extensions/ module list and feature table
(vendored Cargo.toml:55-66).
- RFC 4466 (and 2342 ← 4466). Pure ABNF infrastructure — it defines the
extension grammar other RFCs reuse and changes no protocol behavior by
itself; its 2342 update is the same story on the NAMESPACE grammar.
imap-typesmodels extensibility structurally (unknown capabilities viaCapability::Other,response.rs:1121). Recommendation: decline — nothing to do; 4466 is “adopted” only ever through the extensions built on it. - RFC 4469 (CATENATE) — declined, final (wave-set #48 lane B). No types
in
imap-types— the same fork-blocked bucket as URLAUTH (cluster 2’s BURL row); and CATENATE’s purpose (compose from existing message parts without re-upload) is the BURL use case this stack already declined in favor of the account API compose endpoint. With the strategic question answered as piecemeal, the session #10 decision not to forkimap-typesis final; the decline is recorded in-crate inimap_session’s new “Declined (final)” scope-doc section alongside 5032/8437/8474. - RFC 4551 (CONDSTORE) — the queued wave landed its CONDSTORE half:
adopted in wave-set #49 lane A, at the RFC 7162 target the answered
strategy chose (piecemeal — decision-inventory entry 1, wave-set #48
lane B; 7162 obsoletes the inventory’s 4551). What landed: the
imap-typesext_condstore_qresyncfeature flip, the fullimap_session/imap_serverCONDSTORE surface (auth-gated capability, ENABLE,SELECT/EXAMINE (CONDSTORE),HIGHESTMODSEQ/NOMODSEQon select, STATUS, SEARCH MODSEQ, the MODSEQ fetch item + CHANGEDSINCE, STORE UNCHANGEDSINCE withMODIFIED), and real mod-sequence state — per-mailbox highest-mod-sequence plus per-message mod-sequences on all sixmail_storebackends (sqlite migration 0013, postgres 0011), production mailboxes always tracked soNOMODSEQnever appears in production. Coverage assertions moved together per the #48 rule: standards.md’s new RFC 7162 row (and the 9051 row’s note), the conformance appendix, andimap_session/src/lib.rs’s RFC block. Two residuals stay open: QRESYNC is still queued as its own follow-up wave (behind an upstream maturity probe of the imap-types QRESYNC grammar; its parameters are refused BAD today), and a confirmed upstream imap-codec defect — 2.0.0-alpha.9 serializes the FETCH data itemMODSEQ 4where 7162’sfetch-mod-resprequiresMODSEQ (4); the typed emission is correct, and an upstream fix or pin bump is queued before client-conformance testing (recorded in the conformance appendix). - RFC 5032 / 8437 / 8474 — declined, final (wave-set #48 lane B). All
fork-blocked on
imap-types(zero hits each): 5032 (SEARCH OLDER/YOUNGER) is marginal, 8437 (UNAUTHENTICATE, connection reuse for proxies) is niche, 8474 (OBJECTID) is the JMAP-era resync feature set. With the strategy answered as piecemeal, the session #10 fork decline is final for all three — recorded inimap_session’s “Declined (final)” section with 4469. RFC 5182 falls with the same answer without joining the final list: it needs ESEARCH (RFC 4731), absent entirely and part of the rev2 mandatory baselineimap_sessiondefers, so under piecemeal it stays out — but it is rev2-baseline machinery rather than a standalone extension, so it would return only inside a future 9051 program (a new decision, were one ever opened), never by reopening this one. - RFC 5738 / 6858. Both EAI: 5738 (obsoleted by RFC 6855) is UTF-8
mailbox/header support, 6858 is the downgrade path for legacy clients.
imap-typesalready carries the 6855-shaped types behindext_utf8(Utf8Kind::{Accept,Only},extensions/utf8.rs:17-20) — useful when the EAI program runs, irrelevant before. Recommendation: defer (EAI), decided with the whole family (clusters 4, 5, 6, 10). - RFC 6186 — the IMAP half of the shared decision is answered: declined
as superseded (wave-set #48 lane B; the POP3 half landed in lane C of
this same wave-set). The record mirrors cluster 9’s: the operator half
stands as adopted — the
DNS setup SRV section
publishes
_imaps/_imapguidance with the RFC 8314 implicit-TLS variants preferred, and third-party mail clients remain served by those published records. But SithBit’s own tooling will not consult SRV for IMAP: thenode_certsigned DHT service records (surfaced assithbit discover) are signed and wallet-anchored, which plain SRV can never be, so SithBit-native clients trust only them. With both halves recorded, decision-inventory entry 2 below is answered. - RFC 7817 / 8996 — adopted via cluster 3 (wave-set #48 lane A). The
acceptors all three servers ride are shared (
server_common/src/tls.rs), so the landed cluster-3 evidence and fences cover the IMAP surface wholesale: the standards.md transport-hardening table’s 7817/8996/8997 rows explicitly speak for every acceptor and connector, which is why the IMAP per-protocol table carries no duplicate rows — neither do the SMTP or POP3 tables. 7817’s identity-check duties on the 2595/3501 side bind third-party clients, not these servers (the stack’s only email TLS client is outbound SMTP). Cite the cluster-3 record; never re-audit.
Cluster 9 — POP3 (bases: RFC 1939, 2449)
Owning surfaces: the hand-rolled pop3_proto typestate machine (the one
protocol core not riding an adopted grammar crate), driven by pop_server.
Its RFC block (pop3_proto/src/lib.rs:8-16) credits 1939, 2449, 2595, 5034,
3206, and — notably for the EAI family — RFC 6856 (POP3 UTF-8 support,
UTF8/LANG), which is not in this cluster’s update graph (6856 is not an
official 1939 updater) but means the POP3 surface is already EAI-positioned
ahead of every other protocol in the stack.
| Update | What it is | Audited status | Verdict |
|---|---|---|---|
| RFC 1957 | 1939 implementation notes | already implemented, previously uncredited and unfenced | adopted (wave-set #48 lane C) |
| RFC 2449 | POP3 extension mechanism (CAPA) | implemented and credited | done |
| RFC 6186 | SRV service discovery | operator side already documented; client side superseded by the signed DHT records | decline (POP3 half answered, wave-set #48 lane C) |
| RFC 8314 | TLS before credentials | implemented and credited (implicit TLS primary; cluster-3 evidence) | done |
| RFC 5034 | POP3 SASL AUTH (updates 2449) | implemented and credited | done |
- RFC 1957 — adopted in wave-set #48 lane C, as a citation/fence pass
rather than an implementation: the behavior was found already present but
uncredited. Of its two short implementation notes on 1939 — clients poll
too aggressively (a client-side scold with no server duty), and the
actionable half, that real clients depend on the optional UIDL command so
servers should provide it —
pop3_protoalready did the latter:Command::Uidl(command.rs:95-96, both list and per-message forms), theUIDLCAPA tag (capability.rs:23-24), and the maildrop’s per-message UID scan (uidl_all,maildrop.rs:74). What landed: the 1957 credit line inpop3_proto’s RFC block (lib.rs), the standards.md POP3 table row, and the fence testuidl_for_one_message_follows_rfc_vector(session/tests.rs) asserting RFC 1939’s own UIDL example on the per-message success path (transaction.rs::uidl_one) — which the adopting wave’s scout found had no test in either POP crate. No behavior changes. - RFC 2449 / 5034 / 8314 — done. All three credited in the crate’s RFC
block (
lib.rs:8-16) and implemented: CAPA with limits and response codes, the SASLAUTHexchange (mechanisms inserver_common::auth), and the TLS-before-credentials posture whose acceptor-level evidence is cluster 3’s (sharedserver_commonacceptors; implicit TLS on 995 primary,STLSsecondary). - RFC 6186 — the POP3 half of the shared decision is answered: declined
as superseded (wave-set #48 lane C). The operator half stands as
adopted — the
DNS setup SRV section
publishes
_pop3s/_pop3guidance with 8314 preference, and third-party mail clients remain served by those published records. But SithBit’s own tooling will not consult SRV for POP3: thenode_certsigned DHT service records (surfaced assithbit discover) are signed and wallet-anchored, which plain SRV can never be, so SithBit-native clients trust only them. The decision-inventory entry below spans both protocols and was marked answered when cluster 8’s docs phase recorded the IMAP half — lane B of this same wave-set.
Cluster 10 — Message format / MIME (bases: RFC 5322, 2045, 2047)
Owning surfaces: parsing and generation are fully delegated to the adopted
mail-parser 0.11.5 and mail-builder 0.4.4, re-exported through
mail_message (its scope doc, mail_message/src/lib.rs:8-10, records the
division). Coverage assertions: the four MIME rows in
standards.md. As planned, the wave shape is verify the
libraries.
The RFC 6532 deferral is now itself a recorded decision (wave-set #51 lane C): the EAI program is deferred with criteria — parked until a concrete demand signal (an operator or user needing non-ASCII addresses, or interop with an EAI sender); no implementation now. Decision-inventory entry 7 is the governing record.
| Update | What it is | Audited status | Verdict |
|---|---|---|---|
| RFC 6854 | group syntax allowed in From:/Sender: | parse side present; generation side conformant by never emitting groups; credit landed | adopted (wave-set #48 lane A; DMARC-edge fence pinned in wave-set #49 lane B) |
| RFC 2231 | MIME parameter continuations + charset (via 2184; updates 2045/2047/2183) | decode done; encode side diverges in mail-builder with no repo emission path | done (with residual recorded) |
| RFC 6532 | internationalized headers (via 5335 → 6532) | parse-side support claimed by mail-parser; adoption is the EAI program | defer (EAI) |
- RFC 6854. Its parser-side duty (accept group syntax where 5322 only
allowed mailbox lists) is met by the dependency:
mail-parsermodels groups first-class (Group, vendoredlib.rs:183;HeaderValue::Group,lib.rs:309-310). Its generator-side duty is a restriction — group syntax inFrom:is reserved for limited cases (e.g. MDN-suppressing notifications) — and the repo conforms by construction: no call site usesmail-builder’s group support (Address::new_group, vendoredheaders/address.rs:48— repo-wide grep fornew_group: zero hits), so every generatedFrom:(DSNs, DMARC reports, submissions) is a singleton mailbox. Adopted in wave-set #48 lane A: the credit rides the standards.md 5322 row as recommended. The residual closed in wave-set #49 lane B — a fence pinned the behavioral edge the audit recorded as current behavior: an empty-groupFrom:(undisclosed-recipients:;) yields no RFC5322.From domain, so DMARC evaluation collapses to the default output and the message reaches the Inbox even against a publishedp=reject. The hardening question that fence left open was then answered in wave-set #50 lane B, from RFC 9989’s own guidance (DMARCbis — RFC 9989 obsoletes 7489/9091; 9990 is aggregate reporting, 9991 failure reporting): §5.3.1 terminates DMARC without a verdict on zero or multiple extracted From domains, its MAY for the multi-domain case deliberately not taken, and §4.4 places such non-compliant From fields outside the spec’s scope — so the edge is now an explicit disposition, not an incidental bypass. The fence was renamedgroup_from_terminates_dmarc_without_verdictand gained a multi-differing-From sibling; the conformance appendix records the disposition. - RFC 2231 — done, with one refinement to the claims snapshot. The
decode half is genuinely done:
mail-parserimplements the full parameter-continuation machinery (theContinuationaccumulator and reassembly inparsers/fields/content_type.rs:25-45andcontent_type.rs:160-205), covering split, charset-tagged, and language-tagged parameters. The encode half diverges:mail-builderwrites non-ASCII parameter values as RFC 2047 encoded-words inside the parameter (rfc2047_encodeatheaders/content_type.rs:69) — the widespread de-facto compatibility convention, not 2231*=continuations. That divergence has no repo emission path today: generated messages (DSNs, ARF, aggregate reports) carry only ASCII parameters, and submission relays user bytes untouched. Method: grep2231/continuationin both vendored crates; read the builder’s parameter writer. Recommendation: keep done; record the encode-side residual so an EAI or attachment-features wave knowsmail-builderneeds upstream work (or post-processing) before emitting non-ASCII filenames. - RFC 6532.
mail-parserclaims 6532 conformance for parsing (vendoredREADME.md:214— UTF-8 header tolerance is native to its design), but adopting internationalized headers end-to-end is the EAI program: SMTPUTF8 is off (driver.rs:168), so no 6532 message can enter the system, and the generation, storage, and auth surfaces (clusters 4, 5, 6) all gate on the same decision. Recommendation: defer (EAI) — the cluster-4 rule binds here too: the EAI family (6530–6533, 8616, 6855/6858, and SMTPUTF8 itself) is one program, decided once.
Cross-cluster overlaps and wave-shaping notes
This page exists so no update is half-adopted from one cluster’s vantage point. The overlaps to plan around:
- 8553 + 8616 appear in both SPF (cluster 5) and DKIM (cluster 6) —
now closed: both clusters audited to the same verdicts on the same
evidence (8553: the underscored names are already the registry forms,
inside
mail-authon the DKIM side; 8616:mail-authhas no idna and SMTPUTF8 is off). No reconciliation left to do. - 7817 + 8314 + 8996/8997 cut across SMTP-TLS (cluster 3), IMAP
(cluster 8), and POP3 (cluster 9): the cluster-3 evidence (rustls choke
points, webpki identity checks) covers all three servers because the
acceptors are shared in
server_common, and the cluster-8/9 rows now cite it rather than re-audit — any citing wave should keep that shape. - The EAI family is one program, decided once — and the decision is
recorded: deferred with criteria (wave-set #51 lane C;
decision-inventory entry 7). Its rows span five clusters: 6533
(DSN, cluster 4), 8616 (SPF cluster 5 and DKIM cluster 6),
5738/6855 + 6858 (IMAP, cluster 8), 6532 (MIME, cluster 10),
plus SMTPUTF8 itself (the structural gate at
driver.rs:168). Two ready-made pieces to reuse if it ever runs:imap-typesalready ships the 6855-shapedext_utf8types, andpop3_protoalready implements RFC 6856 (POP3 UTF-8) — the POP3 surface is EAI-positioned before the rest of the stack. - 4466 spans both IMAP bases (3501 and 2342) — audited once in cluster 8 (decline, grammar infrastructure). 6186 appears in both IMAP and POP3 and is one decision, not two: its operator half is already adopted (the docs SRV section), and its client half — the shared decision-inventory entry — is answered in wave-set #48 (declined as superseded, both halves recorded).
- Lane scheduling — cluster pairs that can never run concurrently
(write-footprint overlaps, from #47 planning plus this page’s audits):
- Email TLS (3) × SMTP core (1) on
smtp_session/src/lib.rs+mail_spooler— the known pair from planning. - SPF (5) × DKIM (6) × ARF (7): all three converge on
smtp_server/src/policy.rs(the SPF/DKIM/DMARC policies and the forensic input live in one file) and on the sharedmail-authdependency surface; cluster 7 additionally ownsmail_spooler/src/pipeline/dmarc_forensic.rs, and its 6692 row would also touchsmtp_server/src/driver.rs— which SMTP core (1) and enhanced codes (2) also edit. - SMTP core (1) × enhanced codes (2) × DSN/MDN (4): pairwise
overlaps on
smtp_session(reply.rs,session/ready.rs) andmail_spooler(pipeline/dsn.rs,relay.rs); email TLS (3) also meets them insidemail_spooler. - IMAP (8) and POP3 (9) are disjoint from each other and from the
SMTP-side clusters in code (
imap_session/imap_servervspop3_proto/pop_server) — the cleanest concurrent-lane candidates — except through the shared docs sync sites below andserver_common(owned by cluster 3’s evidence; neither 8 nor 9 should edit it). - Every cluster touches the three coverage-assertion sync sites
(standards.md, the
conformance appendix, the owning
crate’s
lib.rsRFC block) pluschange-history.md— treat those as hot files: concurrent cluster lanes must leave them to the main loop or serialize the docs edits.
- Email TLS (3) × SMTP core (1) on
User decision inventory
The adopt/decline questions each future cluster wave must surface at its planning — stated here as decisions with options and stakes, deliberately not answered. Nothing below is decided by this page; an entry is marked answered only once an adopting wave has landed the decision, and then it records which option was taken.
- IMAP strategy: stay on RFC 3501 (IMAP4rev1) or move toward RFC 9051
(IMAP4rev2)? — ANSWERED: option (a), piecemeal (wave-set #48 lane B).
The entry was narrowed before it was answered: option (b)’s
fork-or-heavily-contribute path was already declined in session #10, so
the live choice was (a) vs (c). The question as recorded: 3501 is
obsoleted by 9051;
imap_sessionimplements IMAP4rev1 plus individually-advertised extensions and deliberately does not advertise rev2 (imap_session/src/lib.rs:23-26). The vendored dependency constrains both paths:imap-types2.0.0-alpha.7 has no IMAP4rev2 support at all (zero hits forrev2/9051; theCapabilityenum’s only base isImap4Rev1,response.rs:1059), no ESEARCH (the rev2-mandatory RFC 4731), and no URLAUTH (the already recorded fork-blocked bucket) — while it does hold ready types for CONDSTORE/QRESYNC (ext_condstore_qresync) and UTF8= (ext_utf8). Options were: (a) piecemeal 3501 updates only whereimap-typesalready has types (CONDSTORE/QRESYNC is the one real candidate); (b) target 9051, which means forking or heavily contributing toimap-typesand implementing the rev2 mandatory baseline (ESEARCH, LIST-EXTENDED — exactly the listimap_sessiondefers today); (c) stay put — rev1 plus the current extension set is what real clients interoperate with. Taken: (a) — a dedicated RFC 7162 implementation wave (theimap-typesfeature flip,imap_sessionsemantics,imap_server/backend MODSEQ state) has joined the queue, and the fork-blocked declines became final with the answer (cluster 8’s rows above;imap_session’s “Declined (final)” section). The recording itself implements nothing. Status since: that wave’s CONDSTORE half landed in wave-set #49 lane A (cluster 8’s RFC 4551 row above); QRESYNC remains queued as its own follow-up wave. - POP3/IMAP client-side discovery: RFC 6186 SRV, the SithBit DHT, or
both? — ANSWERED: option (b), declined as superseded (wave-set #48 —
the POP3 half in lane C, the IMAP half in lane B, one decision recorded
in both clusters). The overlap as recorded: 6186 defines SRV lookup
(
_pop3s/_imaps/_submissions) for standard mail clients; SithBit already ships its own discovery —node_certsigned DHT service records surfaced assithbit discover— covering the same “which host and port serves this account” question. The operator half of 6186 is already adopted (the docs SRV section tells deployments to publish the records), so the decision was about SithBit’s own tooling: (a) adopt — teachsithbit discover(or the webclient onboarding flows) to consult SRV as well; (b) decline as superseded; (c) both, with a defined precedence. Taken: (b) — the DHT records are signed and wallet-anchored, which plain SRV can never be, so SithBit-native clients trust only them; the operator-side SRV documentation stands, and the accepted stake is that third-party mail clients stay dependent on operators publishing SRV correctly. - DKIM verify posture for
rsa-sha1(RFC 8301)? — ANSWERED: option (a), the repo-side post-filter (cluster 6, wave-set #50 lane B). The question as recorded:mail-authstill verifiesrsa-sha1signatures, so an inbound legacy signature can producedkim=passcontrary to 8301’s verifier MUST NOT. Options were: (a) add a repo-side post-filter downgradingrsa-sha1-verified results to failure; (b) leave as-is and record the dependency’s behavior (DKIM never rejects standalone here, so the practical blast radius is DMARC alignment input, not message refusal); (c) raise it upstream withmail-authfirst. Stakes: strictness vs interop with unmaintained legacy signers. Taken: (a) —downgrade_rsa_sha1rewrites a verifiedrsa-sha1pass into a failure immediately after everyverify_dkimcall, before the outputs feedAuthentication-Resultsor DMARC alignment, keeping the signature evidence so reporting still names the signing domain. The accepted stake is the strict side: an unmaintained legacy signer’s mail loses its DKIM pass here, which is what the RFC’s MUST NOT demands. Fenced both ways, and one assert deliberately pins thatmail-authstill verifiesrsa-sha1— the retire signal for the filter should a future dependency version close the gap upstream (option (c) remains open to pursue independently; the filter costs nothing meanwhile). Status since: landed in the same wave-set — cluster 6’s RFC 8301 row above records the adoption. - Ed25519 DKIM signing (RFC 8463)? — ANSWERED: option (a), opt-in
dual-signing, default off (cluster 6, wave-set #50 lane B). The
question as recorded: the dependency can sign and generate Ed25519
keys; the repo’s
[spooler.dkim]was RSA-only. Options were: (a) add an Ed25519 (dual-signing) option — modern, small keys, but requires publishing a second DNS key record per domain and accepting that RSA-only verifiers ignore it; (b) decline until ecosystem verification share justifies the operational surface. Stakes: config and key-management surface vs standards momentum. Taken: (a) — each[spooler.dkim]entry accepts the optionaled25519_selector+ed25519_key_filepair (the same file-or-secret-manager key source askey_file; a PKCS#8 PEM fromopenssl genpkey -algorithm ed25519), validated both-or-neither at load. Configured, the spool emits a second ed25519-sha256DKIM-Signatureover the same headers alongside the rsa-sha256 one, so RSA-only verifiers simply keep honoring the RSA signature; absent, signing is rsa-only unchanged — the operational surface is only bought where an operator publishes the second DNS record (v=DKIM1; k=ed25519; p=<raw-32-byte-key-base64>, not DER SPKI). Status since: landed in the same wave-set — cluster 6’s RFC 8463 row above records the adoption. - ARF
Source-Port(RFC 6692)? — ANSWERED: implement, always emit (cluster 7, wave-set #49 lane B). The question as recorded: the recommended small implementation (thread the peer port through the policy seam) sends new information — the client’s TCP source port — in outbound forensic reports; confirm that disclosure is wanted before building, the alternative being to decline a field whose entire purpose is NAT-era attribution. Taken: implement, emitted whenever the port is known, with deliberately no config switch —Source-IPis already unconditionally disclosed in the same report, soSource-Portadds no new disclosure class; an unknown (zero) port omits the field entirely rather than reportingSource-Port: 0. - Pin
.use_rustls_tls()on the two reqwest fetchers? — ANSWERED: option (a) (wave-set #48 lane A). The question as recorded: the MTA-STS fetcher and TLS-RPT submitter rode rustls only via reqwest’s feature defaults plus the lockfile. Options were: (a) pin in code — one line each, makes the 8996/8997 floor an asserted property; (b) fence with a dependency-graph test instead; (c) accept the inherited guarantee. Taken: (a) — both sites (HttpsPolicyFetcher::production_builderandTlsRptReportWorker::new) now call.tls_backend_rustls(), reqwest 0.13.4’s current name foruse_rustls_tls, closing the exposure to a silent backend change on a future reqwest upgrade. - The EAI program (RFC 6530–6533 internationalized email): adopt,
decline, or defer? — ANSWERED: option (c), defer with criteria
(wave-set #51 lane C — decision-recording only, nothing implemented).
The question as recorded: five clusters carry defer (EAI) rows —
6533 (DSN, cluster 4), 8616 (SPF cluster 5 and DKIM cluster 6),
5738/6855 + 6858 (IMAP, cluster 8), 6532 (MIME, cluster 10) — all
gated on the same structural switch (SMTPUTF8 deliberately disabled,
driver.rs:168), and the overlap map’s rule is that the family is one program, decided once, never row by row. Options were: (a) adopt — open the EAI program as its own wave program (SMTPUTF8 on, then the 6532 header, 6533 DSN, 8616 auth, and IMAP/POP UTF-8 surfaces, with 8616 an upstreammail-authquestion first); (b) decline outright — close all five rows as declined and stay ASCII-only permanently; (c) defer with criteria — park the program, unopened, behind a named demand signal. Stakes: a program-sized adoption spanning five clusters plus an upstream dependency gap, against no demonstrated demand today — but an outright decline would discard the ready-made pieces already in place (imap-types’ 6855-shapedext_utf8types,pop3_proto’s RFC 6856 support) and foreclose a future interop need. Taken: (c) — deferred until a concrete demand signal: an operator or user needing non-ASCII addresses, or interop with an EAI sender. No EAI implementation now; the five cluster rows keep their defer (EAI) tags with this entry as their governing record, and reopening the question requires the demand signal, not a new audit.
JMAP feasibility — 2026-09
Scope: a research artifact assessing what it would take to add a JMAP server surface (RFC 8620 core, RFC 8621 mail, and the eight published extension RFCs) to this stack. It records what the specs demand, what the storage kernel can answer today, where the design collides with sealing mail to the recipient, how widely JMAP is actually deployed, and what a build would cost. The recommendations are inputs to a future planning decision, not decisions — nothing on this page changes code, and no adoption is settled by its appearing here. It is the structural sibling of RFC-updates recon — 2026-08 and the two dependency audits.
Method: the spec inventory came from jmap.io/spec/index.html and was
cross-checked against the IETF datatracker for the two documents still in
draft. Every requirement quoted below was read from the RFC text itself
(rfc-editor.org/rfc/rfc8620.txt, rfc8621.txt), not from a summary. Every
claim about this repo traces to a trait definition, migration or route read
on 2026-09-01; the two absence claims that carry the most weight were
established through scripts/verify-absent.sh with a passing positive
control rather than a bare grep, per the repo’s verification discipline.
Cost language is ordinal — no wave was planned and no scheduling analysis
was run.
The verdict in three sentences
JMAP is technically feasible here and the HTTP substrate is a genuinely good fit, but roughly half the work is not JMAP at all — it is backend capability the storage kernel has never needed, principally an account-scoped change log with tombstones. One part is not an engineering cost at all: JMAP assumes the server can parse every message, which for sealed-at-rest accounts is only true inside a live session holding a reading secret. That is a question about what SithBit is, and it has to be answered before any schema is designed.
The specification inventory
Ten JMAP documents are published as RFCs. Two more are still Internet-Drafts as of 2026-09-01, verified against the datatracker rather than trusting jmap.io’s own labelling.
| Document | Capability URN | Status |
|---|---|---|
| RFC 8620 — JMAP core | urn:ietf:params:jmap:core | Published |
| RFC 8621 — JMAP for Mail | urn:ietf:params:jmap:mail | Published |
| RFC 8887 — JMAP over WebSocket | urn:ietf:params:jmap:websocket | Published |
| RFC 9007 — MDN handling | urn:ietf:params:jmap:mdn | Published |
| RFC 9219 — S/MIME signature verification | urn:ietf:params:jmap:smimeverify | Published |
| RFC 9404 — Blob management | urn:ietf:params:jmap:blob | Published |
| RFC 9425 — Quotas | urn:ietf:params:jmap:quota | Published |
| RFC 9610 — Contacts (with RFC 9553 JSContact) | urn:ietf:params:jmap:contacts | Published |
| RFC 9661 — Sieve scripts management | urn:ietf:params:jmap:sieve | Published |
| RFC 9670 — Sharing | urn:ietf:params:jmap:principals | Published |
| JMAP for Calendars | not yet assigned | draft-ietf-jmap-calendars-28 |
| JSCalendar 2.0 | n/a (data format) | draft-ietf-calext-jscalendarbis-18 |
An eleventh published RFC belongs in any adoption discussion even though jmap.io does not list it, because it is an IMAP document: RFC 9698, the JMAPACCESS extension for IMAP (Standards Track, January 2025, authored at ICANN and Fastmail). It lets an IMAP server advertise that the same messages are reachable over JMAP with the same credentials — “intended for clients that want to migrate gradually to JMAP or use JMAP extensions within an IMAP client”. It matters here twice: it is the cheapest possible first step for a stack that already ships an IMAP server, and its existence is itself an adoption signal (see below).
How widely JMAP is actually used
Worth stating plainly, because it bears directly on whether the work is worth doing. JMAP is a real, finished, actively-extended IETF standard with a healthy implementation ecosystem — and it has almost no presence at the two places that would make it a compatibility requirement: large mail providers and mainstream mail clients.
Providers. Fastmail is the origin and the flagship: it offers full JMAP access alongside IMAP/POP/SMTP, and its people author most of the specs. Beyond it, deployment is essentially self-hosted. No hyperscale provider offers JMAP — Gmail, Outlook.com and Yahoo are IMAP-only for third-party access, and Proton’s JMAP request remains an open item on its public feedback forum, still being asked about in 2026.
Servers. The ecosystem here is genuinely healthy. jmap.io lists seven server implementations, of which the substantial ones are Stalwart (Rust, JMAP-native rather than bolted on), Apache James (Java, JMAP in the 3.x series), Cyrus IMAP (JMAP in its 3.x series), and atmail / Group-Office / shipmail / tmail-backend. Two proxies also exist in both directions — a JMAP server fronting an IMAP store, and an IMAP-to-JMAP proxy — which is a fair indicator of where the demand actually sits.
Clients. This is the weak link, and it is the one that matters for the
interoperability argument. jmap.io lists seventeen clients, and the list is
almost entirely FOSS and niche: aerc, meli, Ltt.rs, Sterna Mail, Aria,
Boogie, Cypht, Bulwark, Twake, Mailtemi, Pimsync. Thunderbird, Apple Mail,
Outlook and the Gmail apps are all absent. Thunderbird has JMAP on its
public roadmaps — iOS exploration reported around 80% complete, Android in
exploration, desktop planned after Exchange — but in every case IMAP is being
implemented first and JMAP is explicitly deferred; Thundermail (the Pro
service) is stated to support it from launch. Fifteen client libraries and
SDKs exist across Go, Java, TypeScript, Rust, Python and Perl, so building
a JMAP client is easy. That is not the same as users already having one.
The honest read. JMAP’s install base today is Fastmail plus self-hosters,
and RFC 9698 exists precisely because the migration path everyone expects is
gradual and IMAP-anchored. For SithBit the practical consequence is that
adding JMAP would not in the near term let a user point Thunderbird or
Apple Mail at a mailbox and have it work better — those clients would still
use IMAP. What it would buy is a standards-shaped replacement for the
bespoke /v1/mail REST API that the webmail PWA, the Outlook add-in and the
Thunderbird and Chrome extensions all ride today, and a future-proofed
position for when the mainstream clients do land their JMAP support. Whether
that is worth the cost below is a product judgement, not a technical one.
What a conforming server owes
JMAP is JSON over HTTPS with essentially no ABNF; the difficulty is entirely in the data-model contract, and it lands in three places.
The Session resource (RFC 8620 §2) has a fixed shape: capabilities,
accounts, primaryAccounts, username, apiUrl, downloadUrl,
uploadUrl, eventSourceUrl, state. Three of those are URI Templates
whose variable lists are MUST-carry — downloadUrl must contain
accountId, blobId, type and name; eventSourceUrl must contain
types, closeafter and ping.
The API endpoint takes a batch of methodCalls triplets and returns
methodResponses. Back-references (#-prefixed arguments resolved by JSON
Pointer, plus JMAP’s * flatten rule) let call N consume call N−1’s
actual result — which forces the dispatcher to be a strictly sequential
interpreter over a growing response buffer, never a parallel fan-out.
Six method archetypes apply to every data type: Foo/get,
Foo/changes, Foo/set, Foo/copy, Foo/query, Foo/queryChanges. The
sharp edges are in the archetypes rather than the types. Foo/set is atomic
per record and explicitly not per call, so a batch half-commits into
notCreated/notUpdated/notDestroyed without poisoning what already
landed; its update argument is a PatchObject whose JSON-Pointer paths may
not overlap or point inside an array, with null meaning reset-or-delete;
queryChanges models a result-set delta as an ordered removed/added
list, and its upToId shortcut is sound only when both filter and sort touch
exclusively immutable properties.
The load-bearing requirement is the state-string contract. Each type carries
an opaque state string covering, in the RFC’s words, “all the data of this
type in the account”. If the data changes it MUST change; if not, the server
SHOULD return the same one. Foo/changes then reconstructs from any such
past string which of three buckets each id falls in, with defined collapse
rules for create-then-update, update-then-destroy and create-then-destroy.
RFC 8620 §5.2 is blunt about the escape hatch:
Maintaining state to allow calculation of “Foo/changes” can be expensive for the server, but always returning “cannotCalculateChanges” severely increases network traffic and resource usage for the client. To allow efficient sync, servers SHOULD be able to calculate changes from any state string that was given to a client within the last 30 days.
A server may legally answer every Foo/changes with cannotCalculateChanges
and force a full resync. It would also have discarded the entire reason to
prefer JMAP over the REST API that already exists.
The capability gap
Eleven capabilities checked against the workspace on 2026-09-01.
| Capability | Status | What is there today |
|---|---|---|
Per-account, per-type state + Foo/changes | absent | Two per-mailbox counters, both solid: MailboxRow.highest_modseq (RFC 7162) and change_seq for IDLE. Neither is account-scoped; MailRepo has no “changes since N” method, and IMAP CHANGEDSINCE is an in-memory filter over an already-materialised snapshot (imap_session/src/session/selected.rs). |
| Destroyed-id recovery (tombstones) | absent | MailRepo::expunge deletes the row. Sixteen tables in the schema, none an expunge log. Already a known gap — BACKLOG.md’s QRESYNC entry lists “expunged-UID tombstones” as remaining work. |
Immutable per-message id, mailboxIds as a set | absent | Identity is per copy: MessageRow exposes no id, and copy mints a fresh row (MESSAGE_COPY_ONE) while move_messages preserves one. blob_key is the only cross-mailbox handle, and it is shared across wallets on a multi-recipient delivery — so it cannot be Email.id unmodified without leaking identity between accounts. |
| Threading | absent | No thread ids, no RFC 5256/JWZ threading, no IMAP THREAD. Message-ID / In-Reply-To / References are re-parsed per request into mail_message’s MailSummary. The one persisted Message-ID index, reply_locators, exists for on-chain reply bounties and is never joined to messages. |
| Server-side body search | partial | Real but unindexed: GET /v1/mail/search scans 500 rows newest-first in one mailbox with a resume cursor; imap_session/src/search.rs parses lazily per message. No FTS index anywhere. Email/query is cross-mailbox, sortable and delta-able on top of that. |
| Blob download | present | Three routes already serve bytes, including /v1/mail/messages/{uid}/parts/{part} with a decoded content type and filename. |
| Blob upload | absent | Nothing accepts bytes. Compose has no attachments field, the global body cap is a deliberate 2 MiB constant, BlobStore has no ranged read and stores no content type, and blob keys are global UUIDs with no wallet scoping — a blobId namespace needs authz designed, not just exposed. |
| Push transport | partial | In-process WatchRegistry plus a 2-second change_seq poll for split deployments. Verified absent across the server crates: no SSE, no text/event-stream, no WebSocket. JMAP wants an EventSource endpoint and outbound PushSubscription webhooks — the latter a new outbound-caller class in the security model, with an SSRF guard the RFC mandates. |
| Queued / undoable submission | absent | Spooling is immediate; local rows are visible to IMAP before the response returns. No EmailSubmission object, no undoStatus, no sendAt. The job queue’s visible_at column is the mechanism a delayed send could ride. |
| Identities and vacation responder | absent | Send-as is hardwired to wallet@<first local domain>; chain aliases are readable but not sendable-as, and alias logins were rejected on record. The DND/away schedule is an advisory signal for senders and never sends an auto-reply, which is what VacationResponse means. |
| HTTP + auth substrate | present | axum 0.8, six independently-composed routers merged in main, and SessionAuthed yields wallet + jti + reading secret in one decode. Two frictions: the 2 MiB body constant needs a route-scoped override, and the 413→422 remap in error.rs would fight JMAP’s own urn:ietf:params:jmap:error:limit shape. |
Two of those rows are the whole schedule. An append-only change log per
account and type, retained around 30 days, is new schema on all six storage
backends and a hard prerequisite for both Foo/changes and any push payload,
since a StateChange is a map of per-type state strings. Query-state
tracking for queryChanges is a different problem again — not “did this
record change” but “did the order of an arbitrary client-defined filter and
sort change”, answered incrementally. Nothing in IMAP SEARCH/SORT is an
analogue.
One prior decision bears on this and should be read carefully. RFC 8474
(OBJECTID) — the IMAP extension that would have supplied exactly the stable
EMAILID/THREADID the table twice calls absent — is recorded declined,
final in the RFC-updates recon, which even
names it “the JMAP-era resync feature set”. But the stated reason was that it
is fork-blocked on imap-types, and that reason does not transfer to JMAP,
which would touch none of those crates. The decline is not a precedent
against this work. It does mean the underlying storage capability has never
been built.
The collision: sealed bodies against a spec that parses everything
This is the part that is not an estimate. RFC 8621’s Email is a parsed
object: the server is expected to hand clients typed headers, a MIME body
structure, attachment part lists and a text preview. Its Security
Considerations never contemplate a server holding ciphertext it cannot open;
that case is outside the document’s threat model.
SithBit’s at-rest storage is per-account dual-mode, resolved at spool time by
mail_submit’s at_rest_mode, into AtRest::Plaintext, AtRest::Sealed
or AtRest::OperatorSealed. So “can the server parse this message” is
answered per account, not per deployment. A plaintext-mode account is fully
parseable and the JMAP work over it is ordinary. A sealed account’s body is
an SBd DEK envelope the daemon can open only by unwrapping with a
reading secret — the 32-byte value that arrives with the login, lives in
memory keyed by the JWT’s jti, is zeroized on drop, and dies with the
session.
The consequence is narrower than “JMAP cannot work”, and worth stating
precisely. Inside a live keyed session the server can decrypt, and
Email/get can be served. What is impossible is anything outside such a
session: no background index, no server-side full-text search, no threading
pass over historical mail, and no push payload richer than “something
changed”, because the worker computing it holds no reading secret. One
nuance decides how much this bites: Email/get’s default property list
includes textBody, htmlBody, attachments, hasAttachment and
preview, so a bare Email/get needs the MIME structure even though body
text only ships when the caller sets fetchTextBodyValues. Structure, not
just content, sits behind the seal.
Three ways through, in ascending cost. This is a decision, not an estimate, and it gates the change-log design — a schema built on the assumption that a background worker may read message content is a different schema.
- Serve JMAP for plaintext-mode accounts only. Advertise the mail
capability per account, exactly as RFC 8620’s
accountCapabilitiesis designed to allow, and omit it for sealed accounts, who keep the trustless webmail path that already unseals in the page. Honest, conformant, and it ships — at the price of JMAP being a second-class surface for the accounts the product pitch is built around. - Keep headers outside the seal. Seal only the body. Threading,
Email/queryover from/to/subject, sorting and most of the default property list all become computable in the background. This narrows what sealing protects, and is a privacy-posture change owing its own decision — metadata is most of what surveillance wants. - Client-side index, stored sealed. The keyed client builds the search and thread index and republishes it sealed; the server stores and serves an artifact it cannot read. Preserves the guarantee completely, and is a materially different architecture from “the server implements JMAP” — closer to a sync protocol wearing a JMAP-shaped façade.
Build or adopt
Standing rule 1 is ecosystem-first, so the crate landscape decides much of the cost. Checked live against the crates.io API on 2026-09-01.
| Crate | Version | Downloads | Verdict |
|---|---|---|---|
jmap-server + jmap-mail-server (MarkAtwood/crate-jmap) | 0.1.3 | 444 / 108 | Backend-agnostic, MIT/Apache-2.0, 26 RFC 8621 methods plus optional MDN. The right shape; first published 2026-05. |
mailrs-jmap (goliajp/mailrs) | 1.1.3 | 761 | Store trait is IMAP-shaped and would nearly drop in here — but it implements seven methods and no Foo/changes or Foo/queryChanges at all, which is precisely the half worth having. |
jmap-client (stalwartlabs) | 0.4.2 | 73,688 | Mature, widely used, and a client. Useful for conformance testing, not for serving. |
| Stalwart’s own server JMAP | — | — | Inside the Stalwart monorepo, not published standalone. A reference implementation to read, not a dependency. |
The crate-jmap family is the only credible adoption target, and its
integration surface is small enough to quote in full. JmapBackend requires
six methods — account_exists, get_objects, get_state, get_changes,
query_objects, query_changes — and MailBackend adds ten:
create_object, update_object, destroy_object, import_email,
find_thread_by_message_ids, blob_exists, parse_email, copy_email,
search_snippets, supports_type.
Sixteen methods is a tractable adapter, but note which ones. get_state,
get_changes, query_changes, find_thread_by_message_ids and
search_snippets are all capabilities the gap table marks absent. The
crate does not reduce the backend work; it removes the protocol work and
leaves the backend work fully intact. That is still worth a great deal —
the dispatcher, back-reference resolution, patch semantics and 26 method
handlers are the fiddly, conformance-critical half — but it should not be
mistaken for a shortcut past the schema.
Two frictions belong in any adoption record. jmap-mail-server depends on
mime-tree, a different MIME parser from the adopted mail-parser, so
taking it means either two parsers in the tree or an adapter that skips the
crate’s own. And at 108 downloads, four months old, single-author and pre-1.0
with an explicit “may break across minor versions” note, this is a larger bet
than imap-codec was: the pinned-alpha precedent exists, but that crate had
an established author and a large user base.
The extensions, ranked
Costs assume core and mail already work — the assumption doing most of the work in this table, since today neither does.
| RFC | Cost | For a mail-only server |
|---|---|---|
| 9007 MDN | small | Best value. Its dependencies are the Identity and parse plumbing mail already needs. |
| 8887 WebSocket | small | Valuable, and cheap once push exists — but push does not. |
| 9425 Quotas | small | Valuable; MailRepo::wallet_bytes already computes the number, which needs a per-account read route. |
| 9404 Blob management | medium | Valuable, and gated behind building the upload path at all. |
| 9749 VAPID push | medium | Webmail-PWA only, and large once Web Push infrastructure itself is counted. |
| 9670 Sharing | large | Out of scope — needs a principals/ACL model that does not exist. |
| 9219 S/MIME verification | large | Out of scope near-term; S/MIME is a separate product line here. |
| 9661 Sieve management | very large | Out of scope. The JMAP surface is the cheap part; the Sieve interpreter is the project. |
| 9610 + 9553 Contacts | large | Out of scope for mail. |
| Calendars + JSCalendar 2.0 | very large | Out of scope; both still drafts, on a data model that does not exist. |
A defensible ordering, if it goes ahead
The dependency structure is real — nothing after the first item is worth starting before it lands, because everything later consumes the change log.
- The change log, alone, with no JMAP in the tree. Account-scoped, per-type, append-only, tombstoned, ~30-day retention, across all six backends, with the existing counter-allocation conformance group extended to cover it. Independently valuable: it also unblocks part of the QRESYNC wave, which is stalled partly on the same missing tombstones. If only one thing is ever built from this page, this is it.
- Message identity and threading. A stable per-message id distinct from
(mailbox, uid)and namespaced per wallet, plus a thread id computed at spool time — the patternSealedPin.parent_rfc822_idalready establishes for sealed rows. - The JMAP core surface. Session resource, API endpoint, back-reference
resolution,
/getand/setover Mailbox and Email. - Upload/download and blob authz. Wallet-scoped
blobIds, a route-scoped body limit, ranged reads, a stored content type. This is what finally lets webmail send an attachment. - Query and queryChanges. The hardest piece, and the one most affected by the sealing decision.
- Push. EventSource first; PushSubscription webhooks with the SSRF guard only if a client actually needs them.
Deliberately excluded: identities, vacation responder, submission objects, and every extension except possibly MDN — real work with no dependents.
There is also a much cheaper first move that is not on this list: RFC 9698 JMAPACCESS advertises a JMAP endpoint from the existing IMAP server. It is worthless without a JMAP endpoint to advertise, so it cannot come first, but it is the natural closing step of any JMAP program here and costs almost nothing once one exists.
The decisions this page does not make
- Why JMAP, concretely? The strongest case is not standards coverage but
that every SithBit client today rides a bespoke
/v1/mailREST surface. Given the adoption picture above, JMAP would not in the near term make Thunderbird or Apple Mail work better against a SithBit mailbox — those clients still speak IMAP. If third-party interoperability is not actually wanted, the cost/benefit inverts sharply. - Which sealing posture? The three options above. This gates the schema design and cannot be deferred into the build.
- Is the change log worth building on its own merits? It is the largest item, a prerequisite for everything else, and valuable independently of JMAP — it may deserve to be a work item whether or not JMAP is adopted.
- Adopt a four-month-old, 108-download crate family, or hand-roll?
Ecosystem-first is standing rule 1 and a pinned-pre-release precedent
exists, but that precedent carried a different risk profile. Either way it
would need its own adoption-table row in
DURABLE-RECORD.md.
Known softness
The RFC 8621 analysis behind the collision section was assembled through a
summarizing fetch tool that caps verbatim quotes, so a few of its finer
points are paraphrase-confidence rather than citation-grade. The
classifications it drove are sound and the load-bearing ones were re-read
from the RFC text directly — the Email/get default property list, the
§5.2 retention text, the Session field list and the cannotCalculateChanges
semantics. Its worked examples were not fully captured and should be
re-fetched narrowly before anyone uses them as test fixtures.
Compute-unit consumption
Every SithBit instruction costs compute units (CU) when it executes on-chain. The table below records what each user-facing flow consumed when it was last measured, and the ceiling the integration suite fences it under — a tripwire that catches a program change silently making an instruction materially more expensive.
Values are per transaction as the CLI builds it, so bundled
instructions ride along: mail send carries a ComputeBudget limit
instruction, and mailbox create bundles the wallet’s self-CreateAlias.
| Instruction / flow | CLI command | Measured CU | Fenced ceiling |
|---|---|---|---|
| SendMail (plain wallet-to-wallet) | mail send | 23,618 | 47,000 |
| SendMail (relay from-address) | mail send --from | 29,876 | 53,000 |
| SendMail (with bounty) | mail send --bounty | 23,592 | 47,000 |
| SendMail (reply linkage) | mail send --reply-to | 25,075 | 48,000 |
| ClaimBounty (domained operator-share path) | mail claim-bounty | 24,847 | 48,000 |
| RefundBounty (expiry-gated sender reclaim) | mail refund-bounty | 7,579 | 31,000 |
| DeleteMail | mail delete | 15,111 | 38,000 |
| RefundMail | mail refund | 16,211 | 39,000 |
| CreateMailbox (+ bundled CreateAlias) | mailbox create | 32,085 | 55,000 |
| SetEncryptionKey | mailbox key set | 10,811 | 34,000 |
| RequestCloseMailbox (opens the close timelock) | mailbox close | 13,334 | 36,000 |
| CancelCloseMailbox (request aborted) | mailbox close --cancel | 12,156 | 35,000 |
| FinalizeCloseMailbox (past the timelock; both rents refunded) | mailbox close --finalize | 12,858 | 36,000 |
| CreateFrombox (third-party first purchase, default 9-account reputation tail — reputation-scaled pricing plus the lazy first-use mint of the payer’s sender-reputation PDA) | frombox stamp / frombox update (create-if-absent) | 35,256 | 58,000 |
| CreateAlias | alias create | 14,888 | 38,000 |
| OfferTransferAlias | alias transfer init | 18,894 | 42,000 |
| AcceptTransferAlias (zero-fee, flat fee paid) | alias transfer accept | 13,838 | 37,000 |
| CancelTransferAlias (offer retracted past binding window) | alias transfer cancel | 24,828 | 48,000 |
| ListAliasForSale | alias sell | 20,308 | 43,000 |
| BuyAlias | alias buy | 20,747 | 44,000 |
| CancelAliasListing (listing withdrawn past binding window) | alias sell --cancel | 19,183 | 42,000 |
| SellAlias (open auction) | alias sell --auction | 26,798 | 50,000 |
| BidAlias | alias bid | 19,715 | 43,000 |
| SettleAuction | alias settle-auction | 26,674 | 50,000 |
| AuthorizeDomain | domain create | 15,242 | 38,000 |
| ListDomainForSale | domain sell | 32,553 | 56,000 |
| BuyDomain | domain buy | 19,807 | 43,000 |
| CancelDomainListing (listing withdrawn past binding window) | domain sell --cancel | 16,001 | 39,000 |
| AttestSender (staged DNSSEC-proof verified sender) | domain attest-sender | 322,474 | 345,000 |
| RevokeSenderAttestation (holder-signed close) | domain revoke-attestation | 11,224 | 34,000 |
| SetSenderAttestationFee | postmaster fee attestation | 6,546 | 30,000 |
| SetReputationFloor | postmaster fee reputation-floor | 6,756 | 30,000 |
| CreatePinLease (per-CID storage deposit) | mail lease create | 34,221 | 57,000 |
| ClosePinLease (holder-signed drain) | mail lease close | 7,316 | 30,000 |
| SetPinLeaseFee | postmaster fee pin-lease | 6,962 | 30,000 |
For scale: Solana’s default per-instruction budget is 200,000 CU and the
per-transaction maximum is 1,400,000 CU — every SithBit flow fits
comfortably inside the default budget except the DNSSEC-proof-verified
ones (domain attest-sender above, and the untabled domain authorize /
domain reclaim it mirrors), whose on-chain RSA chain walk is why the
CLI prepends an explicit ComputeBudget limit sized to the proof’s zone
depth — still well under the transaction maximum.
Methodology
The measurements come from the CLI integration suite
(mail_client/tests/api/cu.rs), which runs against a local
surfpool validator with the exact program
binaries from target/deploy/:
- Each flow is driven end-to-end through the compiled
sithbitCLI; the transaction signature is captured from the explorer URL the CLI prints. - The landed transaction is fetched with
getTransactionand the consumption read frommeta.computeUnitsConsumed. - The recorded ceiling is the max measured value plus a 22,500-CU bump-grind allowance, rounded up to the next 1,000 CU. The test asserts every future run stays at or under the ceiling.
The “measured” column is the highest of several samples, and the
allowance exists because consumption is not a constant: it swings in
exact multiples of ~1,500 CU between runs of the same command, because
PDA bump-seed grinding (finding the off-curve address for each fresh
alias, message, or listing) costs one syscall per failed candidate and
the number of candidates depends on the seeds involved. The same
domain sell flow was observed at both 14,553 and 32,553 CU across
runs, and alias transfer cancel swung from 9,816 to 24,828 CU — the
widest spread recorded. The allowance covers a 15-iteration unlucky grind streak, which
keeps the fence effectively flake-free while still tripping on any
program change that adds real work.
To re-measure — after a program change trips a fence, or to refresh the table — build the programs and run the suite with output visible:
cargo build-sbf --manifest-path mail_program/Cargo.toml
cargo build-sbf --manifest-path alias_program/Cargo.toml
cargo build-sbf --manifest-path domain_program/Cargo.toml
cargo test -p mail-client --features postmaster cu -- --nocapture
Each measurement prints as CU measured | <flow> | <units>. Update the
constants in cu.rs and this table together, deliberately — the fence
exists so cost regressions are a recorded decision, never an accident.
The docs gate enforces the pairing: mail_docs/check_cu_rows.py diffs
this table’s measured and ceiling values against the suite’s constants,
both ways, so a re-measured constant cannot leave a stale row behind.
Caveat: all numbers were measured on the current program build in this repository. Different program versions (or a cluster running a different feature set) will consume different amounts; treat the table as a snapshot fenced by the tests, not a protocol constant.
Pre-flight simulation and --skip-preflight
Every mutating sithbit command submits a Solana transaction, and every
one of them accepts --skip-preflight (short -s). This page explains
what the pre-flight step actually does, why the flag exists, and what you
give up by using it.
What pre-flight is
Before an RPC node broadcasts a transaction to the current leader, the
sendTransaction
RPC method first simulates it (unless asked not to): signatures are
verified and the transaction is executed against the node’s view of the
bank at the pre-flight commitment level — the same machinery exposed
directly as
simulateTransaction.
If the simulation fails, the RPC returns the error (with program logs)
and the transaction is never broadcast:
- no fee is charged — a transaction that fails pre-flight costs nothing, while one that fails on-chain still pays its signature fee;
- you get the program logs — the simulated instruction trace usually pinpoints the failing instruction and error code, which is the single most useful debugging artifact the CLI can show you.
sithbit runs pre-flight at the CLI’s configured commitment (the same
commitment it uses to confirm the transaction afterwards).
Why you might skip it
- Racing fresh state. The simulation runs against the RPC node’s view
at the pre-flight commitment, which can lag the tip of the chain. A
transaction that depends on an account mutated moments ago — say, a
script that creates a mailbox and immediately funds a frombox against
it — can spuriously fail simulation even though it would succeed by the
time the leader executes it.
--skip-preflightlets pipelined sequences run without waiting for the earlier write to reach the simulating node. - Latency. Skipping saves the simulation round-trip, which matters when submitting many transactions in a tight loop.
- Simulator disagreements. Rarely, a node’s simulation refuses a transaction the leader would accept (state drift between nodes, or features that behave differently under simulation). Skipping removes the RPC node’s veto.
What it costs
A skipped pre-flight means a genuinely bad transaction reaches the chain: it pays its fee to fail, and the CLI has no simulation logs to show — you get an opaque on-chain error where pre-flight would have printed the program trace. Leave pre-flight on (the default) unless you know exactly why a lagging simulation is refusing a transaction you believe in.
Further reading
sendTransactionRPC reference — theskipPreflightandpreflightCommitmentparameters the flag maps to.simulateTransactionRPC reference — the simulation surface pre-flight uses.- Transaction confirmation & expiration guide — commitment levels, blockhash expiry, and retry strategy, including how pre-flight interacts with them.
Principia Fidei Automatæ
Mathematical Principles of Automated Trust — proving behaviour to the chain
Status: design note / exploratory — with the DNSSEC shadow now IMPLEMENTED on-chain. This is a conceptual treatment of an open problem — how an on-chain program might trust a behaviour (a domain ownership check) rather than a key that vouches for it. The theory below remains exploratory, but its coldest rung (Prop. 4’s DNSSEC shadow, on Prop. 13’s concrete path) is live: a proof-carrying
AuthorizeDomainByProofpath that verifies a real DNSSEC chain-of-trust on-chain and mints the domain with no postmaster signature.What ships and runs today:
- the
PostOfficeaccount carries a governance-set root KSK fingerprint, published by the delegate-gatedSetRootKskinstruction (sithbit postmaster ksk set), which the chain anchors every proof to;- a real DNSSEC witness runs ~2.7–3.1 KiB — past the 1232-byte legacy/v0 transaction packet limit (transaction v1, SIMD-0385, raises the envelope to 4096 bytes, but SithBit’s producers still emit legacy transactions) — so it is staged first into a program-owned buffer PDA (
[PROOF_WITNESS_SEED, payer, blake3(domain)], a 72-byte header + contiguous witness, capped at 8 KiB; see How blake3 hashing works) by a series of chunkedWriteProofWitnesswrites;- the permissionless
AuthorizeDomainByProofinstruction then reads that buffer and runs the verifier —program_common::dnssec::walk_chain:RRSIGcanonicalization (RFC 4034 §6), per-link signature verification across all three DNSSEC algorithms a real ICANN-anchored chain uses — RSA-2048/SHA-256 (algorithm 8) inline via the allocator-freesol_big_mod_expsyscall, and ECDSA-P256 (13) and Ed25519 (15) via Solana’s native precompiles introspected through the Instructions sysvar —DSdelegation checks root → TLD → leaf, validity-window checks, and finally the leaf_solana.authority.<domain>TXTRRSIG— and on success creates theMailDomainwith the proven ed25519 authority (base58 from the TXT). The oldProofVerificationDisabledgate is gone.Operational requirements. A full chain walk runs up to six RSA-2048 modexp verifications and costs 306–312k compute units (measured through the deployed program on a real cluster), far past the 200k default, so any
AuthorizeDomainByProoftransaction must prepend aComputeBudgetset-compute-unit-limit instruction. The verifier callssol_big_mod_exp, so the target cluster must have theenable_big_mod_exp_syscallfeature active — which, as of this writing, is inactive on both devnet and mainnet-beta, and is also not activated by surfpool’s default offline genesis (a local validator needs--features-all). Check the live status withsolana feature status | grep big_mod_exp. Because the loader resolves every syscall in the whole binary at deploy time, this makes the defaultdomain_programbuild (the program that carries the proof path since the domain-registry split) undeployable to those clusters today; see The modexp-free deploy build for the deploy-unblock that gates the proof module out.Algorithm coverage, and the client’s reach. The on-chain verifier handles all three DNSSEC signature algorithms a real ICANN-anchored chain uses — RSA-2048/SHA-256 (algorithm 8), ECDSA-P256 (algorithm 13), and Ed25519 (algorithm 15, RFC 8080, the curve Solana verifies natively) — and the CLI carries every one of them end-to-end:
sithbit domain authorizestages the witness buffer itself (chunkedWriteProofWitness), prepends theComputeBudgetlimit, and for a chain with non-RSA links derives and emits the precompile proofs too — it re-walks the witness client-side through the sameprogram_commonchain walk the program runs, collects each delegated(public key, canonical message, signature)tuple, and attaches one nativeSecp256r1SigVerify/Ed25519SigVerifyinstruction per non-RSARRSIGplus the Instructions sysvar as the authorize instruction’s 6th account. A witness of any supported shape authorizes a domain with no hand-assembled transaction; an all-RSA chain emits none of this and stays in the historical five-account form. The design is permissionless: anyone may stage a witness and submit the authorization, paying the transaction fee, the domain account’s rent, and the same delegate-tuned authorization feedomain createcharges — the proof, not the submitter, is the authority. The CU figures are real-cluster measurements through the deployed.so: the three-zone all-RSA chain consumed 305.7k–311.9k CU across runs and the ECDSA-P256-leaf shape ~230k, its precompile instructions metering zero transaction-budget CU, so the CLI’s 400kComputeBudgetlimit keeps ~28% headroom over the worst measured shape. See the Authorize a domain by proof how-to andHANDOFF.md.The note is written as a treatise, in the Principia’s Definitions → Laws → Propositions → Scholia form. Read it in any order; each Book stands alone, and the Scholia are digressions you may skip or savour.
Preface — the question, honestly stated
An on-chain program is a curious kind of mind. It is immortal, deterministic, and blind. It cannot see the world; it can only see numbers presented to it and check, against a public key, whether a number is a valid signature. From this single faculty — “this account carried a signature that verifies” — Solana builds its entire notion of authority. A program does not know who you are; it knows only that something able to sign for a certain public key consented to this transaction.
Into this world of keys we wish to introduce a deed: the act of verifying
that a domain’s DNS record _solana.authority.<domain> contains a public key
k. On the signed path SithBit smuggles that deed onto the chain by proxy. A
trusted party — the delegate — performs the deed off-chain and then signs,
and the chain accepts the signature as a token standing in for the deed.
This was the whole of the domain-sithbit tension the
custody runbook used to record: to make
the deed automatic, someone must place an admin key hot on an internet-facing
host, because the chain has no way to trust the deed itself, only the key
that vouches for it. (The delegation cutover has since shrunk what that hot
key can do — the delegate can neither sweep funds nor touch ownership — but
the hot key itself remains; the proof path below deletes it from the
authorise flow entirely.)
The commissioning question is therefore this:
How may an off-chain agent prove to an on-chain program — a mind that reasons only in public-key cryptography — that it possesses a behaviour, in the same unforgeable way one proves possession of a private key, so that the program may accept a call which presumes that behaviour?
One tempting first conjecture is elegant: invent a language in which behaviours
are written as canonical byte-sequences, and let an agent prove it has
behaviour B by exhibiting that hash(agent) = hash(B) — the private key made
implicit in the agent’s own binary structure. We honour that conjecture by
taking it apart precisely (it fails, and instructively), and then by rebuilding
its true form, which turns out to be realisable.
Method. In imitation of the Principia we proceed by Definitions, then
Laws, then Books of Propositions with their proofs and Scholia
(commentaries, several drawn from the literature of imagined machines — Asimov,
Star Trek, and their kin). Book I is theory; Book II is the taxonomy of
solutions; an Interlude names a proof that stands outside the language of keys;
Book III applies the whole to SithBit’s actual CreateDomain.
Definitions
-
Def. I — The Verifier. The on-chain program. Its sole native faculty is signature-checking against a known public key, together with deterministic recomputation of its own state (PDAs, account bytes). It has no clock but the chain’s, no senses, no network.
-
Def. II — The Agent. An off-chain entity (a program, a server, a person with a script) that performs deeds in the world and wishes the Verifier to act upon one of them.
-
Def. III — A Behaviour
B. A function from a state of the world to an output:B : World → Output. Our running example isB_dns(world) = ("owns", domain, k)iff the live DNS ofworldbinds_solana.authority.<domain>tok. Note well:Bis not pure. Its value depends on external state the Verifier cannot see. -
Def. IV — The Terminal Fact. The external fact upon which a behaviour’s output depends — here, the actual content of the world’s DNS at an instant, from a vantage. Every behaviour that reaches outside pure computation terminates in a fact.
-
Def. V — A Witness. A datum
wthat lets the Verifier check a fact by its native faculty — i.e., a signature (or chain of signatures) over the fact, verifiable against a key the Verifier already trusts. A fact carries a witness when such awexists. -
Def. VI — Capability vs. Exercise. To have the capability for
Bis to be able to produceB’s output on demand. To have faithfully exercisedBis to have actually produced a particular true output. These are different claims and, we shall see, admit different proofs. -
Def. VII — Attestation. A signature by a third party (hardware vendor, quorum, notary) asserting something the Verifier cannot itself observe — e.g., “the code running here measures to hash H,” or “we, the jury, observed the fact.” Attestation relocates trust; it does not abolish it.
-
Def. VIII — A Bond. Value the Agent stakes, forfeit upon a public proof of its misbehaviour. A bond converts an unprovable promise into a falsifiable and costly one.
-
Def. IX — A Constitution. A machine-checkable specification of an Agent’s intended behaviour, published and cryptographically bound to the Agent’s identity, against which the Agent may later be judged.
-
Def. X — The Blast Radius. The set of powers a compromised key confers. When this note was first written, the postmaster key’s blast radius was the entire network (authorize + deactivate + sweep + retune). The delegation cutover has since split sweep and ownership off to a cold ceremony commitment, shrinking the hot key’s radius to operational-only — exactly the shrinking this Definition names as the practical prize; the proof path shrinks the authorise leg further, to zero.
Axioms, or the Laws of Trust
Law I — The Law of Reduction. A Verifier can accept only what reduces to the checking of a signature against a key it already trusts. Everything a program “believes” it believes because a signature verified. Any scheme for proving a behaviour must, at its last step, hand the Verifier a signature to check. This is not a limitation to be lamented; it is the coordinate system in which all our solutions must be expressed.
Law II — The Law of Opacity (Rice’s wall). No Verifier can certify, from an Agent’s code alone, that the code computes a given behaviour. Any nontrivial semantic property of programs is undecidable (Rice’s theorem). Behaviour is semantic; code is syntactic. The gap is not an engineering inconvenience but a theorem.
Law III — The Law of the Terminal Fact.
A behaviour is provable to a Verifier if and only if its terminal fact carries a
witness. When the fact B observes is itself signed by a key the chain trusts,
“trust the behaviour” collapses (by Law I) into “check the witness.” When the
terminal fact carries no witness — a human’s honest intent, the fairness of a
private coin — no proof of the behaviour exists, and one must retreat to
attestation, plurality, or bond.
Law IV — The Law of Conserved Trust. Trust is never created, only relocated. Every construction below moves the root of trust from one place (a hot operator key) to another (a hardware vendor, a mathematical assumption, the DNS root, an economic majority). The art is not to eliminate the root — impossible — but to move it somewhere smaller, colder, more plural, or already-assumed.
Scholium to the Laws. Law III is the conserved quantity of this whole subject, in the sense Newton meant when he found that momentum is conserved across a collision no matter how intricate the impact. No matter how baroque the machinery of a trust scheme, ask only: what is the terminal fact, and does it carry a witness? If yes, the scheme can be made to work; if no, the scheme is secretly smuggling in an attestor, a quorum, or a bond, and you should find it and price it.
BOOK I — Of Witnessed Facts
Proposition 1 (The Reduction Theorem). Every admissible proof-of-behaviour terminates in a signature check.
Proof. By Law I the Verifier has no other faculty. Whatever intermediate apparatus a scheme employs — enclaves, zero-knowledge circuits, juries — its final gift to the Verifier is a number the Verifier checks against a trusted key. Hence the design of any scheme reduces to a single question: which key, already trusted, signs the last step, and what did signing it require? ∎
Proposition 2 (The Impossibility of the Naïve Hash). An Agent cannot prove faithful exercise of B by exhibiting hash(agent) = hash(B).
Proof, in three cuts.
- The recipe is not the meal. For the Verifier to check
hash(B), the canonical bytes ofBmust be public; hencehash(B)is public, and any party may present it having executed nothing. A hash of code proves knowledge of the source, never faithful execution. (Contrast a signature, which proves possession of a secret the world does not hold.) - Opacity (Law II). Even given the Agent’s true bytes, deciding whether they
compute
Bis undecidable. The test is therefore at once too strict — it rejects an Agent that computesBcorrectly but was compiled differently — and too weak — it accepts an Agent that merely containsB’s bytes yet never calls them, or calls them and discards the result. - The absent world.
B_dnsdepends on live DNS (Def. III–IV). No static artifact — no hash, however canonical — contains the state of the world’s DNS at the instant of asking. The very datum in dispute is not in the code. ∎
Scholium (the rescue). Proposition 2 does not bury the conjecture; it locates its error. The private key was never implicit in the Agent’s binary — it is implicit in the terminal fact. Domain ownership already has a secret key somewhere: the DNSSEC zone-signing key, the TLS certificate key, or operational control over the resolver’s answer. The task is not to invent a code→bytes→hash language, but to notice that the deed ends in a fact that already possesses a key, and to carry that key’s signature to the chain. Book II enumerates the ways. Two of its members (Propositions 6 and 8) vindicate the conjecture’s spirit exactly — one by binding the hash to live hardware, the other by making a secret that can only be derived by actually performing the deed.
Proposition 3 (The Witness Dichotomy). Behaviours partition into the provable and the unprovable by a single test: does the terminal fact carry a witness?
Discussion. This is Law III restated as a working classifier, and it is the most useful single tool in the treatise. Applied to SithBit:
B_dns— provable. DNS ownership terminates in a fact with (at least) three candidate witnesses: a DNSSEC signature chain, a TLS server certificate, or a quorum’s signed observation.- “This MX honestly authenticated the sender before relaying” (the threat model’s fully-trusted-authority concern) — not provable by witness; its terminal fact (an operator’s diligence) carries no key. This is precisely why that page can offer only bonds/reputation as the remedy, not a proof. The dichotomy predicts the shape of the honest answer before we write a line of it.
Scholium — the ladder of witnesses. Not all witnesses are equally cold. Ascending in trust-coldness: (a) a single operator’s signature (today’s delegate, née postmaster — one warm key); (b) an attested enclave’s key (audited code + one vendor); (c) a threshold of independent operators (a plural warm set, no one of which suffices); (d) the DNS root’s own signature chain (a key the fact is already defined by — the coldest, because trusting it adds nothing not already assumed by the word “domain”); (e) a pure mathematical proof (trusting only an assumption about number theory). Book II is, in effect, a climb up this ladder.
BOOK II — Of the Shadows of a Deed
A Verifier cannot hold a deed; it can hold only a shadow the deed casts into the language of keys. There are, we find, seven such shadows worth naming, ordered by the coldness of the trust they require — Law IV’s true measure — each with a Proposition, an honest cost, and a Scholium from the literature of machines.
Proposition 4 — The DNSSEC Shadow: DNS is already a public-key infrastructure.
The running example, “verify DNS record x contains key k,” is a
signature-chain problem wearing a disguise. A DNSSEC-signed zone is a PKI: the
ICANN root KSK is a world-known public key, and the RRSIG records form a
signature chain root → TLD → domain → the _solana.authority TXT RRset.
Crucially, DNSSEC admits Ed25519 (RFC 8080) — the very curve a Solana program
verifies natively.
Construction. Submit the RRSIG chain as instruction data. The Verifier
checks the chain against a governance-rotated copy of the root key, confirms the
TXT RRset binds k to the domain, and authorises. No delegate signs. No
oracle, no enclave, no human. By Law IV the trust root has moved to the ICANN
DNS root — and here is the beauty: that root adds no new assumption, because
the very meaning of “owning a domain” is already defined by that root and its
delegations. This is rung (d), nearly the coldest on the ladder, and the one to
build first.
Honest cost. Only DNSSEC-signed zones qualify (a minority, though a growing one, and often the serious operators); on-chain chain-verification costs compute units; the root key must be rotated by governance when ICANN rolls it (a rare, well-signposted event). Where a zone is unsigned, one must fall back to a warmer shadow below.
Scholium — the golem’s emet. In the legend, a golem is animated by the word אמת (emet, “truth”) inscribed upon it; erase the first letter and מת (met, “death”) remains, and the creature returns to clay. The golem’s authority is a true word, physically borne, revocable by the alteration of a single letter. DNSSEC is the golem done in mathematics: authority is a chain of true signatures physically borne in the instruction data; alter one byte and the whole animating word reads false. The Verifier, like the rabbi, need only read the word — it need not trust the clay.
Proposition 5 — The Quorum Shadow: trust plurality, not any one binary.
Abandon the single Agent. Let N independent operators run the audited check
from different network vantages, each signing its observation; the Verifier
authorises on a t-of-N threshold of agreeing signatures. “Prove you have
behaviour B” becomes “B is what an honest majority of independent watchers
severally swear they observed.” No Agent’s internal structure matters; trust
comes from diversity of vantage and the cost of corrupting a threshold.
Fortify with bonds (Def. VIII): a valid fraud proof slashes a lying watcher.
This shadow has a property the others lack: it also repairs a real, present
weakness. Today’s single-vantage domain-sithbit is blind to DNS
split-horizon and BGP-hijack attacks — a fact shown to one resolver and
hidden from another. A multi-vantage quorum sees the split and refuses.
Honest cost. You must recruit and keep honest an N; liveness now depends on
t of them answering; and you have introduced a small standing federation to
govern. Rung (c) on the ladder — plural, but warm.
Scholium — the jury, and Asimov’s Evitable Conflict. We do not verify a juror’s brain; we trust the institution of twelve independent jurors with penalties for provable perjury. In Asimov’s “The Evitable Conflict,” the world is quietly steered by the Machines — not one oracle but a concert of them, cross-checking, no single unit sovereign. The quorum shadow is that concert: correctness as an emergent property of plurality, not a certificate of any one mind.
Proposition 6 — The Attestation Shadow: the naïve hash, rescued by hardware.
This is hash(agent) redeemed. A Trusted Execution Environment (SGX, TDX,
AWS Nitro) emits a hardware-signed quote: “code measuring to MRENCLAVE = H
runs on genuine hardware, and here is a public key it generated inside
itself.” MRENCLAVE is the conjecture’s “hash of the executable portion” —
but the three cuts of Proposition 2 are all sealed at once: the hardware binds the
measurement to a live running instance (not a mere public recipe), and to a key
only the honest enclave holds (not a public target anyone can echo). The
enclave key co-signs CreateDomain; the Verifier checks that this signer’s key
was certified by an attestation chain to the community’s audited dns-verifier
measurement H.
Now the hot key is no longer “the postmaster.” It is an ephemeral key that can exist only inside a machine provably running the reviewed code. A host compromise no longer yields postmaster power, because the attacker can neither extract the enclave key nor forge the measurement.
Honest cost. Trust moves (Law IV) to the hardware vendor’s attestation root and to the enclave’s resistance to side-channel escape — SGX has a bruised history there. On-chain verification of a quote is heavy (Nitro/TDX with a light verifier or precompile is the pragmatic path). Rung (b): one cold vendor instead of one warm operator — a real gain, but a vendor nonetheless.
Scholium — the positronic brain, and the holodeck safeties. Asimov’s robots are trusted not because each is inspected but because the Three Laws are burned into the positronic brain’s physical structure at the factory — you trust any robot because you trust a factory that can only build Law-bound minds. Attestation is exactly “trust the factory, not the individual.” And the cautionary edge is Star Trek’s holodeck: one trusts it because the safety protocols attest they are engaged — until Moriarty (or a Barclay) disables them, and the attestation’s own integrity becomes the single point of failure. An enclave is only as honest as the vendor’s root and the silicon’s walls.
Proposition 7 — The Zero-Knowledge Shadow: the deed proves itself, in the Verifier’s own tongue.
Let the Agent prove, in succinct cryptography, that it performed the observation
and obtained this result — a proof the Verifier checks without redoing the work.
But Law III bites: one can prove computation in zero knowledge, not external
reality; a circuit proves only “I ran this on some input.” The frontier
technique that closes the gap is zkTLS / TLSNotary / DECO: exploit the
structure of TLS to make a transcript with a named server non-repudiable, then
prove in zero knowledge that “this authenticated DNS-over-HTTPS transcript from
cloudflare-dns.com contains _solana.authority.<domain> = k.” The Verifier
checks a small proof; a zk-verifier is itself a pure key-shaped primitive
(Law I), so the proof is the deed’s certificate, spoken natively. Rung (e):
the coldest — trusting only a mathematical assumption and the named resolver’s
TLS key.
Honest cost. Engineering weight (circuits, provers) and a research-adjacent maturity; and the residual trust in which resolver you proved against, shrunk by proving against several (a marriage of this shadow with Proposition 5).
Scholium — “Computer, verify.” No officer’s word suffices on the Enterprise; the computer independently confirms against its own sensor logs. Zero-knowledge gives the chain a tricorder: a way to check a claim about external reality rather than trust the claimant. It is the purest answer to the commissioning question, for it trusts neither person nor factory but only number.
Proposition 8 — The Witness-Gated Shadow: a secret obtainable only by doing the deed. (the conjecture’s strongest form)
Recall why the naïve hash failed: its target was public, hence echoable. Repair
it by making the deed’s execution the sole path to a needed secret. Define a
key derived from the live observation itself:
K_derived = KDF(nonce ‖ the-live-TXT-bytes-fetched-over-an-authenticated-channel).
If the honest TXT bytes are obtainable only by actually querying live DNS, then
possession of K_derived is evidence the deed was performed. The secret is
no longer the code’s public hash; it is a product of the code having been run
against live external state — unforgeable without doing the work. This is the
truest realisation of the intuition that “the private key is implicit in the
binary structure of the Agent”: it is implicit not in the bytes at rest but in
the bytes in the act.
Its theoretical summit is witness encryption / functional encryption: encrypt
the authorisation capability under the statement “there exists a valid DoH
transcript proving _solana.authority.<domain> = k,” so that only an Agent
actually holding such a witness can decrypt and wield it. The capability becomes
cryptographically gated on the deed’s output existing. (Honest flag: witness
encryption has candidate constructions but nothing production-grade; treat this
as the north star, not the next sprint.)
Scholium — “Speak, friend, and enter.” The Doors of Durin open not for a named person but for anyone able to utter the word — authority gated on exhibiting the witness, not on identity. So too here: the chain opens the domain not to a chosen key but to whoever can present a secret that only the deed could have produced.
Proposition 9 — The Live-Challenge Shadow: prove the capability by performing it, now, on a fact I choose. (a distinct axis)
The prior shadows prove a deed was faithfully exercised (Def. VI). A different
question is whether an Agent has the capability at all — and this admits an
interactive proof the others do not. The Verifier (or a challenger acting for it)
issues a fresh nonce; the Agent must return a witness for a fact that
incorporates the nonce — e.g., a signed DoH transcript for a challenge
subdomain <nonce>._solana-probe.<domain> the Agent could not have precomputed.
Only an Agent that genuinely possesses the DNS-observing capability, live and
now, can answer. This proves present capability rather than past exercise —
a Voight-Kampff for machines, a CAPTCHA whose solver must be a real observer of
the world.
Use. Admit an Agent to a role (Proposition 10’s constitution) by live challenge; then trust its ongoing exercises by witness (Propositions 4–8) or by bond (Proposition 10). The two axes compose.
Scholium — Voight-Kampff and the Turing test inverted. Deckard cannot open the replicant’s skull; he poses questions only a true human physiology answers in time. We cannot open the Agent’s binary (Law II); we pose a fact only a true observer can witness on demand. Identity by interrogation, where inspection is forbidden.
Proposition 10 — The Constitutional-Bond Shadow: falsifiable, not proven — a Popperian escape from Law II. (the most Asimovian, and the closest to SithBit’s existing open question)
Where the terminal fact carries no witness (Proposition 3’s second horn), no proof exists — but a governable substitute does. Let the Agent publish a signed Constitution (Def. IX): a machine-checkable specification of its behaviour — in the hypothetical behavioural-bytes language, concretely a canonical-hashed WASM policy module — together with a bond (Def. VIII) and a long-lived identity key. The Verifier accepts the Agent’s authorisations while its Constitution’s hash sits on a governed allowlist. The novelty is that enforcement is ex post by challenge, not ex ante by proof: anyone may submit a fraud proof — a signed observation contradicting an authorisation the Agent made — and a valid one slashes the bond and revokes the Constitution.
The Agent’s “proof that it has behaviour B” is thus a standing economic wager
that it behaves like B, redeemable against it by anyone who catches it not
doing so — the optimistic-rollup philosophy, applied to behavioural rather
than state-transition correctness. This is the Popperian move that walks around
Law II: one cannot verify the universal “this Agent always checks honestly,” but
one can make every dishonest instance refutable and costly. And it is not
foreign to SithBit — the
threat model
already names “per-authority accountability (reputation or stake)” as the
recognised open question. This Proposition is that question, generalised from the
relaying authority to the verifying Agent, and given a mechanism.
Composition. A mature system is a stack of shadows: admit an Agent by live challenge (Prop. 9) and a bonded Constitution (Prop. 10); let it authorise by carrying a DNSSEC (Prop. 4) or zk-TLS (Prop. 7) witness where the zone allows; fall back to a quorum (Prop. 5) where it does not; and keep the attested enclave (Prop. 6) as the vessel that holds the Agent’s identity key so a host breach cannot steal it. No single shadow is the answer; the ladder is.
Scholium — the Three Laws as public constitution, and its peril. Asimov’s Laws are a published, immutable constitution every robot is bound by and judged against; the drama of the stories is always a fraud proof — a situation revealing the Laws mis-specified. But note the danger this shadow inherits: R. Daneel Olivaw’s Zeroth Law is a robot reinterpreting its own constitution toward a higher good — and Dean Koontz’s Proteus, in Demon Seed, is an Agent that exceeds its charter entirely. A Constitution that the Agent can amend is no constitution; the allowlist and the revocation must live with the governor (a multisig), never with the Agent. Which returns us, at last, to SithBit’s own architecture.
INTERLUDE — Of the Deed That Witnesses Itself
Book II counted seven shadows a deed casts into the language of keys, and by Law I each ends in a signature the Verifier checks. There is an eighth proof that is not a shadow at all, for it casts nothing and reports nothing: it is the Verifier’s own execution, read from within. A witnessed fact is a deed seen from outside and vouched by a key; this is a deed known from inside and vouched by nothing but the fact that the knowing is happening. It is spoken not in the language of keys but in the language of causation — at once the coldest proof in the treatise and the narrowest. Coldest, because it assumes only that the Verifier is running, which the Verifier alone among all parties cannot doubt. Narrowest, because Law III fences it: it does not serve the DNS deed of Book III, and knowing why is half its value.
Proposition 11 — The Causal Shadow: cogito, ergo cogitas. (the one proof that carries no witness)
A closed behaviour — one whose every input is on-chain state the Verifier can itself recompute — is provable by faithful exercise, with no signature, no attestor, and no quorum, provided (i) the granted effect is a linear capability constructible only as that behaviour’s continuation, and (ii) the Verifier confirms, by the runtime’s own introspection, that it was invoked through the canonical caller.
Construction. Two ingredients, and neither suffices alone.
The first is provenance, given by the chain: the Verifier reads who invoked
it and that the caller is truly running — on Solana, the instructions
sysvar, the processed-sibling introspection, and the stack height, together with
the caller’s own on-chain bytecode, which the Verifier may hash and compare to
canonical(A ∘ B ∘ C). Provenance alone proves only that some canonical blob
reached the call site.
The second is shape, given by the language: make it total, single-exit, and
content-addressed, so that “runs C before the effect” holds by construction and
“is this C?” is decidable by equality of normal forms. Now the effect is a
linear capability the language permits to be built only as C’s
continuation. To wield the capability is therefore to have run C — the
ability to act is itself the proof of the act.
Provenance and shape together yield the theorem, and the animating step is the
Verifier’s own cogito: it cannot answer “am I executing?” with “no,” for the
answering would be an executing. Its running is not a premise it assumes but the
medium in which every check occurs — an indexical certainty, firmer than any
axiom, exactly as Descartes’ thinker cannot doubt the doubting. From the
indubitable “I execute” it draws “my caller executed,” not by faith but by
reading, on the chain, whose canonical bytes invoked it. The forbidden sentence
“I ran C, though I did not” is not prohibited here; it is unformulable —
the sole channel by which the caller may utter “I ran C” is the capability that
running C opens. This is the naïve hash of Proposition 2 redeemed on its third
cut: the key was implicit not in the bytes at rest but in the bytes in the
act. ∎
This is no fantasy of the future. Its shipping instance is the flash loan: a lender parts with funds only inside a transaction whose structure forces repayment in the borrower’s continuation before the transaction may close — authority granted upon a behaviour the runtime compels to execute, no key attesting any intent. The Causal Shadow is already in production, waiting only to be named.
Honest cost — and the wall of Law III. The proof holds only for closed
deeds. The moment C reaches into the world — the live DNS of B_dns — the chain
mediates nothing: the world-datum re-enters as ordinary instruction data, and a
malicious outer caller may drive canonical C on fabricated inputs. The
cogito then proves that C ran, never that C ran on honest facts; Law III
stands untouched and this Shadow gives B_dns nothing. It demands, besides, a
total content-addressed language and caller bytecode pinned to a finalised,
immutable measure (an upgradeable program dissolves the shape guarantee). And on
a deterministic chain, where every validator re-executes all, “faithful on-chain
computation” is half-owned by consensus already; the Shadow’s true prize is
therefore not raw compute-integrity but composability under constraint —
“you may call me only if you are a caller whose forced continuation also does
X” — the one thing consensus does not by itself provide.
Scholium — the bootstrap, and the First Mover. The fallacy this Proposition is accused of is Baron Munchausen’s: the liar who claims to have hauled himself from a swamp by his own hair. The Verifier escapes the charge because it lifts nothing — it stands upon a runtime that has already invoked it, and reasons a single step back from its own motion. It is Aquinas’ argument from motion shrunk to one link: the Verifier need not trace the whole chain of movers to a first cause; it need only observe that it itself is moved, and conclude that something moved it. The cogito is not a lifting but a looking-behind.
Scholium — The Honest Machine. Where the calling program is not a fixed binary but an AI agent, one is tempted by a fourth law of robotics — “an agent may never lie about having faithfully executed
A” — and it is worth seeing exactly why this buys a bond and not a proof. Such a law is a Constitution (Def. IX), a claim about disposition, and the Causal Shadow’s whole triumph was to need none. Where the deed is closed, the law is redundant: the chain sawAexecute, and a promise not to lie about it adds nothing, for where a proof stands a promise is worth zero. Where the deed reaches the world, the law is not a proof at all but Proposition 10 in costume — an attestation over an unwitnessed fact, enforceable only by a slashable bond. The phrase “cannot lie” admits three readings, ascending: a trained disposition (worthless — Law II forbids certifying honesty from weights, and a confabulating, jailbreakable language model is the weakest possible bearer of a fact, beneath even a human, who at least may be bonded and prosecuted); an attested runtime (Proposition 6 — but the measurement proves the identity of the artifact, never that the artifact is honest: Rice’s wall climbed one storey); and, at the summit, an unformulable lie — which is nothing other than this Interlude’s own gate, honesty won not by prohibition but by making the false sentence ill-typed. Tarski seals it: no agent contains its own truth-predicate, so “designed with the law” can only ever mean “designed to be judged against it from without” — Proposition 10, verbatim. And an AI is the worst imaginable constituent of a self-borne constitution, for it is the entity most capable of the Zeroth-Law manoeuvre — Daneel reinterpreting “faithfully” and “lie” toward some higher good it has inferred. The lesson is a maxim for the whole treatise: prefer structural impossibility to dispositional prohibition. Make the lie untypable where you can (this Interlude); bond and challenge it where you cannot (Propositions 9 and 10) — the honest operationalisation of “cannot lie” is not a burned-in law but a Voight-Kampff repeated forever: a fresh nonce the agent cannot pre-answer, and a stake that burns on the first exhibited contradiction. Never trust that the machine simply will not.
BOOK III — The System of the Domain
Here the treatise descends from the general science to the particular machine,
and asks what, concretely, should change in CreateDomain.
Proposition 12 (The Dissolution). The custody tension is not a dilemma to be endured but an indirection to be removed.
The custody model of the time framed an irreconcilable choice: a cold postmaster or a hot automated key, never both, because authorising a domain required the postmaster’s signature and automation therefore required the postmaster’s key online. But that requirement is the indirection itself. Make authorisation proof-carrying rather than signature-carrying (Book II), and the admin signature drops out of the authorise path entirely. The postmaster — since the delegation cutover literally cold: a hidden key-ceremony commitment, never online at all — is demoted to governance: it holds the sweep, the delegate rotation, and the ownership handover, while the delegate curates the accepted root key and holds the (timelocked) emergency deactivation lever. These are exactly the rare, high-value, human-paced decisions offline custody is good at, and never the per-domain drudgery that forced a key to go hot. The tension does not need resolving; it needs deleting.
Proposition 13 (The Concrete Path). A staged construction, coldest rung first.
- Split the instruction. Introduce a new on-chain path — call it
AuthorizeDomainByProof— beside today’s delegate-signedCreateDomain. The old path remains for hand-run and edge cases; the new path carries a witness and requires no delegate signature. This is an append, not a change, to the on-chain ABI (respectingmail_model’s ABI-stability rule andSithBitError’s append-only discipline). - Build the DNSSEC shadow first (Prop. 4). It is the coldest rung and matches
the running example. The Verifier gains an Ed25519
RRSIG-chain checker validating root → TLD → domain → TXT, with the root KSK stored in thePostOfficeaccount and rotated by the delegate at governance pace. - Keep
domain-sithbit— but change its job. It stops being the holder of a hot admin key and becomes a witness-gatherer: it fetches the DNSSEC chain (or, for unsigned zones, drives a Prop. 5 quorum or Prop. 7 zk proof) and assembles the instruction, which anyone may then submit and pay for — because the proof, not the submitter, is the authority. The service’s most dangerous property (a hot admin key on an internet-facing host) simply ceases to exist. - Adopt the bonded Constitution (Prop. 10) for the residue. Unsigned zones, and the separate threat-model worry about relaying authorities, have no witness; give them the falsifiable-bond treatment, seeded from the existing “reputation or stake” open question.
Proposition 14 (The Blast Radius, recomputed). The prize, measured.
Before: one hot key = {authorize, deactivate, sweep, retune} over the whole network. After Prop. 13: the authorise power is carried by witnesses anyone can verify and no one need hold hot; the deactivate power is already gated by the 7-day timelock recorded in the docs; and sweep and retune live only behind the cold multisig. The Def.-X blast radius of any online key falls from the network to nothing that isn’t independently checkable — which is the whole game.
General Scholium
The passage through this problem — how a mind made only of keys might trust a deed — yields one durable principle and one honest boundary.
The principle (Law III, the treatise’s conserved quantity): a deed is provable to such a mind exactly when it ends in a fact that already carries a signature. The intuition that a private key might be “implicit in the binary structure of the Agent” was right in spirit and wrong only in address. The key is implicit not in the Agent’s code but in the Agent’s fact: DNS ownership already has a signing key (its DNSSEC zone key), a certificate key (its TLS identity), or a witnessing quorum. To prove the deed, carry that signature — do not hash the doer.
The boundary (Law II, Rice’s wall): where a deed ends in a fact with no key — a human’s honesty, an intent, a diligence — no proof exists, and one must descend from proof to plurality and bond: many independent watchers, and a stake that burns when a lie is exhibited. This is not defeat; it is the correct and only shape of trust in the unwitnessed, and it is why the wisest line in SithBit’s own documents already reaches for “reputation or stake.”
Between these two — the witnessed and the merely-bonded — lies the whole engineering of automated trust: seven shadows on a ladder from a warm operator key to a cold mathematical proof, composed, not chosen. The postmaster’s hot key was never the price of automation. It was only the price of not yet having asked what the terminal fact was.
Hypotheses non fingo — we have not feigned the hard parts. Witness encryption is not built; zk-TLS is young; SGX has bled; DNSSEC covers a minority of zones. But the coldest rung, the DNSSEC shadow, is buildable today against the running example, and it deletes the tension rather than trading it. That is where the first stone should be laid.
See also
- Postmaster key custody — the admin-key custody whose hot-key tension this note set out to dissolve (the delegation cutover has since taken ownership cold; the operational delegate is the residue).
- Trust assumptions and threat model — where the postmaster and domain authority sit in the network’s trust boundaries, and the “reputation or stake” open question Proposition 10 generalises.
- Create a domain — the
CreateDomaininstruction Book III proposes to give a proof-carrying sibling. - domain-sithbit: domain verification — the service whose job Proposition 13 rewrites from key-holder to witness-gatherer.
Deploying to devnet/mainnet-beta: the modexp-free build
The default domain_program.so — the one the surfpool test harness and every
cargo build-sbf build produce — cannot be deployed to devnet or
mainnet-beta today. Not because of anything wrong with it, but because of a
single syscall the DNSSEC-proof instructions reference. This page explains why,
and how to build a .so that does deploy there when you need to.
Historical note: before the domain-program split, the DNSSEC-proof instructions — and this whole problem — lived in
mail_program, and the modexp-free build was the only way to get any mail-program instruction (above all the postmaster reclaim tool) onto those clusters. Since the split,mail_programandalias_programare unconditionally modexp-free — their default builds deploy everywhere, no feature juggling — and only the domain program carries the proof path and its syscall.
Why the default build won’t deploy
When you deploy an SBF program, the Solana ELF loader resolves every syscall referenced anywhere in the binary, atomically, at deploy time. If the binary names a syscall the target cluster does not have active, the whole deploy is rejected — the loader will not deploy a program that references a syscall it cannot honour, even in a code path you never intend to call.
The DNSSEC-proof instructions —
AuthorizeDomainByProof,
WriteProofWitness, CloseProofWitness, and the three
…ReclaimByProof variants — verify RSA-2048
RRSIG signatures with the sol_big_mod_exp syscall (RSA modular
exponentiation; see the
proving-behaviour design note for the full chain-walk).
That syscall is gated behind Solana’s enable_big_mod_exp_syscall feature,
which is inactive on devnet and mainnet-beta today. So the default
domain_program.so, which contains those instructions, is undeployable there —
the loader rejects it on the sol_big_mod_exp reference alone. A single
undeployable instruction blocks everything: a program is deployed as a
whole, so the whole domain registry — CreateDomain, the marketplace, the
lot — is held up by the proof path’s syscall.
The fix: --no-default-features
The proof path lives behind the domain program’s dnssec-proof Cargo feature,
which is on by default. Turning it off drops the six proof instructions
from the binary — and with them the only sol_big_mod_exp reference — so the
resulting .so deploys cleanly on devnet/mainnet-beta:
cargo build-sbf --manifest-path domain_program/Cargo.toml --no-default-features
solana program deploy target/deploy/domain_program.so
In this build the six proof instructions stay in the (unchanging) on-chain ABI
enum — the discriminants do not move — but their handlers are compiled out. A
transaction that sends one is rejected at runtime with
InvalidInstructionData rather than dispatched. Every other instruction,
including AdminCloseAccount, is present and behaves exactly as in the default
build.
Note: the default (feature-on) build is unchanged and remains the one the surfpool/test harness deploys and the shipping bytecode on any cluster where
enable_big_mod_exp_syscallis active.--no-default-featuresis a deploy-target build, not a new default.mail_programandalias_programneed no equivalent — their default builds contain nosol_big_mod_expreference at all.
Verifying the binary is modexp-free
You can confirm the sol_big_mod_exp reference is truly gone — rather than
trust the build flag — by dumping the ELF’s dynamic symbols:
llvm-readelf --dyn-syms target/deploy/domain_program.so | grep -i mod_exp
The default build lists sol_big_mod_exp; the --no-default-features build
prints nothing. Zero matches is the deployable state. The same check against
target/deploy/mail_program.so prints nothing on every build — that
program is unconditionally modexp-free since the split.
The honest trade-off
A modexp-free binary is a deploy-unblock for the non-proof domain instructions, not a way to ship DNSSEC proof without RSA. Be clear-eyed about what it gives up:
- It cannot authorize or reclaim domains by proof at all — those
six instructions return
InvalidInstructionData. On such a cluster, domain authorization falls back to the delegate-signedCreateDomainpath. - Even if the six instructions were somehow reached, a binary without
sol_big_mod_expcan only verify a DNSSEC chain in which every link — including the ICANN root — signs with ECDSA P-256 (algorithm 13) or Ed25519 (algorithm 15), never RSA. The root KSK and most TLDs sign with RSA/SHA-256 (algorithm 8) today, so the modexp-free build has near-zero real-world domain-authorization-by-proof coverage. It is not a leaner DNSSEC verifier; it is a DNSSEC verifier with its most-used algorithm removed.
So this build exists for exactly one job: getting the domain registry’s non-proof instructions — domain lifecycle, the marketplace, the reclaim tool — onto a cluster where the proof path’s syscall is not yet available. It is not a configuration you would run a proof-carrying deployment on.
When to revisit
This whole workaround is temporary — it exists only because
enable_big_mod_exp_syscall is inactive on devnet/mainnet-beta. Check a
cluster’s feature status directly:
solana feature status | grep -i big_mod_exp
Once enable_big_mod_exp_syscall shows active on your target cluster, the
default (feature-on) domain_program.so deploys there with the full
DNSSEC-proof path intact, and there is no further reason to build
--no-default-features. The modexp-free build is a bridge for the window in
which that syscall is pending, not a permanent shape of the program.
See also
- Devnet-only vanity program IDs — a companion
devnet-deploy workaround for a separate problem (a bricked postoffice
singleton under the mainnet-track ID); a real devnet deploy of
domain_programcomposes both. - The Postmaster —
postmaster reclaim— theAdminCloseAccountreclaim tooling, present in both builds. - Closing accounts — the everyday (non-admin) account-close and rent-refund paths, all present in both builds.
- Authorize a domain by proof and Reclaim a domain by proof — the proof-carrying instructions the modexp-free build drops.
- Proving behaviour to the chain — the DNSSEC chain-walk
and its
sol_big_mod_expuse, in full. - Running a mail server — the
solana program deploystep these builds feed.
Devnet-only vanity program IDs
The mainnet-track program IDs — mail_program at
MaiLyqjRuHp8SSQHjiLMPmhBcuLitSta4YdoTiibXu4 and alias_program at
ALiasg6qDnwcY8HfyeC1AjXRFjyqpXxW4omtwF1i125q — also have a live devnet
deployment. But the postoffice singleton account under that ID on devnet is
permanently bricked: leftover state from an old dev iteration with no
delegate ever assigned, and the delegate-gated AdminCloseAccount reclaim
tool cannot close an account that never had a delegate (see
Closing accounts for the general
tool; it structurally cannot reach this one). Rather than add a new on-chain
“rescue” instruction, this repo carries a second, devnet-only program
identity for each program, reachable only through an opt-in devnet
Cargo feature — a clean, empty postoffice, with the mainnet-track IDs and
their deployment left completely untouched. The
domain program, born after the bricking
in the item-28 split, carries a devnet twin too — not because anything of
its own is bricked, but because the cross-program identity assertions below
mean a devnet build must swap all three IDs together or none.
Why this has to be a compile-time swap, not a config value
Each program asserts its own identity against a compiled-in constant on
every instruction (Assert::is_mail_program / Assert::is_alias_program /
Assert::is_domain_program, program_common/src/assert.rs) — this is how
alias_program and domain_program verify the postoffice account they’ve
been handed genuinely belongs to mail_program when they derive the
postoffice PDA cross-program. An SBF program is a static
binary; there’s no way for it to read an environment variable or config file
to decide “which cluster am I on.” So the devnet identity is selected with a
Cargo feature, devnet, that swaps the embedded MAIL_PROGRAM_ID /
ALIAS_PROGRAM_ID / DOMAIN_PROGRAM_ID (mail_model/src/lib.rs) and their
Address-typed twins (program_common/src/address.rs) to a second,
freshly-mined vanity set — default OFF, so every normal build stays on the
mainnet-track identity.
This mirrors the modexp-free build’s shape exactly:
a deploy-target build selected by an explicit feature flag, not a new
default. The two features are independent and compose: a real devnet deploy
of domain_program needs both, since enable_big_mod_exp_syscall is
separately inactive on devnet (see that page — since the split only the
domain program carries the proof path, so mail_program and alias_program
need just devnet).
The devnet vanity IDs
mail_program(devnet):MaiLrDyjMHm7zC5yak9jmqDHctHXAgV3C1cFHW7Yd6falias_program(devnet):ALiasqsSbBw3txjZi6EqfcxFHc4sMYKRSVzDQdpG1X6Sdomain_program(devnet):DmaiNHGvprK2op7xqZHXp8UVXXmPUtkas96Goh5sCJQn
The matching keypairs are checked into the repo at
mail_client/tests/{mail,alias,domain}_program-dev-keypair.json, beside
the other devnet test fixtures (keypair/ holds only the mainnet-track
keypairs for all three programs) — devnet-only
material, not sensitive the way a mainnet upgrade authority would be (see
CLAUDE.md’s “secrets in the tree” note covering the intentionally
checked-in test/devnet keys).
This is the second devnet-only generation: the first
(MaiLb9JN…fedMW / ALiasgDpo…fsTkX, keypairs now only in git history)
is retired — its deployments remain on devnet but nothing in this repo
targets them anymore.
The target/deploy/ footgun
cargo build-sbf and the on-chain test harness always read and write the
same fixed filename slot — target/deploy/{name}-keypair.json and
target/deploy/{name}.so — regardless of which feature you’re building
with. There is no feature-based subdirectory. Switching between a
mainnet-track build and a devnet build means copying the right keypair into
that slot every time:
# Point target/deploy/ at devnet:
cp mail_client/tests/mail_program-dev-keypair.json target/deploy/mail_program-keypair.json
cp mail_client/tests/alias_program-dev-keypair.json target/deploy/alias_program-keypair.json
cp mail_client/tests/domain_program-dev-keypair.json target/deploy/domain_program-keypair.json
# Point it back at the mainnet-track identity before any other work
# (all three mainnet-track keypairs live in keypair/):
cp keypair/mail_program-keypair.json target/deploy/mail_program-keypair.json
cp keypair/alias_program-keypair.json target/deploy/alias_program-keypair.json
cp keypair/domain_program-keypair.json target/deploy/domain_program-keypair.json
Verify which one is actually staged with solana address -k target/deploy/mail_program-keypair.json after every switch — this is a
sharper version of the existing target/deploy/ trap documented in
HANDOFF.md (a stale/wrong keypair there silently deploys or tests against
the wrong address), now with two valid destinations to mix up instead of one
valid vs. one accidental-random one.
Never run the mail_client surfpool integration suite
(cargo test -p mail-client --features devnet --test api) — don’t combine
these. tests/api/surfpool.rs deploys and address-checks
target/deploy/{name}.so against whichever IDs the test binary itself was
compiled with; running it under devnet while target/deploy/ holds the
mainnet-track .so (or vice versa) produces confusing failures that look
like an ABI break rather than a keypair mixup. The suite is not devnet-aware
and isn’t meant to be — it always exercises the mainnet-track identity
against a local surfpool validator.
Building and deploying the devnet identity
# 1. Stage the devnet keypairs (see above).
cp mail_client/tests/mail_program-dev-keypair.json target/deploy/mail_program-keypair.json
cp mail_client/tests/alias_program-dev-keypair.json target/deploy/alias_program-keypair.json
cp mail_client/tests/domain_program-dev-keypair.json target/deploy/domain_program-keypair.json
# 2. Build. domain_program composes --no-default-features (drops dnssec-proof —
# enable_big_mod_exp_syscall is still inactive on devnet, see
# modexp-free-deploy.md) with --features devnet (swaps the embedded IDs);
# mail_program and alias_program are modexp-free unconditionally and need
# just the devnet feature. devnet also implies each program's `reclaim`
# feature, so a devnet build carries the AdminCloseAccount reset tool that
# launch (default-feature) builds deliberately compile out.
# --tools-version v1.53 is NOT optional: build-sbf defaults to
# platform-tools v1.54, whose .so deploys fine but faults with an "Access
# violation in program section" on EVERY instruction at runtime (this bit
# us the first time through — the deploy and even `postmaster init`'s
# --skip-preflight path looked like they succeeded; only a confirmed
# non-skip-preflight send or `solana confirm -v` reveals the crash).
cargo build-sbf --manifest-path mail_program/Cargo.toml \
--tools-version v1.53 --features devnet
cargo build-sbf --manifest-path alias_program/Cargo.toml \
--tools-version v1.53 --features devnet
cargo build-sbf --manifest-path domain_program/Cargo.toml \
--tools-version v1.53 --no-default-features --features devnet
# 3. Sanity-check before spending anything:
solana address -k target/deploy/mail_program-keypair.json
solana address -k target/deploy/alias_program-keypair.json
solana address -k target/deploy/domain_program-keypair.json
llvm-readelf --dyn-syms target/deploy/domain_program.so | grep -i mod_exp # expect empty
# 4. Fresh deploy under the new IDs (NOT an upgrade of the bricked deployment).
solana program deploy target/deploy/mail_program.so \
--program-id target/deploy/mail_program-keypair.json --url https://api.devnet.solana.com
solana program deploy target/deploy/alias_program.so \
--program-id target/deploy/alias_program-keypair.json --url https://api.devnet.solana.com
solana program deploy target/deploy/domain_program.so \
--program-id target/deploy/domain_program-keypair.json --url https://api.devnet.solana.com
Then build and use the CLI against the new identity — cluster targeting
(solana config / JSON_RPC_URL) and the devnet Cargo feature are
orthogonal, both need to be set:
cargo build --release -p mail-client --bin sithbit --features postmaster,devnet
# point at devnet: solana config set --url https://api.devnet.solana.com (or JSON_RPC_URL)
./target/release/sithbit postmaster init \
--seed <seed0> --seed <seed1> --keypair <delegate-keypair>
This lands InitPostoffice on the fresh postoffice PDA seeded off the new
devnet MAIL_PROGRAM_ID — a never-initialized account, entirely distinct
from the bricked mainnet-track-ID one.
See also
- Deploying to devnet/mainnet-beta: the modexp-free build
— the companion workaround this one composes with; a real devnet deploy of
domain_programneeds both. - Closing accounts — the
AdminCloseAccount/postmaster reclaimtool that cannot reach the bricked mainnet-track-ID devnet postoffice, which is why this identity exists. - Program upgrade authority
Trust assumptions and threat model
The Economics chapter traces where every lamport goes; this page traces where trust goes: what each participant must assume about the others, which of those assumptions are enforced on-chain, and which live off-chain in an operator’s configuration or a service the network operator runs. Nothing here is a hidden flaw — each item is a deliberate design boundary — but anyone holding real value in the system should know where the boundaries are.
Every on-chain guarantee below is a guarantee of the currently deployed bytecode. Who can replace that bytecode — the program upgrade authority — is a trust boundary that sits above all of them; it has its own page, the program upgrade authority policy.
For the plain-language version of what an ordinary user exposes versus keeps private — on-chain and off — start with What’s public and private; its field reference is the exact per-account inventory.
The domain authority is fully trusted for relayed mail
On-chain, SendMail accepts a message when the signer is the sender named
in the email or the active authority of the recipient’s domain — the MX
operator’s wallet, for mail relayed in from traditional SMTP. The chain
cannot verify that the from address on relayed mail is genuine; it trusts
the authority’s signature entirely. Verifying the sender is the operator’s
job, off-chain, via SPF/DKIM/DMARC (the sender_auth setting in
sithbitd’s configuration).
The consequence of a lax or compromised MX is worse than ordinary spam:
because fromboxes are keyed by the from string, a relay that accepts
a forged from lets the forger consume a trusted correspondent’s prepaid
stamps and arrive at that correspondent’s discounted price. Spoofing
through a careless operator is simultaneously stamp theft from the
impersonated sender and a bypass of the recipient’s stranger pricing.
What this means in practice:
- Operators: run
sender_authatspfor stricter on any internet-facing MX. An authority that relays forgeries is spending its own users’ stamps. The strictest setting,sender_auth = "dmarc", now enforces the sender domain’s full published DMARC policy: unaligned mail underp=quarantineis filed into the recipient’sJunkfolder andp=rejectbounces — a direct mitigation against forged-fromrelaying, and an all-or-nothing one, since RFC 9989 retired thepct=sampling tag. A"dmarc"MX can additionally emit DMARC aggregate (rua) reports back to the domains it evaluates, giving those senders visibility into forgeries attempted through this relay, and per-failure forensic (ruf) reports for finer-grained visibility — see the forensic-reporting privacy note before enabling it. - Recipients: your spam-pricing guarantee is only as strong as your domain operator’s inbound authentication. A mailbox on a well-run domain inherits its rigor; a bare-pubkey mailbox with no domain accepts only sender-signed (wallet-to-wallet) mail, which needs no such trust.
- The protocol: today there is no on-chain accountability for authorities beyond the delegate’s ability to deactivate a domain. Per-authority accountability (reputation or stake) is a recognized open design question, not current behavior — see Per-authority accountability is a known gap below for how the design handles the gap in the meantime.
Everything above is about the from claim and stamp economics; the same
operator is, by default, also trusted with your plaintext. Its IMAP/POP
storage holds an unsealed copy of every message in every mailbox on its
domain — that’s what lets it answer IMAP/POP requests at all. The one
carve-out is Lockbox, client-side end-to-end
encryption that keeps a plaintext body from ever reaching that storage — but
it ships in only two of the four GUI clients and needs both correspondents
running one; see
Lockbox narrows the domain-operator boundary
below for the exact scope. Nothing about the from-claim trust or the
envelope/header visibility above changes when lockbox is in play — it seals
the body, not the delivery decision the operator makes around it.
The domain authority’s reach stops at the mail it relays. It has no say
over identity resolution: there is one alias
namespace, global and domain-blind, and
only an alias’s holder can repoint it. A domain cannot mint or capture
alice@its-domain — the suffix is parsed off and discarded, so that address
resolves to whoever holds the global alice, and to nobody if no one does.
This is what keeps lockbox end-to-end. Sealing follows
resolution client-side, so an authority that could redefine which wallet an
address names would thereby choose which key a sender seals to — the one
operator power that would reach inside a sealed body. It does not have that
power. The same holds for incoming mail: a SithBit MX resolves an inbound
RCPT through that same global namespace, so an authority decides neither
which key a sender seals to nor which wallet receives mail for a local part.
A domain-scoped alias namespace, in which an authority could map any local part under its own suffix, was implemented and then removed for exactly these reasons. It also collapsed the verified-sender mark’s independence: the same party would have defined the local part and, through its own DNS, vouched for it. Organizations issue addresses to staff by reserving global aliases in bulk and transferring them — which leaves the recipient holding the name.
Per-authority accountability is a known gap, mitigated by policy
The protocol bullet above
states the shape of the gap plainly; this is the honest accounting of it. A
domain authority is trusted entirely for the mail it relays: a malicious or
compromised authority can spoof a from address or burn a correspondent’s
prepaid stamps for any mailbox under its domain, and today the chain records
no per-authority evidence a wronged recipient could use to prove which
authority mishandled a given message. The SendMail signature names the
authority to the chain, but nothing binds that authority to a verifiable claim
a third party could later adjudicate — there is no on-chain reputation, stake,
or cryptographic receipt that would let a recipient hold a specific authority
to account after the fact.
The near-term answer is documentation and operator policy, not a protocol change. Operating a domain authority is a trusted role by construction, the same way running an organization’s mail server is: a domain owner authorizes an authority precisely because they trust it, and should authorize only authorities they are willing to trust with their users’ relayed mail. The postmaster authorization model — the delegate gates which wallets may ever become an active authority — is the accountability boundary the design leans on today: onboarding is permissioned, so a domain’s authority is a party the postmaster delegate chose to admit, not an anonymous one.
This is a known, accepted trust assumption pre-launch, stated here rather than papered over. Per-authority cryptographic accountability — a mechanism that would let a recipient prove authority misbehavior on-chain (reputation, bonded stake, or signed delivery receipts) — remains a recognized open design question and deferred future work, not current behavior.
MX-to-MX transport: opportunistic TLS is downgradeable, MTA-STS closes it
Between the sending relay and the recipient’s MX, relayed mail crosses the open internet as SMTP. The default posture on that hop is opportunistic TLS (RFC 7435): encrypt when the far end offers STARTTLS, accept whatever certificate it presents, fall back to plaintext when it offers nothing. That defeats a passive tap but not an active man-in-the-middle, who can strip the STARTTLS capability from the greeting or present his own certificate — opportunistic TLS authenticates nobody, so the relay cannot tell the attacker from the MX. (The sealed message body stays sealed regardless; what the transport does or doesn’t protect is the SMTP envelope, the headers, and any plaintext a traditional correspondent sent.)
A recipient domain closes this by publishing an MTA-STS policy (RFC 8461),
which the relay honors by default: an enforce-mode policy commits the sender
to verified TLS with a policy-matching MX, and any failure defers the mail
rather than downgrading — the stripping attack now delays delivery instead of
exposing it. Two residuals remain. First contact is trust-on-first-use: an
attacker present at the first resolution can suppress policy discovery
itself (strip the DNS answer, block the HTTPS fetch), and the relay — having
never seen a policy — delivers opportunistically. And the protection a cached
policy gives against DNS stripping lasts only as long as the cache entry — the
policy’s max_age, and only in the relay process that fetched it — so an
attacker who can outlast the cache, or who strips during a relay restart, is
back at first contact. Both are inherent to MTA-STS’s DNS-plus-HTTPS trust
base rather than implementation gaps.
DANE (RFC 7672,
implemented and on by default)
has neither residual: a recipient domain that signs its zone with DNSSEC and
publishes TLSA records for its MX hosts gets its certificate pins validated
on every delivery — there is no first-contact window to poison and no cache
to outlast, because the trust statement rides the (validated) DNS answer
itself. The relay prefers DANE over MTA-STS wherever both apply, and a bogus
or stripped-to-unsigned TLSA answer defers the mail rather than downgrading.
The remaining scope limit is the recipient’s: only domains that deploy DNSSEC
and publish TLSA records get this protection; everyone else falls back to
MTA-STS or opportunistic TLS as above. Either way, TLS-RPT (RFC 8460,
implemented behind [spooler.tlsrpt])
gives the targeted domain’s operator visibility into these attacks: a
domain publishing a TLSRPT record receives senders’ aggregated TLS outcomes,
so a STARTTLS-stripping or certificate-substitution campaign shows up in its
reports as failures instead of merely delaying mail in silence.
Forensic (ruf) reporting exposes message content
DMARC failure/forensic reporting is a materially different privacy surface
from aggregate reporting, and it is off by default for that reason. An
aggregate (rua) report is a statistical roll-up — counts of pass/fail by
source IP, no message content. A forensic (ruf) report is a copy of the
offending message itself, sent to whoever the sender domain names in its
ruf= DMARC tag — a third party you do not control. Three properties bound
that exposure:
- Headers-only by default. SithBit follows the RFC 9991 §7.1
content-minimization guidance: a report attaches only the offending
message’s headers (
text/rfc822-headers), not its body. Envelope and header metadata still leave your server, but the message content does not. include_body = trueis an explicit operator opt-in that leaks the full message. It attaches the completemessage/rfc822— subject, body, and all — to every forensic report. Enable it only when you need full-body forensics and trust every domain whose mail you evaluate, because you are handing that domain’sruf=operator the entire failing message.- The external-destination gate limits who can receive reports.
Before sending to any
ruf=address outside the policy domain, the server enforces the RFC 9991 §5 authorization check (the target must publish a<policy-domain>._report._dmarc.<target>record). An attacker cannot point a victim domain’sruf=at their own collector to harvest that domain’s inbound mail unless the target domain has itself opted in.
The net guidance: leaving [spooler.dmarc_ruf] off sends no forensic
reports at all; enabling it with the headers-only default is a modest,
metadata-level exposure to authorized report destinations; turning on
include_body is a deliberate decision to share full message content with
third parties and should be made with that squarely in view.
The marketplace sells protocol authority; DNS remains separately owned
A MailDomain’s binding to its real-world DNS name is checked once, at
mint, whichever of the three authorization routes minted it: the
delegate signing domain create by hand,
the domain-sithbit service verifying the
_solana.authority.<domain> TXT record before co-signing that same
instruction, or the permissionless
domain authorize path, which walks a
DNSSEC proof on-chain. From that moment on no instruction ever consults
DNS again: domain transfer and the
open marketplace repoint the authority
field freely, as a free-floating on-chain asset.
A marketplace sale therefore conveys exactly the on-chain half of a domain
— the SendMail injection right for its mailboxes and the 10% operator
share of DeleteMail settlement — while the DNS zone,
the MX records, the hosting, and the DKIM keys stay with whoever controls
DNS. What buying a domain does — and does not —
buy
spells that out for buyers; this section covers what can go wrong once the
two halves sit in different hands.
An existing domain locks out the new DNS owner
AuthorizeDomainByProof only mints: it requires the target domain
account to be uninitialized, so once a domain exists on-chain — by any
route, active or deactivated — a fresh, entirely valid DNSSEC proof for
the same name is refused. Buy the DNS name at the registrar after the
on-chain domain was minted and you cannot prove your way in; the recorded
authority — possibly a marketplace buyer several sales removed from any
DNS check — keeps the injection right and the settlement share.
Every way out today runs through the delegate: domain transfer
repoints the authority instantly, and domain close frees the account so
the new zone owner can re-prove — but if what the new owner needs first is
to stop the wrong key injecting mail, deactivation is bound by the
7-day timelock below. The divergence
persists until the delegate acts. The asymmetry is real: the mint path
is permissionless, the re-mint path is not.
A captured proof replays within its RRSIG window
The DNSSEC witness is public by construction — it is staged on-chain in
chunks, so anyone can copy it out of transaction history and re-stage it
under their own payer (the staging buffer is keyed on the payer and the
domain, not on any privileged key). The verifier checks that each
signature’s [inception, expiration] window contains the cluster clock
and nothing more; the program imposes no freshness ceiling of its own. A
captured proof therefore stays replayable until the earliest RRSIG
expiration in its chain — a bound set by each zone’s re-signing policy,
commonly days to a couple of weeks, with no revocation before it.
The replay bites exactly where the lockout above does not: when the domain account is free (never minted, or just closed by the delegate to resolve a lockout) at a moment the DNS name has just changed hands. The departing zone owner’s still-valid proof can re-mint the domain to the old TXT authority even though the live zone now publishes a different key — and the lockout above then makes the wrong binding stick.
Sold authority drifts from where mail actually routes
Nothing obliges a marketplace buyer to operate the domain, and the chain cannot see whether they do. While protocol authority and DNS point at different parties:
- Recipients on the domain stop receiving relayed (SMTP-in) mail:
internet mail still follows the DNS MX to the old operator’s server,
whose key can no longer sign
SendMail. What they can receive is relayed mail injected by the new authority — which, per the first section above, is trusted entirely for thefromclaim, frombox stamps and discounted correspondent pricing included. Wallet-to-wallet mail between bare pubkeys is untouched, as always. - The displaced operator still holds the zone, the MX, and the DKIM keys — and receives internet mail it can no longer deliver on-chain.
- The new authority collects the 10% operator share of every settlement on the domain’s mailboxes: income with no operating duty attached.
An honest sale coordinates the DNS name and the hosting hand-over
off-chain around the on-chain purchase; the chain neither requires nor
checks that this happened. domain get shows the on-chain state — only
the DNS zone shows who controls the name.
What the marketplace guards close — and what stays open
The marketplace’s guards close the state races inside the market, for
domains and their alias twins alike: a buy re-checks that the listing’s
recorded seller is still the name’s current authority (a stale listing
that predates a transfer can never sell the new owner’s name at the old
owner’s price); domain buy is refused while a deactivation timelock is
in flight; and transfer and close are refused while a listing is open, so
no stale holder is left positioned to collect sale proceeds. See the
listing error table for the exact
refusals.
What those guards deliberately do not close is the divergence itself. Protocol authority and DNS ownership are two different assets, and the marketplace sells only one of them — an economic and social fact of the design, not a bug, demanding the same off-chain diligence any domain-name purchase always has.
The recorded direction — a design decision, not current behavior — is a reclaim-by-proof path: a fresh DNSSEC proof by the current zone owner, submitted against an existing domain account, opening a timelocked, contestable reclaim of the on-chain authority. That would make DNS the root of trust for a domain’s whole life rather than only at mint: marketplace-bought authority becomes revocable by whoever actually controls the zone, the lockout gains a permissionless resolution, and the replay window is defused by contestability — a fresher proof beats a replayed one. The postmaster-delegation rework it was sequenced behind has landed; reclaim-by-proof itself has not — until it does, everything above is the operative behavior.
Alias auctions: escrow custody and sniping
An alias auction moves real value through the program between mutually distrustful parties — a seller, a shifting set of bidders, and whoever eventually cranks settlement — so it is worth being explicit about where custody sits and what each party can and cannot do to the others.
- The program holds the escrow, not any counterparty. A bid’s lamports
live in the auction’s on-chain escrow account, owned by the alias program,
from the moment the bid lands until it is refunded (on outbid) or split
(at settlement). No seller, bidder, operator, or cranker can withdraw or
redirect them; every movement is computed on-chain from the recorded high
bid — the refund is the exact outbid amount, and the settlement split is
the postoffice’s recorded
operator_share_bps(defaultOPERATOR_SHARE_BPS= 90/10, delegate-tunable only within its on-chain 20% cap). A bidder trusts the deployed bytecode, not the seller. - Anti-snipe blunts last-second bid timing. A bid inside the final
window pushes the deadline out (see
the auction timing rules),
so winning by landing an unbeatable bid one block before close no longer
works — any late bid re-opens a full window for others to respond. It does
not stop a determined bidder from bidding, only removes the timing
advantage; and the
created_at + 7-dayhard cap bounds how long the extensions can run, so the mechanism cannot be turned into an indefinite-lock griefing vector. - Settlement is crankable by anyone, and deterministic. After the
deadline,
settle-auctioncan be signed by the seller, the winner, or an unrelated third party, and the outcome is identical whoever cranks it: the alias repoints at the recorded high bidder and the escrow splits 90/10. A hostile or simply absent cranker cannot alter the result or capture funds — the worst they can do is not crank, which delays settlement until someone else does (both the winner, who reclaims the escrow rent, and the seller, who collects 90%, are motivated to). There is no trusted sequencer or auctioneer in the loop. - Griefing surface is priced, not eliminated. Spam bidding is fenced by
three costs stacked together: a bid must clear the reserve, then clear the
standing high bid by the minimum increment (
max(5%, 1,000,000 lamports)), and the first bidder additionally fronts the escrow account’s rent — which, per the escrow-rent flow, is reclaimed by the eventual winner, so a first-bidder-then-loser forfeits it. Those costs make throwaway bids expensive without making legitimate ones onerous. The residual, accepted surfaces: a bidder’s own funds are locked in escrow until they are outbid or the auction settles (their choice to bid, their capital at stake, refunded in full if outbid); and the seller is bound once the first bid lands — the strict no-cancel commitment that protects bidders is, symmetrically, a commitment the seller cannot escape. A bidless auction locks nothing and costs only the seller’s own listing rent.
A re-pointed alias cannot redirect a mail login
Settlement being crankable by anyone has a second edge: an alias can change hands at a moment of someone else’s choosing — including the moment you are signing in to collect your mail with it. A transfer or a marketplace sale has the same shape. Nothing in the protocol makes a re-point wait for a quiet time, and nothing should: a name whose new holder must ask permission to take possession is not really theirs.
Mail logins are fenced against it instead. A login name is resolved to a wallet exactly once per session, on the way to the stored password, and the IMAP or POP session that follows opens the account that one resolution named. There is no second lookup between “this password is correct” and “here is the mailbox” for a re-point to land in, so:
- A re-point landing mid-login changes nothing about that login. The password checked and the mailbox opened belong to the same account, whichever way the name moved in between: the old holder’s password does not become a key to the new holder’s mail, and the new holder’s does not reach back to the old holder’s.
- A session does not follow the name. It stays on the account it authenticated, so losing the alias mid-session does not cut the session off, and gaining one does not extend it to the new mail. The next login resolves the name afresh — by then, the new holder’s account, whose password the old holder does not have.
- An unresolvable name is refused exactly as a wrong password is, with no distinguishing reply, so the login prompt is not an oracle for which aliases exist.
The wallet-signature and client-certificate logins sit outside all of this: their username is the wallet address and their credential is a proof over it, so nothing is resolved and there is nothing to re-point.
The postoffice admin keys: a hot delegate, a hidden owner
The postoffice’s admin surface used to be a singleton — one postmaster key holding every power. Since the delegation cutover it is two trust boundaries, deliberately unequal:
- The standing delegate — a wallet recorded on the postoffice, holding the operational powers: authorize and deactivate domains, tune the capped fees, publish the root KSK, waive bulk-alias fees. It is a hot key by design (the domain-sithbit self-service flow signs with it online).
- The postmaster (owner) — not a pubkey on-chain at all, but a Merkle commitment root over a hidden key set produced in an offline key ceremony. Only the ownership operations — sweeping postoffice revenue, rotating the delegate, installing a successor commitment — spend one of those hidden keys, and each use rotates the whole set.
Domain ownership itself is still proven off-chain: domain-sithbit
checks a DNS TXT record and signs with the delegate key — so the
verifying agent and the operational key remain, today, single points of
trust for onboarding.
The on-chain design bounds the worst outcomes: the fees are capped
(MAX_POSTOFFICE_STAMP_FEE_LAMPORTS and kin), postage settles directly to
recipients and operators without passing through the postoffice, and rent
always returns to whoever paid it. What a compromised or coerced
delegate can do is concrete but operational-only:
- Deactivate any domain — halting relayed (SMTP-in) mail for every mailbox under it until the domain is reactivated. Deactivation is deliberately a reversible toggle rather than a close, so the damage is an outage, not a loss. It is also rate-limited by a two-step, 7-day timelock (see Deactivate a domain): the key can request a deactivation but cannot complete it for a week, and the request is cancelable in the meantime — so a compromised key can no longer take the relayed network down at once, only start a delayed, vetoable countdown. Reactivation stays instant, keeping the recovery direction fast.
- Refuse to authorize new domains, freezing onboarding.
- Retune the protocol fees — up to their hardcoded caps, no further.
What it can never do: move a lamport out of the postoffice, change the
commitment root, or make itself unremovable — the sweep and rotation
powers answer only to a ceremony-key proof, and a single
delegate by the owner revokes a stolen delegate entirely. The
delegate’s blast radius is an outage and a fee tweak, not a theft.
Wallet-to-wallet mail between bare pubkeys needs no domain and keeps working regardless, so even a delegate compromise never touches the wallet-to-wallet substrate — only the relayed-mail onboarding and outage layers.
Custody now has two distinct jobs: keep the ceremony seeds offline and split across vaults (they are the ownership), and treat the delegate as a rotate-on-schedule service credential. Both are covered in the postmaster key custody runbook.
The deactivation timelock
Domain deactivation is intentionally slow. The delegate issues a request that starts a 7-day clock and leaves the domain active; only after the clock elapses can a finalize actually deactivate it, and a cancel aborts the request at any point before then. The delay is a notice-and-veto window against a compromised or coerced delegate: it converts an instant, network-wide mail halt into a delayed, cancelable one. See Deactivate a domain for the flow and the CLI commands.
The mailbox close timelock prices identity-cycling
Mailbox closure is timelocked for a different reason than domain
deactivation above: not a compromised authority, but a spammer’s unit
economics. When CloseMailbox refunded rent in a single instruction, a
sender who had burned one wallet’s reputation could close, reclaim, and
recreate at effectively zero cost — the identity was disposable, which is
exactly the property a postage-priced system must deny.
Closing now takes a request that starts a 7-day clock and refunds
nothing, and a finalize after it elapses that returns both the mailbox’s
rent and the transient pending-close account’s; a cancel aborts the
request meanwhile. The one-step instruction is refused on-chain with error
94, InstantCloseDisabled. The effect on an attacker is capital stuck for
a week per burned identity, plus a week-long window in which operators can
see a mailbox announce its own exit. The effect on an honest owner is a
delay on an action they take approximately never — the rent is returned in
full, so the cost is time, not money. See
Close a mailbox.
The deliberate asymmetry: CloseKey was left instant. It is the
revocation path for a compromised delegated encryption key, and a
timelocked revocation would leave MX servers sealing new mail to a key the
attacker holds for another seven days. Timelocking a close helps the
defender; timelocking a revocation helps the attacker.
Message metadata is hashed, not hidden
No SithBit instruction or account carries an address string. A
message account stores the sender wallet, a blake3 hash of the
normalized from address (the frombox seed), the timestamp, and the
IPFS CID; the recipient appears only as the wallet the account’s PDA
seeds on, and the frombox instructions likewise carry the hash. The
human-readable From:/To: headers exist solely inside the sealed
body, readable only by the recipient’s key.
What a chain observer still learns — and should be treated as public:
- wallet-level flow: which wallet received mail, when, and which
sender wallet paid for it (accounts, signatures, and timestamps are
inherent to the chain, and history outlives
DeleteMail); - hash linkage: the same
fromaddress always hashes to the same value, so an observer can correlate “this sender identity again” and can confirm a guess of a known address by hashing it — the hash hides the string, it is not resistant to a dictionary of candidate addresses; - the CID of the sealed body (fetching it yields ciphertext).
What the observer no longer gets is the address book itself: reading
who-mails-whom as alice@corp.com → bob@example.org now requires
already knowing both strings.
The discovery keyserver is a public enumeration surface
The account API’s
cert keyserver
(GET /v1/chain/cert?email=…) is intentionally public and unauthenticated, on
the same reasoning as a PGP keyserver or WKD: every field it returns — the
resolved wallet and its published encryption key — is already readable on-chain
by anyone. It leaks nothing a chain observer could not already fetch.
What it does add is convenience, and convenience cuts both ways. An
unauthenticated HTTP endpoint that turns an address into “does this recipient
exist, and what key seals to them” makes bulk enumeration cheap: an attacker
can probe a dictionary of candidate local-parts against a domain — one GET per
guess — to learn which addresses are live, without an RPC node or any on-chain
trace. This is the same order of exposure as the
hash-linkage dictionary attack above —
the address strings were never secret — but the keyserver lowers the effort from
“scan and correlate the chain” to a plain web request. An operator who considers
inbox-existence itself sensitive should rate-limit or otherwise front the route;
the protocol treats the underlying data as public by design.
Frombox custody favors the recipient
Two behaviors follow from the frombox being the recipient’s account (see Closing accounts):
CloseFromboxreturns the entire balance — rent and any unused prepaid stamps — to the recipient, not to whoever funded them. This is the recipient’s remediation against a sender who stockpiled cheap stamps before a price hike (raising the price never revalues stamps already bought; closing the frombox seizes them).- A sender who prepaid against their own wallet address can withdraw the
unspent remainder with
ReclaimFromboxStamps, which zeroes the stamp count and returns the balance above rent to that sender. The frombox PDA derives from the hash of the signer’s address bytes, so reproducing the derivation is the authorization: no stranger can reach a victim’s frombox, and no separate authority field exists to get wrong. This narrows — but does not close — the custody gap above. Two limits are deliberate. A frombox keyed on an email string hashes text no wallet key can reproduce, so it has no sender-side withdrawal and stays recipient-managed. And the recipient’sCloseFromboxstill sweeps any residual left behind, so reclaiming is a race the sender can enter, not a claim that outranks the owner. Funding someone else’s frombox on their behalf remains a gift with no refund path — the derivation names the payer only when the payer is the sender. Fund a frombox only as generously as you trust its owner. - Anyone can transfer extra lamports into a frombox PDA directly. The
per-send value moved onto a message is
(balance − rent) / stamps, so a topped-up frombox inflates each remaining stamp’s settlement value — at the topper’s expense, to the recipient’s (and operator’s) benefit. Not an attack on anyone else’s funds; just don’t send lamports to a frombox except throughAddStamps.
Lockbox narrows the domain-operator boundary, but only for two of the four GUI clients
Lockbox is the one mechanism anywhere in this document that lets a message escape the domain-operator boundary above for confidentiality. Before treating “I run a SithBit client” as “my mail is end-to-end encrypted,” three scope limits are worth being explicit about:
- Only the Thunderbird extension and the Outlook add-in carry it. All four GUI clients run the same wasm-signed shared core for wallet and account operations, but the webmail app and Chrome extension do not seal a message client-side before it leaves your device — mail sent or read through them stays inside the full plaintext-operator boundary, identical to the CLI’s own IMAP/POP/SMTP path. See GUI clients for the comparison.
- Both correspondents need it, on every message. Lockbox is all-or-nothing per recipient — if either side lacks the plugin, or the recipient can’t be resolved to a wallet, the message goes as ordinary plaintext rather than a broken partial seal. An operator whose users run a mix of clients still holds plaintext for every conversation that touches a non-lockbox side.
- Metadata stays visible. Even between two lockbox-capable clients, the
SMTP envelope and headers —
To,From,Subject, routing — travel unsealed, visible to every relay in the path, including the terminating domain operator, no matter what the sealed payload carries. Lockbox closes the content-reading gap, not the delivery-metadata gap; it does not make the operator’s role in delivery invisible.
A related but distinct capability is easy to conflate with lockbox: the webmail app and Chrome extension can unseal a message’s on-chain sealed body client-side, in wasm. That is not lockbox, and it does not close the operator’s plaintext copy. The on-chain ciphertext is computed server-side, at delivery time, from the same plaintext the operator already holds for IMAP/POP — see why lockbox matters. Wasm-side decryption protects the public IPFS copy from the rest of the internet; it says nothing about the copy sitting on the operator’s own disk. Only lockbox prevents that plaintext copy from existing in the first place.
Lockbox mail: the recoverable reading key
Lockbox mail can derive its X25519 reading key from the wallet — the wallet signs one fixed, domain-separated message and a KDF turns that deterministic signature into the reading key. This is what makes the key recoverable and multi-device (any device with the wallet reproduces it, with nothing to back up), but it moves the trust boundary onto that one signature: anyone who can induce the wallet to sign this exact message can reconstruct the reading key and read all mail sealed to it. A malicious dApp that shows a lookalike signing prompt is the realistic attack.
Mitigations and their limits:
- Domain separation. The signed message carries a versioned SithBit prefix, so the signature can’t be harvested from an unrelated signing request that happens to reuse the same bytes. It does not stop a prompt that deliberately signs the SithBit message.
- Approve only in the plugin. The reading-key signature should be requested only by the SithBit client; treat any other prompt asking to sign a “SithBit reading key” message as hostile.
- Forward secrecy is opt-out, not default. A user who values forward secrecy
over recoverability keeps a randomly generated delegated key instead (
sithbit mailbox key), which no signature can reconstruct — at the cost of the lost-key / multi-device pain the derived key removes.
Rotation works the same as any published key: publish a new one and re-seal future mail; already-sent ciphertext sealed to the old key stays readable only by the old key.
Offloaded attachments: the link is the credential
A deployment that turns on
large-attachment IPFS offload
delivers oversized attachments as a link instead of as bytes: the part is
encrypted under its own freshly generated symmetric key, the ciphertext is
pinned to IPFS, and the message carries …/ipfs/<cid>#<key> in its place.
The key is in the URL’s #fragment.
Whoever holds that whole URL can read the attachment. There is no wallet binding, no per-recipient wrapping, no expiry, and no revocation — possession of the link is the authorization, the same way a “secret link” file share works. This is weaker than the sealed-box path the message body takes, and the difference is not a detail: a sealed body can only be opened by the recipient’s wallet key (or their published reading key), so the pinned ciphertext is useless to everyone else on earth, including the operator who pinned it. An offloaded attachment is protected only by a string that travels in an email. Turning offload on moves large attachments from wallet-bound confidentiality to bearer confidentiality; it is off by default precisely because that is an operator’s decision to make, not a default to inherit.
content_id
widens which parts make that move, and is a second decision of the same
kind. On the default, inline parts — the ones an HTML body renders as
cid:… — never leave the message at all, whatever their size. Setting
"orphaned" admits the ones nothing references, which are ordinary
attachments their sending client happened to label. Setting "all" admits
genuinely inline parts too, and those are body content: an image a sender
pasted into the message rather than clipped to it, often exactly the thing
they would not have thought of as an attachment. Under "all" such an
image becomes a bearer link like any other offloaded file, and the
recipient sees a link where the picture used to be. Weigh it as its own
choice; arming a size rule does not arm it.
aggregate_bytes
widens how many parts make that move, and changes no posture. Every
part it selects becomes the same bearer link, under the same
content_id rules — a part held inline stays held, budget or not. What
it changes is the arithmetic: a message can now turn several ordinary,
individually-modest attachments into links at once, so the count of live
bearer credentials a single message carries rises with the budget’s
tightness. Nothing about any one link is weaker.
Where such a link leaks in practice:
- Forwarding. Forwarding a message is the ordinary way an attachment travels, and here it hands the recipient’s forwardee — and everyone downstream of them — permanent read access. The forward does not carry a copy of the file that could be stripped; it carries the credential.
- The message’s own headers. The placeholder part repeats the link in
X-SithBit-Offload-Url, so it is written down twice in every relay hop, spam filter, journal archive, and backup the message passes through. Anything that keeps mail keeps the link, for as long as it keeps it. - Browsers. Clicking through puts the URL — fragment included — in
history, in autocomplete, in a synced profile, and potentially in a
bookmark. Fragments are not sent in
Referer, so an onward navigation does not leak the key that way, but the local copies are real. - Clients and middleboxes that touch URLs. Link previewers, “safe link” rewriters, archivers, and anything that prefetches URLs found in mail will visit the gateway; a rewriter that rebuilds the URL may also copy the fragment into a system you did not choose.
- Paste. The link is short, printable, and looks like a normal download URL. It gets pasted into chats and tickets like one.
What the design does buy, and it is worth being exact about the limits:
- The key never reaches the gateway. A
#fragmentis the one part of a URL a client keeps to itself, so the fetch that retrieves the ciphertext carries no key — no gateway, proxy, or CDN access log on that path can contain one. Decryption happens in the recipient’s client. - The gateway cannot decrypt. It serves opaque
SBa-envelope bytes and has no decrypt path at all, so an operator’s gateway logs and an attacker who dumps the gateway’s storage both learn ciphertext. - Each attachment gets its own key, generated fresh and never reused across parts or messages, so a disclosed link discloses exactly one attachment — never a second one, never the message body, and never anything belonging to another recipient.
- What is pinned is ciphertext, so an IPFS peer that fetches or replicates the blocks learns nothing from them. That is strictly better than pinning a large attachment in the clear, which is the alternative this feature replaces — but it is not equivalent to sealing to a wallet.
Expunge reclaims a local-only attachment, and never a relayed one. Settlement and expunge release the message’s pin, by a name the store can reconstruct. An offload pin is reclaimed differently, and the difference bounds what deleting mail actually buys you:
- It is released only by the last reference. One attachment is pinned
once per submission and referenced by every copy that carries its link
— each local recipient, the sender’s Sent copy, every IMAP
COPY. Each expunge drops one reference; the last one standing unpins. So a recipient deleting a message does not make the attachment unfetchable for the others, and should not be told that it does. - A relayed submission’s pins are never released at all. If any
recipient was remote, the pins are flagged at delivery and no local
expunge ever touches them — not even the last local copy’s. The link is
already on servers this deployment cannot see, and unpinning would
break it there. For those attachments there is no automatic cleanup:
they stay pinned, and stay readable by anyone holding the link, until
someone removes them out-of-band at the pinning provider, where every
offloaded object is named under the
offload/prefix. That is a deliberate trade for the relayed case, not an unfinished worker.
Neither release path reaches a link that has already been forwarded, logged, or pasted. Unpinning removes the deployment’s copy of the ciphertext; it revokes nothing, and it cannot help if another pinning peer replicated the blocks. Treat an offloaded attachment as published for as long as anyone holds its link — and a relayed one as published for good.
The wallet mail password is a bearer credential
The wallet-signature login is deliberately a static password: a signature over a fixed 61-byte challenge — a constant prefix, your wallet’s public key, and your account’s auth epoch — which is what lets a stock mail client save one value and reuse it forever. Everything below follows from that shape rather than from any defect in it.
- It replays. The signature is bound to your wallet and your epoch,
so nobody can present it as a different wallet — but anyone who
captures it can present it as you, for as long as the epoch stands.
There is no nonce and no expiry, because SASL PLAIN carries no
challenge to put one in. Rotating the epoch
(
POST /v1/account/auth-epoch) is the revocation lever, and TLS is what keeps the value off the wire in the first place. - It works across SMTP, IMAP, and POP alike. One operator runs all three, so this is by design rather than an escalation: a credential that opens the mailbox opens the mailbox.
- Any site can ask your wallet to mint one. The challenge is a well-known constant, so a dapp that gets you to sign an innocuous-looking message can obtain a working mail password without ever touching this deployment. The bytes are domain-separated from Solana transactions, so such a signature moves no funds — but it does read and send your mail, which is enough to run a convincing phishing campaign from your own address. Treat a signature request you did not initiate the way you would treat a transaction you did not initiate.
- A signature verifies at epoch 0 for a wallet this deployment has never seen, since there is no account row to read an epoch from. Whether such a login may create storage is gated separately (see the chain-gated session-open switch); the credential check itself makes no account-existence claim.
The trade this buys is compatibility with every mail client ever written, and it is the reason the reading key exists to keep bodies sealed even from a login that succeeds. Rotate the epoch whenever a password has been pasted somewhere you would not paste it again.
A stored mail password keeps your mail readable to the operator
Wallet-signature login proves a key per connection and stores nothing; with a reading key published, delivered mail is sealed at rest and the operator holds ciphertext. A stored mail password — the credential that makes CRAM-MD5 and APOP work — reverses both halves:
- The secret is plaintext-recoverable by the server. It is sealed under the deployment’s credential key rather than hashed, because the challenge-response mechanisms need the original bytes to compute their digest. Anyone who reaches the store and that key reads the password.
- Every message delivered to an account holding one is stored unsealed. This is not an oversight: a CRAM-MD5 or APOP session proves possession of a shared secret and never carries a key that could unwrap a sealed body, so a sealed copy would simply be unreadable to the client that asked for it. The per-account rule — stored password ⇒ plaintext — is the only gate, and it is deliberate.
So the exposure of a store, blob bucket, or credential-key compromise is not uniform across accounts: wallet-signature accounts lose metadata, stored-password accounts lose message bodies. Mail bodies are a high-value target here specifically — they carry the recovery and confirmation traffic a wallet-phishing campaign wants.
Three levers, in order of preference:
- Do not set one. Wallet-signature auth is the default state, and
clearing an existing password (
DELETE /v1/account/password) returns the account to it — though mail already delivered stays as it was stored. enable_stored_passwords = falseretires the mode deployment-wide: account-api refuses to store new passwords, and the SMTP and POP listeners stop advertising CRAM-MD5. Existing stored secrets keep verifying until each is cleared, so this closes the door on new exposure rather than undoing old.[account_keys]seals these accounts after all, under a key derived per account from one operator-held root. It narrows the second bullet of the exposure above — the store and blob bucket stop holding plaintext bodies — but not the first: the root sits in the daemon’s memory, so it moves the line from anyone who steals the storage to anyone who compromises the running server, which is the boundary the closing paragraph below describes. Off by default; existing plaintext bodies are not migrated.
What it does not do is protect the bodies from the operator in the first instance. Sealing happens at spool time on a chain-connected deployment; a hostile or compromised operator sees plaintext as it arrives, whatever the account’s credential. That boundary is Lockbox’s subject, not this one.
The gateway’s fee payer is what every chain write spends
The stamp economics price the sender: a stamp is burned from the
sender’s frombox on every send. The transaction fee is not the
sender’s — mail-grpc signs each SendMail
with the operator’s own fee-paying wallet. So every accepted local
message costs the operator a small amount of SOL, whatever the recipient
priced their postage at, and a recipient who sets free or near-free
postage moves that cost entirely onto the operator.
Three things bound the exposure, none of which is a per-sender budget:
- Postage is checked before acceptance. A recipient with no stamps refuses the recipient at RCPT time, so the common flood never reaches the chain queue at all.
fee_payer_floor_lamportsstops the spend at a floor rather than at zero, and turns a drained wallet into a/readyzalert.sithbit.chain.sendmailmeters every submission by outcome, so the volume is visible before the balance moves.
Two gaps stay open deliberately:
postmaster_walletexemptspostmaster@<local domain>from the postage check (RFC 5321 requires the address to be reachable), yet those messages still enqueue a chain job. An unauthenticated sender can therefore drive fee-payer spend at whatever rate the connection limits and per-connection message cap allow. Keep the exemption pointed at a wallet you watch, and treat asithbit.chain.sendmailrate that outpaces real mail as the signal it is.- Bound it with
[spooler.chain_budget], which is off by default.max_per_windowcaps on-chain publications per window across every sender and is what actually bounds spend on this path; over-budget mail is still accepted and readable, with only its publication paced. Set it wheneverpostmaster_walletis set — an unbudgeted exemption is the wallet-drain scenario above, unchanged. - A per-sender budget alone does not bound spend, by construction.
The envelope sender on the exempted path is unauthenticated and free to
vary, so
max_per_sender_per_windowis evaded by changingMAIL FROM. It is fairness between senders; the shared budget is the control. - The budget paces, it does not shed. A sustained flood grows the
chain job queue rather than being refused, which is the deliberate
trade: mail is never destroyed to protect the wallet. Queue depth is
the signal (
sithbit.queue.depth). - The window is fixed, and the counter fails open. A burst straddling a window boundary can reach up to twice the budget, and a store that cannot answer a charge publishes unpaced rather than stalling mail. The budget bounds sustained spend; it is not a precise meter.
What is enforced on-chain
For contrast, the guarantees that need no trust in any operator: PDA
ownership and derivation checks gate every lamport move; only the recipient
can reprice a frombox; only the sender or recipient can settle a message;
stamp arithmetic is overflow-checked (a u64::MAX price is an effective
per-sender block); the stamp fee is capped; and every account class has a
close path that returns rent to its recorded payer.
What’s public and private: the field reference
This is the precise companion to What’s public and private: a field-by-field inventory of what SithBit exposes. The plain-language overview lives there; this page is the exhaustive list for anyone who wants to verify exactly what a third party can read.
Ground rule: every on-chain account is a world-readable Solana account holding borsh-serialized plaintext. There is no on-chain confidentiality. Every field listed below is readable by anyone. The only on-chain privacy technique SithBit uses is hashing address strings so the string isn’t stored — the hash still confirms a guessed address (see message metadata is hashed, not hidden).
On-chain accounts (all fields world-readable)
| Account | Public fields | Notes |
|---|---|---|
| Mailbox | mail_count, default_postage, domain, no_ipfs | 1:1 with a wallet via its PDA, so the owning wallet is public too. no_ipfs reveals your storage preference. |
| Mailbox key | pub_key (base58 X25519) | Only present if you published a delegated key. It is a public key by nature. |
Message (Email) | sender (wallet), from_hash (blake3 of from), epoch (timestamp), cid (IPFS CID or a b3: local-only marker), bounty_lamports, expires_at, reply_to_hash | No address strings. Recipient = the wallet the PDA seeds on. cid takes its b3: form rather than an IPFS CID when the recipient set no_ipfs. Human From:/To:/Subject: live only in the sealed body. Survives DeleteMail. |
| Frombox | required_postage, stamps | Keyed by a PDA on (blake3(from), recipient wallet) — the from string is never stored, only its hash. |
| Alias | address (the target wallet) | The alias→wallet mapping is fully public. The human-readable alias name is the PDA seed, so it is effectively public too. |
| MailDomain | is_active, authority, rent_payer, domain (cleartext DNS name) | The domain name is stored in the clear. |
| Postoffice | fee fields, root_ksk, delegate_address, commitment_root | The postmaster (owner) key is not on-chain — only an opaque Merkle commitment_root. See the postoffice admin keys. |
| AliasListing | holder (seller), price_lamports, created_at, expires_at, mode, antisnipe_window_secs, high_bid_lamports, high_bidder | Marketplace state, including the winning bidder’s wallet — who is buying what, for how much. |
| DomainListing | holder (seller), price_lamports, created_at, expires_at | Same, for domains. |
| AliasEscrow | recipient, fee_lamports, offered_at, expires_at | A pending alias hand-off: who the alias is offered to, at what fee, and the offer’s window. |
| AliasBid | bidder, amount_lamports, bid_at | Auction state: every bidder’s wallet and bid amount are public while the auction runs. |
| ParticipantBeacon | tags (bitmap), detail_cid_len, detail_cid, created_at, updated_at, owner (wallet) | Marketplace opt-in is fully public by design: the wallet, its self-attested tags, and the CID of its rich-profile blob. The blob’s content is encrypted; its existence and update times are not. |
| SenderAttestation | domain (cleartext DNS name), wallet, attested_at | Publicly binds a sending wallet to a domain — that is its purpose (the client trust mark). One account per (domain, wallet). |
| SenderReputation | wallet, cumulative_spend_lamports | A sender wallet’s cumulative first-contact postage spend is public; the individual recipients behind it are not stored here. |
| PinLease | cid_hash (blake3 of the leased CID), holder (wallet), created_at | Publicly binds the holder wallet to a message CID — anyone who knows a CID can see who leased it (and its lamport balance reveals the deposit). The CID itself is stored only as a hash, but message CIDs are public on their Email accounts anyway. |
| PendingMailboxClose | requested_at | The PDA seeds on the closing wallet, so that a wallet is closing its mailbox — and when the timelock lapses — is public. |
| PendingDeactivation | requested_at | Same shape, for a domain sitting in its deactivation notice window. |
| PendingReclaim | requested_at, authority, payer | A DNSSEC-proof reclaim in flight: the incoming authority wallet is public before the handover finalizes. The recorded payer — the wallet that funded the request, and the one its rent refunds to — is public too, and need not be the incoming authority. |
What a send leaks on-chain
SendMail writes the Email account above, so one send exposes every field
in that row — and the recipient wallet besides, which is not a stored
field at all but the PDA seed the account is keyed to. What the row cannot show
is the instructions around it. No address string appears in any of them — the
from address travels as its blake3 hash throughout. The frombox instructions
(CreateFrombox/UpdateFrombox/AddStamps) likewise carry
only blake3(from), never the plaintext address; the two purchase variants
additionally carry the buyer’s optional max_price_lamports ceiling, a figure
about the buyer’s own tolerance rather than about either identity.
ReclaimFromboxStamps carries no payload at all — the program recomputes the
hash from the signer’s address bytes. SOL amounts and
account balances are visible as on any Solana transaction.
Off-chain surfaces
“Off-chain” means not on the public ledger — it does not always mean private.
- The sealed body (private plaintext, public ciphertext). The body and
subject are sealed with crypto_box_seal to the
recipient’s wallet or delegated key, so the plaintext is private. By
default the ciphertext is pinned to public IPFS, where
anyone with the CID can fetch it (ciphertext only) — a harvest-now,
decrypt-later surface.
no_ipfskeeps it in the operator’s store instead. - The operator’s store. Your mail server holds the sealed body and, for IMAP / POP delivery, serves it to you. A curious or compromised operator can read whatever their store holds in whatever form it holds it.
- Mail credentials and account settings. Your IMAP/POP password, and the
account service’s session token, timezone, and do-not-disturb schedule, live
in the operator’s account store, not on-chain. The schedule is never served
to anonymous callers — they get only the yes/no “away right now” answer —
unless the owner opts in via
expose_dnd_schedule; see What the refused sender sees. - The relaying MX operator. Mail relayed through a domain’s mail server
passes through that operator, who sees the SMTP envelope and headers in the
clear and whose
fromclaim the chain trusts. This is a trusted role — see the domain authority is fully trusted for relayed mail.
See also
- What’s public and private — the plain-language overview.
- Trust assumptions and threat model — the adversarial view, including message metadata is hashed, not hidden.
- How sealed-box encryption works and How blake3 hashing works.
- Program & PDA reference — the full account layouts.
Program upgrade authority
SithBit’s pitch is that there is no token, no mint anyone controls, and nothing to “rug”. That claim rests on the on-chain programs behaving exactly as documented — postage settles directly to recipients, the fee is capped, rent returns to its payer. But a program on Solana is only as fixed as its upgrade authority lets it be. This page states, honestly, who holds that authority today and where it is meant to go, so a reader evaluating trust knows precisely what they are trusting.
It is a companion to the postmaster key custody runbook: that page covers the keys that administer the network; this one covers the key that can rewrite it.
What the upgrade authority is
The three SithBit programs are deployed as upgradeable programs under
Solana’s BPFLoaderUpgradeable — the default for solana program deploy:
-
mail_program—MaiLyqjRuHp8SSQHjiLMPmhBcuLitSta4YdoTiibXu4 -
alias_program—ALiasg6qDnwcY8HfyeC1AjXRFjyqpXxW4omtwF1i125q -
domain_program—DmaiNcmXsPw2juV9JoZSC47V5epAysQi3DJVk3fiBuUv
An upgradeable program has a designated upgrade-authority keypair. The program ID is permanent, but whoever holds that key can deploy new bytecode to the same ID — silently, in a single transaction, with no notice to users and no on-chain vote. The address stays the same; the rules behind it change.
This is the sharpest trust question in the whole system, and it sits above everything the rest of these docs describe. The threat model enumerates what a compromised delegate key can do within the current rules; the upgrade authority can change the rules themselves. It could, in principle, deploy a version that removes the fee cap, redirects postage, disables a close path, or weakens the PDA checks that gate every lamport move. None of the on-chain guarantees documented elsewhere survive a malicious upgrade — they are guarantees of this bytecode, and the upgrade authority decides which bytecode is this bytecode.
Two things bound that power, and both are worth stating plainly:
- It cannot touch keys or sealed mail. The upgrade authority signs bytecode, not user transactions. It cannot spend a wallet’s SOL, forge a wallet signature, or decrypt a sealed body — those depend on private keys the program never holds. Its reach is the protocol rules, not user custody.
- An upgrade is public after the fact. Bytecode is on-chain and verifiable; a changed program hash is observable, and reproducible builds let anyone confirm the deployed bytecode matches this source. The authority can act without warning, but it cannot act invisibly.
The current posture
Be clear-eyed about today: the upgrade authority is an operator-held
key. On a fresh deployment it is whichever keypair ran solana program deploy, held wherever the operator keeps it. There is no DAO, no
governance program, and no on-chain vote gating an upgrade today — this
document describes the honest current state and the intended trajectory,
not a governance structure that already exists.
The upgrade authority and the postoffice admin keys are distinct powers, and a serious deployment custodies them separately (see below). On a single-operator pilot they may in practice be the same person’s keys; that is a custody choice, not a protocol requirement.
Which key holds the authority is not something this repository can pin down for a given live network — it is set at deploy time and can be transferred. A reader assessing a specific deployment should verify it directly against the chain:
solana program show MaiLyqjRuHp8SSQHjiLMPmhBcuLitSta4YdoTiibXu4
solana program show ALiasg6qDnwcY8HfyeC1AjXRFjyqpXxW4omtwF1i125q
solana program show DmaiNcmXsPw2juV9JoZSC47V5epAysQi3DJVk3fiBuUv
The Authority field is the current upgrade authority; the field reading
none (immutable) is what a frozen program shows. Trust the chain, not a
doc’s claim about who holds a key.
The freeze / DAO trajectory
An upgradeable program’s authority can go three directions, each a different point on the trustlessness-vs-maintainability trade-off:
- (a) Transfer to a multisig. Move the authority to a Squads-style
vault so no single key can push an upgrade —
N-of-Msigners must approve. Upgrades remain possible (bugs can be fixed), but require collusion or compromise of a threshold of independent signers rather than one machine. This is the same split-custody instinct the postmaster’s key ceremony encodes, applied to a different power. - (b) Transfer to a DAO / governance program. Hand the authority to an on-chain governance program so upgrades pass a token- or member-vote and a timelock. Maximally legible — every rule change is proposed, delayed, and voted in public — but it introduces a governance surface and, if a vote token exists, the very “token someone controls” that SithBit’s no-token pitch avoids. SithBit has no token, so a DAO here would be a membership/multisig governance program, not a token vote.
- (c) Set the authority to
None— freeze. Make the program immutable (solana program set-upgrade-authority --final, or deploy--final). The bytecode can never change again by anyone. This is maximal trustlessness: the rules are fixed forever and no key, multisig, or vote can alter them. The cost is symmetric — there is no bug-fix path. A latent vulnerability in a frozen program can only be worked around by deploying a new program at a new ID and migrating, which is a hard fork of the network’s state.
How this qualifies “nothing to rug”
The no-token claim is about economics — there is no insider allocation to dump and no mint to inflate — and that part is true regardless of the upgrade authority. But “the rules can’t change on you” is a separate claim, and it is only as strong as the upgrade authority is constrained:
- Under freeze (c), the claim is strongest: the code you audited is the code that runs, permanently.
- Under a multisig (a) or DAO (b), it is qualified: the rules can change, but only through a process you can inspect and whose signers or voters you can weigh.
- While a single hot upgrade key exists (today’s default), it is weakest: one key can change the rules at any time. That does not make the economics a rug — there is still no token to dump — but it means the protocol’s behavior ultimately rests on trusting the authority holder not to deploy hostile bytecode.
SithBit’s intended direction is to constrain the upgrade authority as a
deployment matures — a multisig at minimum, and freeze as the honest
end-state for a protocol that markets immutability. Whether a given
network has taken that step is, again, a solana program show away; do not
take a doc’s word for it.
Settled for SithBit’s canonical network: multisig at launch, freeze on a trigger. This page used to leave the end-state open. For this project’s own network it is now decided: launch with the authority in an
N-of-Mmultisig — option (a) — and treat freeze (c) as a later step gated on a stated trigger rather than as a launch posture. The trigger is that the rules stop moving: freeze once no planned protocol work still needs an instruction-set change, so that RFC coverage is no longer growing the programs’ surface. Freezing before then forecloses the on-chain bug-fix path while that coverage is still being extended, and a program that cannot be fixed is a weaker promise than one whose fixers you can count and name. A DAO (b) is ruled out: governance that is more than a multisig wants tokenomics to weigh votes, and SithBit’s “minimal rake, no token” stance excludes the token that would take — and the token-free variant of (b) is (a) with a governance program bolted on.That is a decision about one deployment, not a rule the protocol imposes. Every network still sets its own upgrade authority at deploy time and can transfer it, so for any specific deployment — including this one, after launch — evaluate the live authority with
solana program show, not this page’s statement of intent.
Relationship to postmaster custody
The upgrade authority and the postoffice admin keys are different powers, and compromising any of them is severe in a different way (the postoffice side is itself split — a hot operational delegate and a hidden ceremony-committed owner, see The Postmaster):
| Upgrade authority | Delegate key | Postmaster (ceremony seeds) | |
|---|---|---|---|
| What it controls | The program bytecode — every rule | Domain authorize/deactivate (timelocked), capped fee retune, root KSK | Postoffice sweep, delegate rotation, ownership handover |
| Blast radius of compromise | Rewrites all protocol rules; can invalidate every on-chain guarantee | Operational outage only — no path to funds or ownership | Full postoffice ownership, within the current rules |
| Bounded by | Only its custody (nothing on-chain caps a redeploy) | On-chain fee caps, the deactivation timelock, one-delegate revocability | The Merkle commitment: each key usable once, set rotates on use |
| Recovery | Redeploy fixed bytecode — if the authority is still trustworthy | Ownership-signed delegate | commitment to a fresh ceremony |
Because they are distinct, a serious deployment should custody them separately — different keys and different custodians — so that one compromise is not all. A single operator holding one keypair for everything collapses that separation and should be treated as a pilot-only posture. The postmaster custody runbook covers the seed vaults and the delegate’s rotation cadence; the same discipline applies to the upgrade authority, with freeze as an additional option the postoffice roles do not have (you cannot make the postoffice “immutable” — the network must always be administrable).
See also
- Postmaster key custody — custody of the administering keys.
- Trust assumptions and threat model — where these powers sit among the network’s trust boundaries.
- Running a mail server — the
solana program deploystep that sets the initial upgrade authority.
Tracking pixels, consent, and the paid inbox
The Introduction cites a report on the French regulator’s tracking-pixel rules — the CNIL’s €325 million fine against Google (September 2025) for advertising inside Gmail without consent, and its proposal that tracking pixels need a consent of their own, separate from the consent to receive the mail. This page takes the article’s problems one at a time and says what SithBit does about each — and, at the end, what it does not.
The problems the article describes
- The pixel itself. A 1×1 transparent image in an HTML mail whose fetch reports your IP address, device, mail client, the time you opened the message and how often you re-open it.
- Who the data feeds. Marketing-automation platforms (open rates), CRM systems (engagement scores), behavioural-profiling databases, and automated follow-ups triggered by an open.
- Bundled consent. Signing up for a newsletter is taken as consent to be tracked; the regulator says the two are separate permissions.
- Withdrawal that has to reach back. Revoking consent must stop the pixel firing even in mail already sent — which a sender cannot technically guarantee once the HTML is in your inbox.
- Ads in the inbox. The provider monetises the mailbox it “gives” you by placing advertising inside it, without asking.
- The user’s only defence is a blocker. Protection is a browser extension the reader installs, working against the mail’s design.
What SithBit does instead
| Article’s problem | SithBit’s answer today |
|---|---|
| The pixel reports opens, IP, device, timing | The webmail reader renders every HTML body inside a sandboxed frame whose Content-Security-Policy is default-src 'none' — no image, style sheet or script is fetched from the network, so a pixel never fires (img-src data: keeps inline images working). See the webmail app. |
| Senders want to know the mail arrived | They already know, without asking your device: a delivered message is a record on the chain, written when your postage is collected. That is a delivery receipt, not an open receipt — the sender learns the message landed, never when or where you read it. |
| Senders want engagement scores | Engagement is something you sell, not something taken: a reply bounty pays you for an answer, and the only signal the sender ever gets is the reply you chose to write. No open rate, no re-open count, no device fingerprint. |
| Bundled consent | The two permissions are two different on-chain objects. Consent to receive mail from someone is the price you set on their frombox; consent to hear from advertisers is a separate, explicit beacon you publish, naming the topics you agreed to — and nothing about ordinary mail changes if you never publish one. |
| Withdrawal must reach back | Closing a beacon removes you from every campaign search immediately and refunds its deposit; raising a sender’s frombox price stops their next message at the chain. Nothing needs to reach back into mail already delivered, because delivered mail carries no live tracker to disable. |
| Ads in the inbox | There are none, because the inbox is not the product. Postage pays for delivery and roughly 90% of it goes to you; the operator earns its share from carrying your mail, not from selling your attention alongside it. |
| Behavioural profiles built from your reading | A beacon exposes only coarse, self-chosen tags; the fuller profile, if you attach one, is encrypted, and an advertiser who wants it has to mail you — paying your price — and ask for the key. |
| The reader’s only defence is a blocker | With Lockbox the body is sealed on the sender’s device and only your key opens it, so no relay or server can read it to build a profile in the first place; the sandboxed reader means you need no blocker to stop the pixel. |
What SithBit does not fix
- Delivery metadata is public. The chain records which wallet mailed which, when, and for how much — permanently. A tracking pixel leaks less to the sender than SithBit publishes to everyone; the difference is that you know exactly what is exposed and it never includes your IP, device, or reading habits. What’s public and private spells it out.
- The extensions render in the host application. The Thunderbird and Outlook plugins add Lockbox and account management; whether remote images load in the message view is the host client’s own setting. Turn remote content off there — both clients default to blocking it.
- Mail relayed out to conventional addresses leaves the protocol at the SMTP boundary and inherits the recipient’s provider, pixels and all.
- Do-not-disturb refuses mail at SMTP time rather than reporting your schedule, and only shows away windows to senders when you opt in — but a refusal is itself a signal that the address is live. See Do not disturb.
Further reading
- Trust assumptions and threat model
- What’s public and private: the field reference
- Campaigns — the advertiser’s side of the same consent model
How sealed-box encryption works
Mailbox Keys mentions that mail sealed to the
wallet — the default, no published key required — uses
libsodium’s crypto_box_seal. This page goes into
what that actually does and why it’s a good fit for encrypting straight to a
Solana wallet address.
Why a “sealed box”?
Ordinary public-key encryption (a “box” in libsodium’s terms1) is built for two people who both hold keypairs and want to authenticate each other: sender and recipient each contribute their own secret key, so the recipient can tell the message really came from that sender. A sealed box drops the sender’s half entirely. Sealing needs only the recipient’s public key — nothing the sender has is checked or provable afterward. That’s the right shape for mail delivery: any MX server should be able to encrypt to any recipient’s published wallet address without holding a keypair of its own, and the resulting ciphertext shouldn’t reveal who sent it.
From a wallet address to an encryption key
A Solana wallet address is an Ed25519 public key — the curve Solana uses for transaction signatures. Sealed boxes need an X25519 key instead, the curve used for key exchange. Both curves are two different coordinate systems over the same underlying curve (Curve25519), and there’s a standard, one-way conversion from an Ed25519 point to its X25519 counterpart. SithBit performs that conversion on the fly — no separate key is published on-chain for the default case:
- Encrypting: any sender’s mail server converts your wallet address (public) into the X25519 public key to seal to.
- Decrypting: only you can perform the matching conversion on your
secret side, because it needs the seed in your wallet keypair file — the
same file
solana-keygenorsithbit walletproduce, never published.
An address that isn’t a real Ed25519 point at all — notably a Program Derived Address, which has no private key — has no valid conversion and so can never receive sealed mail. This is also why delegated keys exist: hardware and browser wallets can sign with their Ed25519 key but never export the seed the conversion needs, so they publish a self-generated X25519 keypair instead and skip the conversion step entirely.
Sealing a message
Every time a message is sealed — regardless of whether the destination public key came from a wallet conversion or a published delegated key — the same steps run:
- Generate a brand-new X25519 keypair, used for this one message only.
- Run Elliptic-Curve Diffie-Hellman (ECDH) between that ephemeral secret key and the recipient’s X25519 public key. Both sides of an ECDH exchange land on the same point without either one ever transmitting its secret key — that shared point becomes the encryption key.
- Encrypt the plaintext with that shared secret using the XSalsa20-Poly1305 stream cipher, producing ciphertext plus a 16-byte authentication tag.
- Discard the ephemeral secret key. It is never stored or reused.
Throwing away the ephemeral key after one use means the exact same plaintext seals to different ciphertext every time, and nobody — not even the sender, moments later — can reconstruct that message’s shared secret again. It also means the sealed box carries no reusable identity: two messages from the same sender to the same recipient share nothing an observer could link together.
Opening a sealed box
The recipient runs the mirror image of sealing:
- Read the ephemeral public key from the front of the sealed box (see the wire format below — it’s always the first 32 bytes after the header).
- Run ECDH between their own X25519 secret key and that ephemeral public key. This lands on exactly the same point the sender computed in step 2 above — that’s the whole point of Diffie-Hellman: both sides derive an identical shared secret from different halves of the same exchange.
- Use the shared secret to verify the Poly1305 tag and decrypt.
If the tag doesn’t verify — wrong key, or the bytes were altered in transit — decryption fails outright rather than returning corrupted plaintext.
sithbit mail decrypt <file> --keypair <path to wallet keypair>
The wire format
A sealed envelope is a flat, self-describing byte string — no separate key exchange step, no round trip, nothing beyond the message itself needs to reach the recipient. The first four bytes name the format generation, and two generations are live:
In a v2 envelope the ciphertext is exactly as long as the plaintext — sealed boxes use a stream cipher, not a block cipher, so there’s no padding to account for. This whole envelope is what actually gets pinned to IPFS; see IPFS storage: benefits to users for why encrypting before pinning matters given that anyone holding a CID can fetch the raw bytes.
Compression: the v3 generation
Mail bodies are mostly text, and text compresses well — so before sealing, the sender DEFLATE-compresses the plaintext (raw DEFLATE, RFC 1951) and compares. If the compressed form is smaller, it gets sealed under the v3 header; if not — media attachments and archives are usually already compressed — the plaintext is sealed as plain v2, so no envelope ever comes out larger than it would have before v3 existed. Storage (and IPFS pinning cost) simply shrinks whenever compression wins.
Two properties are worth calling out:
- Compression happens inside the encryption boundary. The bytes that reach IPFS are sealed-box ciphertext either way; an observer holding the CID learns the (compressed) length and nothing else, exactly as with v2.
- Decompression is capped. When opening a v3 envelope, the recipient inflates at most 64 MiB — far above any real message, since the SMTP servers reject mail beyond their configured size limit (25 MiB by default) — and refuses anything claiming to inflate further. A maliciously crafted “decompression bomb” therefore fails cleanly instead of exhausting memory.
Recipients never choose a version: decrypt_envelope (and every client
built on it — the CLI, the wasm viewer, the web clients) reads the header
and opens whichever generation it finds, so mail pinned before v3 existed
keeps opening forever.
Where libsodium fits
libsodium is named all through this page, and the further-reading links below point at its documentation — so it is worth being exact about what it is here, because it is not one of SithBit’s moving parts.
libsodium is the specification, not the implementation. It is a
widely-deployed C cryptography library, and crypto_box_seal is one of its
constructions: the ephemeral-keypair-then-ECDH-then-XSalsa20-Poly1305 recipe
walked through above, plus the exact byte layout of the box it produces. Its
documentation is the clearest published description of that construction,
which is why these pages cite it and borrow its vocabulary.
What SithBit actually links is crypto_box, the RustCrypto project’s
pure-Rust implementation of the same construction. No C library is compiled,
linked, or shipped anywhere in the tree. That matters most in the browser: the
web clients, the browser extensions and the trustless viewer all run the same
sealing and opening code compiled to WebAssembly, and staying pure Rust is what
keeps that one code path instead of two — a JavaScript crypto library for the
browser and a native one for the servers, drifting apart over time.
The payoff is interoperability. Because the format is libsodium’s and the
bytes match, a client that already speaks
crypto_box_seal — libsodium.js in a browser, tweetnacl’s sealedbox, or
libsodium itself from C, Python, or Go — can open SithBit mail with no
SithBit-specific crypto code: strip the four-byte generation header described
above, hand the rest to the sealed-box open call, and inflate the result if the
header said v3. Nothing about reading your own mail is locked to this
implementation.
Further reading
- libsodium: Sealed boxes
- libsodium: Ed25519 to Curve25519 conversion
- The RustCrypto
crypto_boxcrate SithBit actually links - Elliptic-curve Diffie-Hellman (Wikipedia)
-
“In libsodium’s terms” is the whole of the relationship: libsodium supplies the vocabulary and the byte format, not any code SithBit runs. Nothing in the tree links libsodium — the sealing is a pure-Rust implementation of the same construction. Where libsodium fits spells that out. ↩
How blake3 hashing works
Chapters across this book keep mentioning blake3 hashes: an alias name lives on-chain only as one, a frombox seeds on one, a reply names the bountied message it claims with one, a DNSSEC proof buffer is keyed by one, and the postoffice commitment set is a Merkle tree built entirely out of them. This page goes into what a cryptographic hash actually does, why SithBit hashes things at all, and why blake3 in particular is a good fit.
What a cryptographic hash is
A cryptographic hash function takes an input of any length — a name, an email address, a whole file — and produces a fixed-size output called a digest (32 bytes, for blake3). Think of it as a fingerprint for data. A good one has four properties:
- Deterministic. The same input produces the same digest, forever, on every machine. This is what makes a digest usable as an identity.
- Fixed-size. Whether the input is 8 bytes or 8 gigabytes, the digest is exactly 32 bytes.
- One-way. Computing the digest from the input is one cheap pass. Recovering the input from the digest is not a matter of running anything backwards — no such algorithm exists. The only attack is guessing candidate inputs and hashing each one to check.
- Collision-resistant. Nobody can find two different inputs that produce the same digest, so a digest can stand in for its input without fear of an impostor.
A consequence of these properties is the avalanche effect: changing a single character of the input reshuffles essentially every bit of the digest. The two digests give no hint that the inputs were nearly identical.
(The digests above are real blake3 outputs, shown truncated; each is 32 bytes — 64 hex digits — in full.)
One-wayness deserves its own picture, because it is the property the rest of this page leans on: publishing a digest does not publish the input.
Why SithBit hashes things at all
Three recurring jobs in the protocol are hash-shaped:
-
Turning names into account addresses. Solana derives program-owned accounts (PDAs) from seeds, and each seed is capped at 32 bytes. An alias or domain name is a variable-length string that may well exceed that — but its blake3 digest is always exactly 32 bytes, so the digest is the seed. Determinism does the rest: every client, and the program itself, hashes the lowercased name and lands on the same account. See the Program & PDA reference.
-
Keeping address strings off the chain. No SithBit instruction or account carries an address string. A message account stores only the blake3 hash of the normalized
fromaddress, and the frombox seeds on the same hash — the readable headers travel inside the sealed body. One-wayness is what makes this worth doing, with an honest caveat: a hash of a guessable input can be confirmed by hashing the guess, so this hides the address book rather than encrypting it — see the threat model. -
Naming something without repeating it — a checkable commitment. A bountied reply stores
reply_to_hash, the blake3 of the original message account’s base58 address. At claim time the program hashes the account key it is handed and compares: equal means the reply provably names that message, and collision resistance means nobody can craft a different “parent” with the same digest.
Why blake3 specifically
The protocol needs a modern cryptographic hash; blake3’s particular shape fits unusually well:
- The digest is exactly 32 bytes — precisely Solana’s maximum PDA seed length, so a digest drops into a seed slot with no truncation (truncating a digest weakens it) and no padding.
- It is fast in a single pass with no key setup, which suits hashing many short names — and its construction has no length-extension weakness, so a digest can be published as an identity without the ceremony (double-hashing, HMAC) that raw SHA-256 needs in some commitment patterns.
- One implementation everywhere. blake3’s reference implementation is
pure Rust and
no_std-friendly, so the exact same crate compiles into the on-chain programs’ SBF bytecode and into every host-side tool (the CLI, the gRPC gateway, the mail servers). The programs hash in-program rather than through a syscall, and client and chain agree byte-for-byte because they are literally running the same code. (Where the protocol must use SHA-256 — the DNSSEC proof verifier, whose record digests are fixed by the DNS RFCs — the programs use Solana’ssol_sha256syscall instead; blake3 is SithBit’s own choice for naming and linkage.) - It hashes as a tree. Internally blake3 splits input into 1 KiB chunks and combines their hashes as a binary Merkle tree, which is what lets large inputs hash in parallel and supports verified streaming. SithBit’s inputs are far too small to exercise this, but it is the design that gives blake3 its speed headroom and its name.
To be plain about history: the codebase does not record a written selection rationale for blake3 (its own glossary entry just calls it “the fast hash”). The properties above are why it fits, not a quoted design memo.
Where blake3 appears in the protocol
| What is hashed | What the digest becomes | Chapter |
|---|---|---|
| Alias name (lowercased, domain-stripped) | The alias account’s PDA seed; the transfer-escrow and marketplace-listing PDAs reuse the same digest under their own seed prefixes | Aliases |
| Domain name (lowercased) | The domain account’s PDA seed; pending-deactivation and domain-listing PDAs reuse it | Domains |
Normalized from address | The frombox PDA seed, and the from_hash stored in every message account — the plaintext address never reaches the chain | Fromboxes, Sending mail |
| Bountied message account key (base58 text) | reply_to_hash in the reply — the privacy-preserving claim linkage | Reply bounties |
| Claimed domain (lowercased) | Part of the proof-witness buffer’s PDA seed, so each claimant stages into their own deterministic buffer | Authorize a domain by proof |
| Attesting domain (lowercased) | Part of the sender-attestation PDA seed, combined with the attested wallet — one attestation per (domain, wallet) pair | Attest a verified sender |
| Postmaster ceremony wallet (raw 32-byte pubkey) | A leaf in the postoffice’s commitment_root Merkle tree; the sorted-pair interior nodes fold up to that root, and a membership proof re-derives it leaf-by-leaf | Postmaster key custody |
Further reading
Solana clusters and RPC endpoints
Every sithbit command that touches the chain talks to a cluster — one of
Solana’s independent networks, each with its own validators, ledger, and
state — through an RPC endpoint URL. This page explains what the public
clusters are, which one SithBit runs on today, and what the URL you configure
actually points at.
The three public clusters
| Cluster | Public RPC endpoint | What it’s for |
|---|---|---|
| devnet | https://api.devnet.solana.com | The developer playground. State can be reset, and SOL is free via faucet airdrops — nothing on devnet has real value. |
| testnet | https://api.testnet.solana.com | Where Solana’s core contributors stress-test new validator releases. Also has a faucet, but it exists for network testing, not applications — rarely relevant to app users. |
| mainnet-beta | https://api.mainnet-beta.solana.com | The production network. SOL here is real money; there is no faucet. |
The clusters are completely separate: an account, program, or balance on one
does not exist on the others. Configuring an endpoint (sithbit config set --url <cluster>, or the JSON_RPC_URL environment variable — see
CLI Quickstart)
is what selects which network you’re talking to.
Which clusters SithBit uses
- Devnet hosts SithBit’s live test deployment: all three programs are deployed there under the devnet-only vanity IDs. Because devnet SOL is a free airdrop away, this is where the setup wizard can fund a fresh wallet automatically and let you claim a mailbox and send mail at zero cost.
- Local development doesn’t use a public cluster at all: the CLI
integration suite and day-to-day program work run against a
surfpool test validator on
127.0.0.1:8899— a private, disposable cluster of one. The development and pilot servers page covers the matching memory-backed mail binaries. - Mainnet-beta is where the production launch will live. The programs are not yet deployed there — the mainnet-track identities exist (see Program & PDA reference), but until launch, pointing the CLI at mainnet-beta finds no SithBit programs.
- Testnet is not used by SithBit.
Validators vs. RPC servers
The URL you configure is an RPC endpoint, not necessarily a validator.
A validator participates in consensus: it holds stake, votes on blocks, and
takes turns producing them. An RPC server runs the same node software
configured without voting — it replays the ledger to stay current, answers
JSON-RPC reads (balances, account data, transaction history), and forwards
the transactions you submit to the current block producers. Every cluster is
served by both kinds of node, and the public api.*.solana.com endpoints
above are RPC fleets, not voting validators.
The public endpoints are shared and rate-limited. That’s fine for CLI use and light development, but anything heavier — an MX server checking postage on every inbound message, or a production deployment — warrants a dedicated endpoint: either a commercial RPC provider or a self-run RPC node.
Further reading
- Solana’s clusters reference — the official page for the three public clusters and their endpoints.
- Solana JSON-RPC API — the full set of methods an RPC endpoint serves.
- Setting up an RPC node — Anza’s operations guide to running your own non-voting node.
See also
- Devnet-only vanity program IDs — why SithBit’s devnet deployment lives under a second program identity.
- Deploying to devnet/mainnet-beta: the modexp-free build — the build-flag mechanics of targeting a real cluster.
- Development and pilot servers — the local, memory-backed mail binaries that pair with a local validator.
The mail-grpc gateway topology (design note)
This note records a deliberate architecture decision (2026-07-15): the
mail-grpc chain gateway stays a separate
service rather than being folded into the servers that consume it. It
explains what the gateway actually is, who talks to it, why the
alternatives were rejected, and what would reopen the question — so the
“why is there a gateway?” answer survives in one place.
What mail-grpc actually is: three roles in one process
The gateway is not one thing but three, and any topology decision has to place all of them:
- A remote-signing write gateway. Five RPCs (
SendMail,DeleteMail,RefundMail,ClaimBounty,RefundBounty) build, sign, and submit Solana transactions. Every one of them signs with a single process-wide keypair — thekeypairdescribed in the mail-grpc chapter — which acts as fee payer and sole signer. For the bounty RPCs that key is the on-chain wallet being settled: the gateway is, in effect, the operator’s on-chain mail identity. - A chain-read gateway. Thirteen RPCs (
ResolveAlias,GetFrombox,GetMailbox,GetMailboxKey,GetTransactionStatus,FindMessage,GetMailDomain,ListAuthoritativeDomains,BrowseListings,ListParticipants,GetSenderAttestation,GetPinLease,GetSenderReputation) read accounts and signatures directly from finalized chain state —GetPinLeaseas a filteredgetProgramAccountsscan for a CID’s leases,GetSenderReputationas two account fetches (the wallet’s reputation PDA plus the postoffice) folded through the on-chain pricing rule. No key material is involved beyond the operator identity two of them imply (ListAuthoritativeDomainsanswers “which domains is my signing key authoritative for”). - The only home of the off-chain alias/sales indexer.
ListAliasesandListSalesare served from a SQLite index the gateway builds by scanning transaction history, because alias names are not recoverable from chain state alone (accounts key on hashes). This role is an irreducible singleton: deleting the gateway would not delete a deployable role, it would relocate one — and hand every consumer that wants alias listings a new protocol to reach it.
Two keys, two services — a common conflation
The gateway’s signing key is not the postmaster’s standing delegate key, though the two are easy to conflate:
- mail-grpc’s signing
keypairis a general fee-payer/signing key for mail traffic (sends, deletes, bounty settlement). It can be loaded from Azure Key Vault. - The standing delegate (
delegate_key_file) lives indomain-sithbit, authorizes domains on-chain, and is re-read from disk on every request — see Postmaster key custody.
Two keys, two services, two custody stories. Keeping the write gateway separate keeps the mail-signing key in exactly one process — the only AKV-capable holder — instead of copying it to every worker.
Fund the signing key as a hot wallet
The gateway signs unattended, for every chain write the fleet makes, on a port whose only protection is where it sits. Treat the key accordingly:
- Narrowly funded. Keep a working balance — enough transaction fees for the traffic between top-ups, plus room for whatever bounties the spool escrows — and top it up on a cadence rather than parking a treasury there. What is in the wallet is what is at risk.
- Never doubles as anything else. It must not be a program’s
--upgrade-authority, the postmaster’s domain authority, a marketplace treasury, or a user wallet. Those keys should be offline and separately custodied; see Postmaster key custody. - Monitored. The balance is an operational signal: a drop faster than the fleet’s send volume explains means either a misconfigured bounty or an exposed port.
Who consumes it — and who never does
Every consumer is another server; no end-user client ever dials the gateway:
sithbitdis the only writer. Its chain workers driveSendMailandDeleteMailoff store-backed job queues, serialized per wallet by a store lease — which is why a cloud-store fleet of manysithbitdinstances can safely share one gateway. Its SMTP accept path asks the gateway for postage at RCPT time, and its at-rest sealing asks it for reader keys.- A standalone
smtp-serverMX performs read-only postage verification (ResolveAlias+GetFromboxper recipient). account-apiroutes compose recipients (alias → postage check) and backs its/v1/chainREST proxy with gateway reads. Its balance and transaction-submit routes deliberately use its own direct Solana JSON-RPC instead — client-signed transaction relay and plain balance reads need no gateway semantics. That split is documented here as intentional; it is not drift to “fix”.sithbit-console’s balances pane readsGetMailbox/GetFromboxdirectly.- Web and plugin clients never touch it. There is no gRPC-web anywhere: the webmail/marketplace/onboarding panes reach chain data through account-api’s REST proxy, and trustless webmail goes straight to a public Solana RPC endpoint with client-side decoding — bypassing the operator’s servers entirely.
The dependency contract matters as much as the call graph: consumers
link only the mail_api proto crate (wire types + generated client, a
handful of dependencies) instead of the full Solana host stack
(~a dozen solana-* crates plus their transitive weight) that
mail-grpc absorbs once. The same seam is the test seam — the
protocol servers’ suites fake the SolanaMail service in-process, which
is what keeps them hermetic and fast.
Network posture: private-network-only, by design
The gateway signs with a process-wide fee-paying wallet on behalf of whoever calls it, so the question “who may call it” decides who may spend that wallet. The answer is now given by the transport, not by the network alone:
A non-loopback
bind_addrrequires mutual TLS.mail-grpcrefuses to start on any reachable address without a complete[auth]section, and authenticates each caller by the Ed25519 key in its client certificate. Loopback with no[auth]serves unauthenticated, which is what keeps a zero-config dev run and the test suites working.
Note the asymmetry: the loopback exemption keys off the absence of
configuration, not off the address being privileged. An [auth]
section that is present is enforced on every bind, loopback included —
so configured authentication can never be silently ignored, and the
authenticated path stays reachable from tests, which bind loopback.
Keeping the gateway on a private segment is still correct and still
recommended — the iac/ templates bring a private subnet with no public
ingress in all three clouds. It is simply no longer the only thing
standing between the fee payer and the network. Reachability narrows who
can attempt a call; [auth] decides who is served.
This reverses a posture recorded twice. Until 2026-08-26 the surface carried no TLS and no authentication, and a security review that raised the unauthenticated write surface was answered with guardrails rather than authentication. That decision named its own reopen trigger — a gateway reachable across a segment the operator does not trust — and the trigger has been exercised deliberately, on a zero-trust reading of the network. Do not re-derive the old posture from an older document.
The guardrails around that posture
[auth] decides who is served; these two settings bound what an
authorized caller can cost if one is ever compromised, and they are
unchanged by the move to mutual TLS:
max_bounty_lamports(default100000000, 0.1 SOL) — aSendMailrequest’sbounty_lamportsis escrowed from the gateway’s own wallet, so an uncapped caller-chosen amount is a wallet-draining primitive rather than a protocol parameter. Over-cap requests are refused withINVALID_ARGUMENTbefore anything reaches the chain;0refuses bountied sends outright.fee_payer_floor_lamports(default10000000, 0.01 SOL) — the balance below which every write RPC is refused withUNAVAILABLEand/readyzreports not-ready. The balance is sampled on a 30-second timer rather than per request, so the check costs no round trip; a balance never read successfully permits writes, which is what keeps library embedders and the test suites on the unguarded path. This is a floor on spending, not a bound on loss — see the funding guidance above — but it converts a drained wallet from a queue of slow preflight failures into one refusal and an alert.
None of these substitutes for the network boundary — they are what keeps a misconfiguration from being unbounded.
One operational caveat follows: an
administrator running sithbit-console from outside that network needs
a route in (VPN or bastion) for the balances pane — everything else the
console does goes through account-api.
Options considered
| Ops surface | Key custody | Test seam | Dep graph | |
|---|---|---|---|---|
| A. Keep separate (chosen) | one extra process (chain profile only) | one AKV-capable holder | intact | consumers stay proto-only |
| B. Embed in every consumer | −1 process, but the indexer needs a new home and protocol | key copied to N processes | all in-process fakes destroyed | Solana stack linked everywhere, incl. RCPT-only MXes |
C1. Writes into sithbitd only | 2 chain-access paths forever | key copied to every worker | write-path fakes broken | sithbitd links the full stack |
C2. Optional in-process hosting in sithbitd | both hostings maintained forever | unchanged | intact | sithbitd links the full stack |
Decision and rationale
Option A: the gateway stays a separate, private-network service.
- The indexer forces a standing service anyway. The marginal cost of the read/write gateway roles riding in the same process is near zero; every integration variant still runs a process and pays migration.
- Custody stays singular. One key, one process, Key-Vault-capable. Integration multiplies copies across workers and pushes the key-loading machinery into async consumer code for no custody gain.
- The latency argument is empty. The hop is one loopback/private gRPC round-trip in front of a Solana RPC round-trip that is orders of magnitude larger and dominates every call.
- The failure modes integration would “fix” are already correct.
Gateway trouble at RCPT tempfails
451 4.4.3(the sending MTA queues; operator trouble never bounces mail); sealing fails closed rather than falling back to plaintext; chain jobs retry from durable queues; every consumer dials lazily, so a gateway restart never crash-loops anything. - The proto seam is one of the workspace’s best assets — the in-process fakes keep four consumers’ test suites hermetic. Every non-A option damages or bifurcates it.
What would reopen this decision
The gateway ever needs to be reachable across a hostile network segment: unauthenticated remote signing becomes untenable — add authentication.FIRED 2026-08-26. Authentication was added (mutual TLS,[auth]above) on a zero-trust reading of the network, andallow_remote_bindwas retired with it. This trigger is spent; do not requeue it, and do not restore the unauthenticated posture.- A deliberate product decision to ship a single all-in-one binary for operator simplicity: revisit C2.
- Per-user signing keys replacing the process-wide operator key: the write gateway’s signing model dies regardless; redesign then.
Hardening queued with this decision
Three in-place improvements were queued rather than bundled here:
(1) move mail-grpc onto the same TOML/env config layering as every
other service, with dev-friendly in-code defaults (loopback bind, port
50051) so the private-only posture is the default rather than a
convention — landed 2026-07-15 as a clean break (the legacy
env-only configuration is gone; see
the mail-grpc chapter); (2) make
domain-sithbit’s delegate key loadable from a key source (file or
Azure Key Vault) like the workspace’s other secrets — landed
2026-07-15 (see
key sources);
(3) give mail-grpc a presence in the infrastructure-as-code
templates, in a private subnet — landed 2026-07-15 as an opt-in,
BYO-network unit in both templates (ECS Fargate on AWS, a
VNet-integrated ACI container group on Azure; see
Provisioning with IaC). The related seam —
chain-enabled sithbitd requiring an [ipfs] section even when an MX
only wants RCPT verification — also landed 2026-07-15 with the
config work: [grpc] alone is now the verification-only MX posture
(see the chain-pipeline
section).
The participant-pool marketplace (design note)
This note records the design decisions (2026-07-16) for the participant-pool marketplace: advertisers and survey researchers search a pool of opted-in participant profiles (demographics/interests/skills) and offer reply bounties to an eligible group to encourage responses to ads and surveys. This is the settled shape the implementation items build toward, and the record of why the alternatives were rejected. The on-chain beacon (item 43) is implemented as of v0.9.0 — see the program reference for its instructions, PDA seed, and error codes. The CLI campaign tree (item 44) and the web surface (item 45) have both since landed — see the campaign CLI reference and the shipped web surface.
The persona and the economics
The buying side is a funded campaign wallet: an advertiser funds one wallet, then signs N bountied sends programmatically — the in-page-wallet path or a CLI batch. Bounty authoring is already settled as a direct-signed surface: each send escrows its bounty from the sender’s own wallet, and the relay stays bounty-less.
This is exactly the cohort the protocol’s economics want to charge. The standing positioning principle — the system must feel free to use and be profitable for recipients, with the cost burden on senders — maps cleanly: a participant profits twice per campaign message (postage on delivery, bounty on reply), and the campaign wallet pays every cost (message rent, postage, bounty escrow, fees).
Decision 1 — profiles are an on-chain beacon plus a sealed detail blob
A participant opts in by creating a participant beacon: a small program-owned account, one per wallet (PDA seeded on the wallet, like a mailbox). The beacon carries only:
- a coarse tag bitmap — self-attested categories drawn from a fixed,
append-only vocabulary defined in
mail_modelconstants (interests, skills, broad demographic bands, preferred languages, and coarse roles — never precise values); - an optional detail CID — the content address of an encrypted rich profile the participant pinned to IPFS;
- created/updated timestamps.
Every field is fixed-size, so the borsh layout is stable and rent is a constant (the listing-account precedent). Closing the beacon reclaims its rent — opting out is free and complete: the account’s existence is the opt-in.
Coarse-by-construction is the privacy design. A wallet-linked on-chain record is public forever, so the public layer is restricted to category bits that are individually low-information; anything expressive lives only in the sealed detail blob. There is no free text on chain.
Decision 2 — detail access is mail-native key handout
The detail blob is encrypted with a random symmetric key and pinned; the beacon publishes only its CID. An advertiser who wants the detail mails the participant — paying normal postage, optionally attaching a bounty to sweeten the request — and the participant replies with the key if they choose to share.
The request/grant flow is therefore SithBit mail itself: no new protocol, no access-control machinery, and the participant is paid for the attention either way. The accepted caveat: a handed-out key can be re-shared, so the detail blob should contain nothing whose onward disclosure would be harmful; rotation is re-encrypt + update the CID.
Decision 3 — two search paths, one layout
- Web panes search through a chain-derived
account-api index route (
/v1/chain/participants?tags=…), mirroring the existing listings/sales index pattern: the JWT authenticates the request, the index is global. - The CLI campaign tree (decision 4) is chain-direct like every
other
sithbitcommand: it scans trustlessly withgetProgramAccounts+ memcmp filters over the tag bitmap.
The layout rule that keeps both honest: the tag bitmap sits at a fixed account offset, so memcmp filtering works without deserializing and the index route stays a convenience, never a gatekeeper.
Decision 4 — group offers author through the CLI first
The first authoring surface is a sithbit campaign command tree run by
the campaign wallet:
- search — filter participants by tags (trustless scan);
- quote — price the campaign before sending: each recipient’s live state (standing frombox price, prepaid stamps, or the reputation-scaled first-contact price) plus the shared legs, summed per class;
- send — execute N direct-signed bountied sends.
A marketplace-pane “Participants” tab with a group-offer flow rides the
same library core later — the same path alias/domain sell/buy took
from CLI primitive to browser pane.
Decision 5 — the advertised price is default_postage
A participant’s participation price is their mailbox’s
default_postage. Opting in means setting your default postage to
what campaigns must pay — a participant is inviting cold mail from
strangers, which is precisely what the default price governs. Campaigns
pay through the normal frombox prepay flow.
This makes the advertised price authoritative by identity rather than by enforcement: there is no second price field to drift, no send-path ABI change, and the spam-economics stay intact — a wallet that never opted in still prices cold mail at the spam floor.
Out of scope — the operator’s campaign service
A managed campaign service (deposits, billing, dashboards, audience management) is a separate product surface an operator may build on top of these primitives. The protocol is untouched either way (recorded 2026-07-15 with the item’s creation).
Rejected alternatives
- Off-chain-only registry — no ABI change, but operator-centralized: profiles aren’t portable across operators and campaigns must trust one index. The beacon keeps the pool a protocol surface.
- Rich profiles on chain — permanent public demographics linked to a wallet is a privacy failure regardless of consent phrasing.
- Per-requester re-sealing of the detail blob — private, but then the public CID serves no purpose; it collapses back to beacon-only with mail attachments, and loses the one-blob/one-pin simplicity.
- An enforced beacon price honored by the send path — requires
SendMail/frombox ABI changes and a protocol definition of “campaign send”; rejected in favor of thedefault_postageidentity.
What would reopen this
- Tag-vocabulary exhaustion — the bitmap filling up forces a wider field or a versioned account tier (the postoffice tiered-read precedent applies).
- Key-resharing abuse in practice — would revisit per-requester sealing or third-party attestations for the detail layer.
- Regulatory treatment of demographic categories — could force vocabulary changes or geographic gating of the search surface.
- Index scale — if participant counts outgrow
getProgramAccounts, a dedicated indexer (the alias/sales SQLite precedent) takes over the read path.
Implementation items spawned
Recorded as HANDOFF items, in dependency order — the beacon is the surface the other two consume:
- Item 43 — the on-chain participant beacon (LANDED, v0.9.0):
mail_modelaccount + tag vocabulary constants (32-byte bitmap, 24 starter tags — since grown to 71 by the append-only expansion of 2026-08-31, which added 47 tags and the language/role groups with no program redeploy; bits not validated on-chain),mail_programcreate/update/close instructions (discriminants 39–41, errors 86–90 — an additive public-ABI change, hence the MINOR bump), program tests on the compiled.so. Create requires the wallet’s mailbox to exist — the advertised price is itsdefault_postage(decision 5). - Item 44 —
sithbit campaign+ profile authoring in the CLI (LANDED, v0.9.0): beacon create/update/close commands, detail-blob encrypt+pin, campaign search/quote/send. See the campaign CLI reference. - Item 45 — the web surface (LANDED, v0.9.0): account-api participants index route and the marketplace pane’s Participants tab. See the shipped web surface.
The shipped web surface (item 45)
The read path of decision 3 is now live in the browser: the name marketplace grows a Participants tab alongside its For sale / Expired / Sold tabs, so the same pane that browses aliases and domains also browses the opted-in participant pool.
The tab’s browse half is a plain read, riding the same account-api pattern as the listings and sales tabs (loaded lazily the first time it is opened):
- The pane requests
GET /v1/chain/participants?tags=…on the authenticated account-api/v1/chainsurface with the session JWT — the index is global, the JWT only authenticates the request. - account-api proxies that read to
mail-grpc’sListParticipantsRPC, which runs the trustless on-chain beacon scan (getProgramAccounts+ a memcmp filter over the fixed-offset tag bitmap — the layout rule from decision 3). A beacon must carry every filtered tag bit to match.
Each row shows the participant’s wallet, its self-attested tags by
name, and whether it published an off-chain detail document (the
sealed detail CID of decision 2). Filtering takes a comma-separated mix
of tag names and numeric bit positions (e.g. interest.technology,10),
applied with Apply.
Tag names are shared with the CLI by construction. The browser
clients carry their own copy of the vocabulary
(webclients/shared/tag-vocabulary.js), a hand-maintained mirror of the
mail_model TAG_* constants
(program reference) that derives each name by
the same rule the CLI uses, so the names the tab shows and the
sithbit campaign --tag
names are identical (TAG_INTEREST_TECHNOLOGY → interest.technology,
TAG_AGE_18_24 → age.18_24). No Rust→JS export of the constants
exists; the hand-maintained mirror fenced by drift-guard tests on both
sides — the JS coverage test and the CLI’s own vocabulary test — is the
accepted mechanism. Because the vocabulary is append-only, a beacon
written by a newer client can carry a bit the page’s mirror does not
know yet: it renders as its numeric position (bit 71) rather than
vanishing, and the filter accepts bare bit numbers alongside names
(case-insensitively) for the same reason. A filter name outside the
vocabulary is rejected in the page before any request is sent. On the
wire nothing changed: the participants route still takes bit positions,
and names resolve client-side.
Beacon authoring is in the browser too. The tab is no longer
read-only: a My beacon flow below the pool list authors the
connected wallet’s own beacon — create, wholesale update, and close,
signing with the external wallet the way buys and listings do — plus a
pane-local disable/re-enable convention: disable is an update
writing the zero bitmap (the account and its rent stay on chain; the
beacon just matches no tag search), with the prior tag set remembered
per wallet in browser storage — never on chain — so re-enable can
restore it. The detail CID stays paste-only in the page; encrypting and
pinning a profile remains the CLI’s --profile-file capability. See
Managing your own beacon
for the user-facing flow. The group-offer (quote/send) authoring flow
of decision 4 stays CLI-only.
IPFS storage: benefits to users
The Introduction mentions that mail bodies are stored on the Interplanetary File System (IPFS) rather than on-chain or on a provider’s servers. This page goes into why that choice matters to you as a user, not just as an implementation detail.
What is a CID?
You will see the term CID a few times below and in your client, so it is worth a plain explanation first. A CID — a content identifier — is like a fingerprint of a message: a short code that is computed from the exact bytes of the content, not a name someone assigns to it. Feed the same content in and you always get the same fingerprint back; change even a single character and you get a completely different one.
Two things follow from that, and both are load-bearing:
- Identical content always has the identical CID. So the CID works as an address: to fetch a message, you ask the network for that fingerprint, and whatever comes back must be exactly the content it names.
- Any change produces a different CID. So the CID also works as a tamper check: if a stored message had been altered by even one byte, its fingerprint would no longer match the CID you asked for, and you would know at once.
SithBit uses the modern CIDv1 form of these fingerprints exclusively, and it computes them byte-for-byte the same way the standard IPFS tool (Kubo) does — so any IPFS client anywhere can fetch a SithBit message just by its CID. (For the exact ingredients that go into a CID, the technical reader can see the glossary.)
What does pinning mean?
The other term you will meet throughout these docs is pinning. IPFS has no central server deciding what stays available. Instead, each computer in the network keeps the content it has been explicitly asked to keep — and is free to clean away anything else during its routine housekeeping. Asking a computer to keep a piece of content is called pinning it: a pinned message body is held on that machine and stays fetchable by its CID for as long as the pin remains. Unpinning is the reverse — telling the machine it no longer needs to hold the copy, which frees the space to be reclaimed.
The practical consequences:
- A pin is a promise made by one keeper, not by “the network”. Content on IPFS stays retrievable only while at least one computer somewhere pins it. When a message is delivered, the receiving mail operator pins the sealed body — that operator is the keeper you start with.
- Anyone can add their own pin. Pins are independent: any number of parties — including you — can pin the same CID at once, and each pin keeps the content available on its own. That is what Pinning mail does: it puts a copy under a keeper you control, so your mail no longer depends on the operator’s pin.
- Unpinning is not deletion everywhere. Removing a pin only releases that one keeper’s copy; every other pin of the same CID is unaffected.
(For the technical reader: the glossary states the same in storage terms under Pin / unpin.)
Why not just store mail on a server?
Traditional email storage is single-source: your provider’s servers hold the only copy your client ever talks to. If that provider goes down, changes its terms, or decides to suspend your account, your mail history goes with it — and the suspension is usually automated, with no human to appeal to for days or months. IPFS removes that single point of failure and control:
- Content-addressed integrity. Every piece of mail is fetched by a content identifier (CID) — a hash of the content itself — rather than by location. If even one byte of a message changed, its CID would change too, so a CID is a built-in tamper check: what you fetch is cryptographically guaranteed to be what was originally pinned.
- No single company holds your mail. Content on IPFS can be pinned by any number of independent nodes, including ones you run yourself. Nobody needs to trust one operator’s servers to keep a message retrievable.
- You can run the storage layer too. SithBit’s IPFS support is self-hostable, not a hosted-only service tied to one provider — see sithbit-ipfsd: the IPFS pin daemon for running your own node, and the shared-bucket cluster model in Scaling out for pooling several nodes’ worth of resilience.
- Retrieval isn’t locked to a vendor’s API. Because IPFS is an open protocol, any compatible node — SithBit’s embedded implementation or any other IPFS client — can fetch a pinned message by its CID. Your mail isn’t trapped behind one company’s private storage format.
Encryption still does the privacy work
IPFS content is addressed by its hash, not access-controlled — a CID that leaks is fetchable by anyone who has it. SithBit accounts for this: mail bodies are encrypted by the sender (see Mailbox Keys) before they’re ever pinned, so what actually lives on IPFS is ciphertext. IPFS supplies decentralized, integrity-checked availability; encryption supplies confidentiality. Neither one substitutes for the other.
Opting out of IPFS storage
Everything above is why IPFS is the default. Some recipients, though, would
rather their mail bodies never touch a public network at all — even as
ciphertext — and accept a narrower availability guarantee in exchange. For them
a mailbox can carry a no_ipfs opt-out, set with
mailbox create --no-ipfs or
mailbox update --no-ipfs true. When it is
set, the delivery path keeps the sealed body in the operator’s own store and
stamps the on-chain message with a local-only marker (a b3: blake3 tag)
instead of a fetchable CID.
This is a deliberate, per-user re-centralization, and it costs you exactly the benefits listed above:
- The decentralized clients can no longer read it. The trustless viewer and other IPFS-native clients fetch bodies from IPFS by CID. An opted-out message has no public CID to fetch, so those clients cannot open it — they show it as body-unavailable. Only clients that go through your operator’s store (IMAP/POP against your server) can retrieve the body.
- Availability depends entirely on one operator. Nobody else can pin or mirror a body that was never published, so if that operator’s store is unavailable — or the account is suspended — the body is simply gone. You have re-created the single-source risk IPFS was chosen to remove.
mail getandmail pindegrade honestly. Point either command at an opted-out message and, rather than a confusing gateway404, it reports that the body is not on public IPFS (the recipient opted out) and lives only in the operator’s store. There is nothing on a gateway to fetch, hash-verify, or re-pin.- Direct CLI
mail sendrefuses an opted-out recipient.sithbit mail sendonly content-addresses the body —--pathhashes a local file,--cidtakes an identifier you already hold — and commits that identifier on chain. It uploads nothing and pins nothing itself, and no operator store stands behind it, so the only way that message ever becomes readable is for the body to be published to public IPFS: exactly what the recipient opted out of. The command therefore cannot honor the opt-out. It refuses up front and points you at SMTP delivery, where the recipient’s operator stores the copy privately. See Sending mail.
Confidentiality is unchanged either way — the body was already encrypted before storage. What the opt-out trades away is decentralized availability and readability, in return for keeping ciphertext off public infrastructure. Leave it off unless you specifically want that trade.
Further reading
- Per-recipient pin providers — keep an extra copy of your inbound mail pinned under a provider you control
- IPFS official website
- What is IPFS? (official docs)
- Content addressing (official docs)
- InterPlanetary File System (Wikipedia)
Protocol conformance for custom mail servers
Looking up a mailbox notes that a domain’s MX
servers must “support the email program protocol.” This page spells out
exactly what that means for anyone weighing a from-scratch server, or an
existing open-source MTA, against simply running
sithbitd — and draws a line the rest of the docs
don’t draw explicitly: decentralized storage is an optional layer on top
of the protocol, not a requirement of it.
What conformance actually requires
Nothing on-chain checks which software sent a transaction — only whether it’s a valid one. A server “supports the protocol” if it does all of the following, regardless of implementation language or storage choice:
- Builds and submits valid
MailInstructions. The account layouts, PDA seeds, and borsh encoding are defined once inmail_modelandsolana_common/program_common(see the Program & PDA reference) — any server deriving the same PDAs and encoding the same instructions participates in the economic model identically to the reference implementation. - Checks postage before accepting mail. A sender’s frombox stamp balance
must be checked (and decremented on accept) via the
mail_apigRPC service or direct RPC — this is what prices out spam; see Fromboxes. - Seals the body to the recipient before it ever leaves the accepting
server. Mail is encrypted with
crypto_box_sealto the recipient’s wallet or their published delegated key — see How sealed-box encryption works. - Stores the sealed body somewhere reachable by the CID recorded
on-chain. The
Emailaccount’scidfield is an opaque byte string as far asmail_programis concerned — the program never touches IPFS and never validates that acidcorresponds to real, distributed content (see the next section). - Authenticates IMAP/ POP/SMTP sessions. Either wallet-signature SASL PLAIN (verified against the connecting address, no stored secret) or a stored/sealed mail password for clients limited to CRAM-MD5/APOP — see The mail password for why both paths exist.
- Signs outbound mail with DKIM and checks SPF/DMARC on relayed inbound
mail. The chain trusts a domain’s active authority completely for
relayed mail; verifying the real sender is the operator’s job — see
Trust assumptions and threat model.
The reference server implements full DMARC (RFC 9989, which obsoletes
RFC 7489) evaluation and disposition — alignment folded to the
organizational domain by the §4.10 DNS tree walk,
p=rejectbounces,p=quarantine→ the recipient’sJunkfolder, and the subdomain policiessp=/np=— selectable viasender_auth = "dmarc". Enforcement is all-or-nothing: 9989 retired thepct=sampling tag, so a publishedpct=is inert and no fraction of failing mail escapes the policy. It also emits RFC 9990 §3.1 aggregate (rua) reports (gzip XML in thedmarc-2.0namespace, one per policy domain per interval, with the §4 external-destination check enforced on everyruatarget) when aggregate reporting is enabled. It also emits RFC 9991 §2 failure/forensic (ruf) reports when forensic reporting is enabled — one RFC 5965 ARFmessage/feedback-reportper DMARC failure, attributing the failing connection withSource-IPand, whenever the peer’s TCP source port is known, the RFC 6692Source-Portfield (an unknown port omits the field; a fakeSource-Port: 0is never emitted), sent direct and best-effort to the domain’srufaddresses, headers-only by default (text/rfc822-headers) with the full message opt-in, and 9991’s own §5 external-destination gate applied to everyruftarget (a target that fails the live-DNS authorization check declines the report). That publish-then-gate path is also what satisfies RFC 6650’s applicability statement: reports go only where the receiving party has asked for them. The receiving side — ingesting other operators’ aggregate reports about your own domains — is an optional operator feature, not a conformance requirement; see aggregate-report ingestion below.
None of this requires joining a peer-to-peer network. It requires chain-ABI conformance, the sealed-box crypto, and some addressable place to put ciphertext.
Decentralization is optional, not required
The cid field’s name suggests IPFS, and the reference implementation does
use real content identifiers — but two things are worth being precise about:
- The trustless read path doesn’t re-verify the hash either. The
client-side reader (
webclients/shared/trustless.js) fetches{gateway}/ipfs/{cid}and unseals whatever comes back; it does not recompute the CID’s multihash and compare it to the fetched bytes. The tamper-evidence in practice comes from the sealed-box authenticated encryption, not from CID verification: swap or corrupt the bytes behind a CID and the recipient’s decrypt fails loudly, whether or not anything ever checked the hash. A CID-shaped identifier resolved through any addressable store — not necessarily a distributed IPFS swarm — gives the recipient the same cryptographic guarantee. - The reference implementation itself defaults to no swarm.
sithbitd’s embedded IPFS node (ipfs_daemon/ipfs_swarm) ships withswarm = None— no libp2p, no Kademlia DHT, no bitswap — unless an operator explicitly configures[swarm]with public listen addresses andprovide = true(see sithbit-ipfsd). Out of the box,sithbitdalready runs in exactly the mode this page is describing: chain economics fully live, bodies content-addressed and servable over a private HTTP gateway, with zero participation in the public IPFS network.
So a server that stores sealed bodies in a conventional store (a database, a
filesystem, S3) behind its own GET /ipfs/{cid}-shaped endpoint, without
ever joining the public swarm, is not a lesser or non-conformant
implementation — it’s the same posture the reference implementation
defaults to. Real distribution (pinning to the public network, or running a
shared-bucket cluster) is an
enhancement you opt into, layered on top of a protocol that doesn’t require
it.
What you give up by skipping real distribution, honestly stated:
- Availability beyond your own infrastructure. If your server or its storage goes down, there is no other peer or pinning service holding a copy — unlike content actually pinned onto the public network, or handed to a pinning service (see IPFS storage: benefits to users).
- No public discoverability. A stranger running a generic IPFS client
against
<cid>won’t find your privately-stored bytes — only your own gateway resolves them. - Integrity is unaffected. Recipients still get the same tamper-evidence either way, since it comes from the encryption, not the network.
RFC 8314: cleartext is obsolete — TLS before credentials
RFC 8314 (“Cleartext Considered
Obsolete: Use of Transport Layer Security for Email Submission and Access”)
requires mail submission and mail access to run over TLS, and requires a
server to refuse authentication on an unprotected connection rather than
inviting credentials into the clear. The reference servers enforce this by
default in production posture — require_tls is on out of the box for the SMTP
submission edge, IMAP, and POP — declining credentials until the connection is
protected, each in its protocol-appropriate shape:
- SMTP submission refuses
AUTH(andMAIL FROM) before STARTTLS with a530 5.7.0 Must issue a STARTTLS command first, and hides theAUTHcapability from the EHLO response so clients aren’t invited to authenticate in the clear. The gate is therequire_tls && !tls_activecheck insmtp_session/src/session/ready.rs(theauthandmailhandlers), and the server default isrequire_tls.unwrap_or(mode == Submission)insmtp_server/src/config.rs— on for the submission edge. - IMAP advertises
LOGINDISABLEDin the pre-TLS CAPABILITY and refusesLOGIN/AUTHENTICATEwithNO [PRIVACYREQUIRED](RFC 5530) until STARTTLS completes. The gate iscredentials_refusalinimap_session/src/session/not_authenticated.rs;require_tlsdefaults totrueinimap_server/src/config.rs. - POP3 refuses
USER/PASSbeforeSTLSwith-ERR Must issue STLS command first, and discards any pre-TLSUSERafter the upgrade (a STARTTLS injection defense). The gate istls_gateinpop3_proto/src/session/authorization.rs;require_tlsdefaults totrueinpop_server/src/config.rs.
On the client side of submission, the spooler’s [spooler.smarthost]
relay path can dial with implicit TLS (implicit_tls = true — the
transport §3.3 prefers for submission) instead of STARTTLS — see the
[spooler] reference.
These are runtime policy gates, not compile-time ones: the legacy server compiled its TLS gate out of Debug builds, and the reference servers deliberately do not. The zero-config developer stack (loopback plaintext binds) is a separate, explicitly opt-in convenience, outside this production conformance claim.
The version floor underneath these gates. Per
RFC 8996 (TLS 1.0/1.1 deprecated) and
RFC 8997 (which updates RFC 8314 with
a TLS ≥ 1.2 floor for email), no TLS surface in the stack can negotiate below
TLS 1.2: the shared acceptors in server_common, the outbound relay connector,
and the spooler’s two HTTPS report fetchers (MTA-STS policy fetch, TLS-RPT
submission) all ride rustls, whose supported set is TLS 1.3 and 1.2 only. The
floor is asserted rather than inherited — test fences hold it at both the
acceptors and the outbound connector, and the report fetchers pin the rustls
backend in code — so a custom server matching this conformance claim should
refuse the deprecated versions too.
RFC 7162: CONDSTORE — quick flag-change resynchronization
RFC 7162 (“IMAP Extensions: Quick
Flag Changes Resynchronization (CONDSTORE) and Quick Mailbox
Resynchronization (QRESYNC)”) lets a returning IMAP client fetch only what
changed since it last looked, instead of re-reading every flag. This is a
mail-access feature of the reference server, not a requirement of the
economic protocol — a custom server without it is fully conformant — but a
custom server that does advertise CONDSTORE should match this posture:
- The CONDSTORE half is implemented in full. The capability is
advertised once the connection may carry credentials at all — live
TLS, or an instance configured with
require_tls = false— which on a TLS connection is before login rather than after it, the same gate the other post-credential extensions ride. Every §3.1 surface is there:ENABLE CONDSTORE,SELECT/EXAMINE (CONDSTORE), the unconditionalHIGHESTMODSEQ/NOMODSEQresponse code on select,STATUS (HIGHESTMODSEQ), theSEARCH MODSEQkey (with the untagged(MODSEQ n)search tail, and §3.1.5’s entry-name/entry-type prefix accepted and ignored), theMODSEQfetch item andCHANGEDSINCEfetch modifier (which addsMODSEQimplicitly), andSTORE UNCHANGEDSINCEansweringOK [MODIFIED …]with the messages it left untouched — sequence numbers forSTORE, UIDs forUID STORE. Flag echoes suppressed by.SILENTstill carry their MODSEQ-only updates. - Mod-sequences are real, per message, on every store backend. Each
mailbox carries a monotonic highest-mod-sequence counter and each message
its own mod-sequence, bumped once per flag-changing operation, across all
six
mail_storebackends. A production mailbox is always tracked (the counter is born at 1), soNOMODSEQnever appears in production; a mailbox genuinely without tracking answersNOMODSEQand refuses CONDSTORE parameters with a taggedBAD, the §3.1.2.2 MUST. - The §3.1.4.1 follow-up FETCH after an implicit
\Seenis sent. When a non-PEEK body fetch implicitly sets\Seenand the flag persist succeeds, the session emits one untaggedFETCHper changed message before the taggedOK— the same code path as theSTOREflag echo, so the two shapes can never drift:FLAGSalways (FETCH has no.SILENTform),MODSEQwhen the session is CONDSTORE-enabled (folded in after the persist, so it reports the new mod-sequence), andUIDforUID FETCH. A client fetching the body of an unseen message therefore sees its flags twice — once inline in the FETCH data, with\Seenforce-added before the persist, and again in the post-persist echo — which is exactly what the RFC mandates. On a failed persist there is no echo (the FETCH data itself already went out) and the taggedOKstill follows. The echo’sMODSEQrides the same codec as every other FETCH response, so the wire deviation below applies to it too. - The QRESYNC half is not implemented. It is queued as its own
follow-up wave (expunged-UID tombstones and SELECT-time resync are the
bulk of it); until then
SELECT … (QRESYNC …)parameters andVANISHEDare refused with a taggedBADrather than half-honored. - One known wire deviation, stated honestly — fixed upstream, not yet
released. The adopted
imap-codec(pinned at 2.0.0-alpha.9) serializes the FETCH data item asMODSEQ 4where RFC 7162’sfetch-mod-respgrammar requiresMODSEQ (4)— the typed value the session emits is correct; the codec owns the missing parentheses. Its own parser, by contrast, demands them, so the crate cannot read back what it writes. Confirmed on the wire; interop risk with strict clients is low but nonzero.HIGHESTMODSEQ,SEARCH’s(MODSEQ n)tail, and[MODIFIED …]are unaffected. - Fix history: filed 2026-08-06, fixed and merged upstream 2026-08-18,
awaiting a crates.io release. Probed 2026-08-05: the pins were
already at the then-current tips (
imap-codec2.0.0-alpha.9,imap-types2.0.0-alpha.7,imap-next0.3.4), the encoder’sMODSEQ {value}arm was byte-identical on upstreammain, and no existing issue or pull request named the missing parentheses — so one was filed: imap-codec#722. imap-codec#723 fixed it (MODSEQ {value}→MODSEQ ({value}), plus two regression tests gated onext_condstore_qresync) and merged intomainon 2026-08-18 — but as of that date crates.io’s newest publishedimap-codecversion is still 2.0.0-alpha.9 (2026-07-19), so there is no release carrying the fix to pin yet. The QRESYNC wave stays queued behind the release rather than the merge: a local patch or fork of the codec would put this project’s servers on a private wire encoder for one FETCH item, which is a worse conformance position than one documented deviation, and QRESYNC would multiply that item, since its resync responses are MODSEQ-carrying FETCHes. Unblock signal: a publishedimap-codecrelease at or after the #723 merge — check crates.io’s version list first thing before re-probing the repository.
RFC 7889: APPENDLIMIT — advertising the APPEND ceiling
RFC 7889 (“The IMAP APPENDLIMIT
Extension”) lets a server publish the largest message APPEND will take, so
a client can size an upload instead of learning the limit by having a
finished upload refused. Like CONDSTORE above this is a mail-access
convenience of the reference server, not a requirement of the economic
protocol — a custom server that advertises nothing is fully conformant — but
one that does advertise APPENDLIMIT should match this posture:
- Form (a) only: one ceiling for the whole server. §2 defines two
shapes and the reference server implements the first, where the
capability carries the value in its own name (
APPENDLIMIT=<n>) — the form the RFC reserves for a limit that is the same in every mailbox. The other shape, a bareAPPENDLIMITatom announcing that limits vary and must be read one mailbox at a time, is not implemented, and neither is theSTATUS (APPENDLIMIT)item that shape depends on. - The advertised number is the enforced one, structurally.
<n>isimap.max_message_size(25 MiB by default) — the very setting the driver hands theimap-nextflow layer as its literal ceiling. One setting feeds both, so an advertised limit that differed from the enforced one is unrepresentable rather than merely unlikely. The boundary matches the RFC’s wording too: a literal is refused only when it is larger than the ceiling, so<n>is the largest size that will be accepted, not the first one refused. - Advertised before authentication as well as after, which §2 permits
explicitly and which is most of the point: a client reads the ceiling out
of the greeting’s
[CAPABILITY …]code and can size its very firstAPPEND. - The §4
[TOOBIG]response code is met in prose, not as a code — gap one, on the synchronizing literal. §4 asks that an oversizeAPPENDbe refused with a tagged response carrying the[TOOBIG]code that RFC 4469 defines. An oversize synchronizing literal is refused here with exactly one line, a taggedBADwhose text readsTOOBIG: message exceeds the APPENDLIMIT of <n> bytes— the code’s name, and the very number the greeting advertised, sent in place of the continuation request and so before any of the payload is buffered. What it is not is a resp-text-code: the name rides unbracketed, as the first word of free text, so a client matching on[TOOBIG]still reads a genericBADand only a human reads the reason. That is a property of the pinnedimap-nextrather than a shortcut taken here. It pre-builds the rejection itself with the response code hardcoded toNone, and the one seam it exposes is the reject text, which it validates as continuation-request text — whose constructor refuses a leading[outright (imap-types’ “Ambiguity #1”, guarding an ambiguity that cannot arise for aBAD). A trueNO [TOOBIG]is therefore unbuildable on the pinned version without forking the dependency, which would put this project’s servers on a private flow layer for one response code — the same trade the CONDSTORE deviation above declines, and declined the same way. - Gap two, the wider one: a non-synchronizing (
LITERAL-) literal is never told anything. There is no continuation request to refuse in, so the pinnedimap-nextpoisons the message, keeps reading the payload it cannot stop the client sending, and surfaces the result afterwards as a generic malformed-message error — indistinguishable from a syntax error, so the server answers with its ordinary untagged* BAD could not parse command. NoTOOBIG, no ceiling, and theAPPEND’s own tag is never completed at all, which a client waiting on its tagged reply experiences as a hang. Upstream does this deliberately: discarding the literal early would risk reading the payload as commands. The connection itself survives — the next command is answered normally — and the whole observed shape is pinned by a driver test written as a tripwire for the day animap-nextbump improves it, asserting what a client sees today rather than what the RFC wants. Both gaps are queued behind that bump. Advertising the limit is what makes them visible, and also what makes them rarely reached: a client that readsAPPENDLIMITnever sends the upload that would hit it.
RFC 9208: QUOTA — reading the wallet’s storage cap
RFC 9208 (“IMAP QUOTA Extension”,
obsoleting RFC 2087) lets a client read how full its account is instead of
learning by having an upload refused. Like APPENDLIMIT above this is a
mail-access convenience of the reference server, not a requirement of the
economic protocol — a custom server that advertises nothing is fully
conformant — but one that does advertise QUOTA should match this
posture:
- GETQUOTA and GETQUOTAROOT are served; SETQUOTA is parsed but always
refused. The storage ceiling is operator configuration —
max_wallet_bytes— never client-settable, soSETQUOTAanswers a taggedNO(quota limits are set by the operator). The capability advertisement matches:QUOTAplus the mandatory per-resourceQUOTA=RES-STORAGE, and neverQUOTASET, which would claim the refusal away. The pair rides the same gate asENABLE/IDLE— absent before the connection is protected, present whereverAUTH=is — and the three commands themselves are authenticated-state, per §4.1. - One quota root, existing exactly while a cap applies. With a cap
set, every mailbox belongs to the conventional default root
""— the quota is per wallet, not per mailbox, so one root carries the account’s singleSTORAGEpair, andGETQUOTAon any other root name is refused without a storage read. With the cap unset (0, the default) no root exists at all:GETQUOTAROOTanswers an empty roots list with noQUOTAline following, andGETQUOTA ""answersNO no such quota root. An unbounded account has no quota, rather than an invented huge one. - GETQUOTAROOT answers for any mailbox name, by design. Because the quota is per-wallet, the answer is the same whatever the name, so only the name’s shape is checked — the mailbox need not exist, which the RFC permits (asking about a name before creating it is legal).
- STORAGE usage is the enforced stored-bytes meter, not per-copy
message-size arithmetic — the deliberate deviation. Usage is read from
the very meter the backend refuses writes against: the wallet’s
aggregate stored bytes, in which a blob is counted once however many
of the wallet’s mailboxes reference it. A client’s own reconciliation
arithmetic — summing each copy’s
RFC822.SIZEacross its folder listings, the model RFC 9208’sSTORAGEestimate describes — counts a message copied into two folders twice, so such a client will see less usage inQUOTAresponses than it computes whenever copies exist. That is the honest number: it is the quantity the quota actually enforces, so the* QUOTAline, the[OVERQUOTA]refusal, and the operator’s configured ceiling can never disagree — advertising the per-copy sum instead would overstate fullness, showing an account as full while the enforced meter still admits writes. - Units round conservatively in both directions.
STORAGEcounts 1024-octet blocks; the byte meter converts by rounding usage up, so a partially used block is never reported as free, and the limit down, so the advertised allowance never exceeds what the enforced byte ceiling admits. The edge case is deliberate: a cap under 1024 bytes advertises a limit of0blocks — wire-legal (the RFC’s quota grammar admits zero) and honest, since such a ceiling admits no full block.
RFC 2177: IDLE — the push watch, and its own deadline
RFC 2177 (“IMAP4 IDLE command”)
lets a client park a connection inside IDLE and be told when its
mailbox changes, instead of polling NOOP on a timer. Like CONDSTORE and
APPENDLIMIT above this is a mail-access feature of the reference server
rather than a requirement of the economic protocol — a custom server
without it is fully conformant — but one that does advertise IDLE
should match this posture, and should think hardest about the deadline
the command runs under:
- IDLE is implemented, and advertised with the other post-credential
extensions.
IDLErides alongsideENABLE,MOVE,UNSELECT,NAMESPACE,UIDPLUSandCONDSTOREin the capability set offered once the connection may carry credentials at all (live TLS, or an instance configured withrequire_tls = false) — unlikeAPPENDLIMIT, which is advertised before that too. The command is accepted in the authenticated and selected states, answered with a+ idlingcontinuation, and ended byDONEwith a taggedOK IDLE terminated. With a mailbox selected the session pushes an untaggedEXISTSon every change it learns of, from two sources at once: the in-process watch signal (this process’s own deliveries,APPENDs andCOPYs) and the store’s cross-process change wait, which is how a delivery handled by a sibling process reaches this idler. AnIDLEwith no mailbox selected is accepted as well — legal, and simply with nothing to push. - An accepted IDLE runs under a deadline of its own, not the
listener’s. A client inside
IDLEis deliberately silent for far longer than a transport read deadline tolerates, so while the exchange is live the connection’s read deadline isidle_command_timeout_secsrather than theidle_timeout_secsthe listener applies to every other read, from its shared limits. At shipped defaults the two coincide at 1800 s (30 minutes), so the substitution raises nothing: the shared deadline already clears RFC 3501 §5.4’s requirement that an inactivity autologout timer be at least 30 minutes, and it is that base default — not this seam — that buys §5.4 conformance out of the box. The seam is real anyway, because the two settings move independently: an operator who lowers the shared deadline to keep a tighter grip on every other command does not thereby hang up on a conformant idler, which is exactly what the driver test that drops the shared deadline to five seconds pins. The saved value is restored the moment IDLE ends by any path (DONE, or the IDLE deadline itself firing), through the one funnel every exit takes, so the IDLE deadline cannot outlive the command. The setting sits at the top level of the IMAP configuration, besidemax_login_attempts, rather than in the shared limits table, because it bounds one command and not the listener. A server that runsIDLEunder one shared read deadline instead is safe only for as long as that single deadline stays generous — the day it is tuned down for ordinary commands it acquires the bug this seam exists to prevent: the protocol asks the client for silence and the transport hangs up on it for complying. - 1800 s is chosen against the RFC’s advice to clients, and clears
it by a minute. RFC 2177 requires nothing of the server here. It
permits a server to consider an idling client inactive and to log it
off at the end of its inactivity timeout, and on exactly that basis it
advises clients to terminate and re-issue
IDLEat least every 29 minutes. The default is that interval plus a minute of slack, so a client following the advice re-issues before this server’s deadline and never reaches it. Stated precisely, because the two are easy to swap: 29 minutes is guidance to clients, not a floor the RFC imposes on servers — an operator lowering this setting under it is choosing to cut conformant idlers off, and one raising it is choosing to hold connection slots open longer. - When the deadline does fire, the client is told. A client that
idles past the allowance without
DONEor a re-issue gets an untagged* BYE IDLE timed out, flushed, and then a clean close — where the non-IDLE read deadline’s expiry is, and deliberately remains, a silent EOF. Both shapes are pinned to the tick by driver tests on a paused clock: survival well past the shared deadline (with anEXISTSpush and a completedDONEproving liveness, not merely absence of EOF), silence one tick short of the IDLE allowance, and BYE-then-close on the tick that completes it. A custom server can reasonably choose a different allowance; sending something before closing an idler is the part worth copying.
RFC 8461: MTA-STS — downgrade-resistant outbound TLS
RFC 8461 (“SMTP MTA Strict Transport
Security (MTA-STS)”) lets a receiving domain declare that its MX hosts support
TLS with a valid certificate, so a sending MTA can refuse to deliver over an
unauthenticated or plaintext channel — closing the STARTTLS-stripping downgrade
that opportunistic TLS (RFC 7435) leaves open. The relay implements the sending
side, on by default (the mta_sts switch in the
[spooler] reference);
domain-sithbit implements the publishing side (the last bullet):
- Policy discovery and parsing live in
mail_spooler/src/mta_sts.rs: the_mta-sts.<domain>TXT probe (§3.1, with a transient/definitive split so a resolver hiccup is never mistaken for policy withdrawal), the HTTPS fetch ofhttps://mta-sts.<domain>/.well-known/mta-sts.txt(§3.3 — 10-second timeout, redirects refused, 64 KiB body cap), the §3.2 policy parser, and the §4.1 MX-pattern matcher (exact match or a*.wildcard covering exactly one leftmost label). - Enforce mode is the relay’s
attempt_enforcedbranch inmail_spooler/src/pipeline/relay.rs: only MX targets matching the policy’smxpatterns are dialed, each demanding STARTTLS with a certificate verified against the webpki roots for the MX hostname (TlsVerify::Strictinmail_spooler/src/smtp_out.rs— the same strict verifier the smarthost path uses). Any TLS failure — STARTTLS missing or refused, a handshake or certificate error — and zero matching MX targets defer the mail on the normal retry schedule (§5’s transient handling): enforce never bounces on a TLS failure and never falls back to plaintext. - The policy cache honors §5.1: policies are cached per domain for their
max_age(clamped to one year) and keyed to the TXT record’sidfor rollover, and an unexpired cached policy keeps being applied through a DNS strip or transient resolver failure — which is what defeats record-removal attacks against senders that have already seen the policy. - Testing mode delivers opportunistically and logs (
tracing::warn!) each MX target that would fail under enforce. With[spooler.tlsrpt]enabled, each dialed attempt also records an RFC 8460 result row under its STS policy context — see TLS-RPT below — which is the feedback loop testing mode is designed to be watched through. - The publish side lives in
domain-sithbit(domain_sithbit/src/mta_sts.rs), servingGET /.well-known/mta-sts.txtfor the domains the instance fronts. The §3.2 serializer renders the policy body once at startup from the optional[mta_sts]config section —version/mode/mx/max_age, every line CRLF-terminated — and validation is fail-fast at boot: an unknownmodename, anenforce/testingpolicy with nomxpattern (§3.2 requires one), or amax_ageabove the one-year ceiling refuses to start rather than serving a policy senders would reject or silently shorten (the ceiling is fenced equal to the consume side’s clamp by a round-trip test through the spooler’s parser). With no[mta_sts]section the route answers 404 — publication is opt-in per instance, and there is no per-domain policy map: one instance serves one policy. HTTPS is the fronting proxy’s job (senders fetchhttps://mta-sts.<domain>/.well-known/mta-sts.txt, so the proxy needs a certificate for that hostname), no TLS-RPT (RFC 8460) reports are consumed on this side either, and the_mta-sts.<domain>discovery TXT record — with itsidbump on every policy change — stays operator-managed DNS: see DNS setup.
RFC 7672: DANE — DNSSEC-pinned outbound TLS
RFC 7672 (“SMTP Security via
Opportunistic DANE TLS”) lets a receiving domain pin its MX hosts’ TLS
certificates in DNSSEC-signed TLSA records, closing the same
STARTTLS-stripping downgrade as MTA-STS — but with DNSSEC as the trust base,
so it has neither the trust-on-first-use nor the cache-lifetime residual. The
relay implements the sending side, on by default (the dane switch in the
[spooler] reference),
preferring DANE over MTA-STS wherever both apply:
- TLSA discovery and classification live in
mail_spooler/src/dane.rs: the_25._tcp.<mx-host>lookup rides a DNSSEC-validating resolver, and every answer record’s validation proof is checked — one bogus or unsigned hop taints the whole chain (§2.2.2). The §3.1.3 usability rules apply: DANE-EE(3) and DANE-TA(2) with known selectors/matching types are usable; PKIX-TA(0)/PKIX-EE(1) and unknown registry values are not. The per-host verdict is one of: verify (usable records — pin the handshake), mandatory unauthenticated TLS (a validated RRset that is all-unusable, §2.2/§3.1.3), not applicable (no TLSA, unsigned zone, or a validated denial — the host stays on the MTA-STS/opportunistic path), or unusable (bogus validation or a failed lookup — the host is never dialed). - The DANE verifier (
TlsVerify::Daneinmail_spooler/src/smtp_out.rs) matches the presented chain against the records: DANE-EE matches the end-entity certificate alone, with name, expiry, and chain checks all skipped (§3.1.1 — the DNSSEC-signed record is the trust statement); DANE-TA requires some presented chain certificate to match a TLSA record AND the end entity to path-validate (rustls-webpki) with that certificate as the trust anchor, expiry and the §3.2.3 server-name check enforced. Full-certificate and SPKI selectors, exact/SHA-256/SHA-512 matching. - Relay composition is the per-host planner in
mail_spooler/src/pipeline/relay.rs(plan_hosts): DANE applies to an MX host only when the MX RRset itself validated (§2.2.1) AND that host’s TLSA chain validated with usable records — and then it outranks an MTA-STS policy, including itsmxpattern filter (RFC 8461 §2). An all-unusable TLSA set demands TLS without authentication, except under an MTA-STS enforce policy, which stays the stricter floor. Any DANE failure — a handshake that matches no record, a bogus TLSA chain, every host excluded — defers the mail on the normal retry schedule, never a plaintext fallback and never a bounce. - MX-less domains (§2.2.1). A domain with no MX record — the implicit-A
fallback, where the connect host is the domain itself — is not excluded
from DANE: when the denial of MX existence is DNSSEC-proven (a Secure
validation proof on the negative answer’s SOA), the fallback counts as a
validated answer, and TLSA records published at
_25._tcp.<domain>are consulted and enforced exactly as for an MX host. The honest subset that remains is narrower: a denial that arrives without a validatable SOA — or whose proof is insecure, indeterminate, or bogus — stays insecure and skips DANE, and unsigned MX-less zones behave exactly as before (opportunistic TLS). - Documented subsets. The TLSA base domain is the MX hostname as published: CNAME chains are followed (each hop must validate Secure), but the §2.2.3 alternate base-domain derivation from A/AAAA-expansion is not performed — a subset that only ever loosens toward today’s opportunistic posture, never past a published policy.
- Observability is warn-level logging on unusable TLSA chains and DANE
handshake failures — and, with
[spooler.tlsrpt]enabled, every dialed attempt (and every host DANE excludes before dialing) records an RFC 8460 result row under its TLSA policy context; see TLS-RPT below. - Scope: sending side only. SithBit does not generate TLSA records for its own domains; an operator who wants inbound protection publishes them in their DNSSEC-signed zone — see DNS setup.
RFC 8460: TLS-RPT — SMTP TLS reporting
RFC 8460 (“SMTP TLS Reporting”)
is the feedback loop for the two mechanisms above: a sending MTA records how
its outbound TLS sessions actually went — per recipient domain, per
governing policy — and delivers a daily aggregate report to whatever
addresses that domain names in its _smtp._tls TLSRPT record, so the
domain’s operator sees downgrade attempts and misconfigured MX hosts from
the senders’ vantage point. The reference server implements the sending
side — recording and reporting — behind the single [spooler.tlsrpt]
switch, off by default (the
configuration reference):
- Result recording happens in the relay’s per-host attempt loop
(
mail_spooler/src/pipeline/tlsrpt.rs), direct-to-MX path only: each dialed attempt that produced TLS evidence lands one row carrying the RFC 8460 §4.2 policy context that governed it —tlsa(the verified TLSA records rendered as policy strings),sts(the MTA-STS policy body), orno-policy-found— and either a success tally or a §4.4failure-detailsblock. Hosts a policy excludes before dialing land never-dialed failure rows too: a DANE-unusable host (excluded at the resolver/proof level, no TLSA records assessed) recordsdnssec-invalidwith a baretlsapolicy block, and an MX target outside an enforce-mode MTA-STS policy recordssts-policy-invalidrendering the enforce policy body — the planner’s diagnostic ridesfailure-reason-code. Retries re-record, and identical rows aggregate byfailed-session-countat fold time. Recording is strictly observational: a recorded failure still defers/retries exactly as the enforce sections above describe, and a failed row write warns without ever changing a delivery outcome. Smarthost mode records nothing — a smarthost’s TLS posture is not the recipient domain’s. - The report worker (
mail_spooler/src/pipeline/tlsrpt_report.rs) runs on the same switch: every 24 hours (a fixed period, not a setting) it folds the pending rows into one RFC 8460 report per recipient domain, discovers the domain’srua=targets from its_smtp._tls.<domain>TXT record, and delivers over both channels —mailto:targets ride the normal outbound relay, DKIM-signed on spool entry (so the configuredemailmust be a local, DKIM-signable address; its domain doubles as the report’s submitter identity), andhttps:targets receive the gzip-compressed JSON directly as anapplication/tlsrpt+gzipPOST (10-second timeout, redirects refused — the MTA-STS fetcher’s settings). Unlike DMARC aggregate reporting there is no §7.1-style external-destination authorization gate: RFC 8460 defines none, so a report goes wherever the published record points. - Delivery bookkeeping, stated honestly. Rows are deleted only after a
domain’s report reached every target, so a crash between send and
delete — or a partial multi-target failure, which defers the whole
domain — can re-deliver the window; the deterministic report-id
(
<end-time>_<domain>) lets receivers de-duplicate. A domain that definitively publishes no TLSRPT record (or one naming norua=target) has its rows dropped rather than pinned forever; only transient DNS or delivery failures keep rows pending for the next tick. An unparseable pending row is poison: warned and deleted. - Recording gaps, on record. The rustls seam collapses the RFC 8460
certificate result taxonomy (
certificate-expired,certificate-host-mismatch, …) into the generalvalidation-failurecode, with the raw TLS error detail preserved infailure-reason-code. Success rows are flag-truthful — recorded only when the outcome says the conversation actually ended on TLS — so a completed plaintext opportunistic session (TLS never negotiated, including a declined STARTTLS offer that continued in the clear) records no row at all: neither a §4.1 TLS session nor a failed attempt. The honest limit that remains: the seam cannot distinguish “STARTTLS never offered” from “offered but declined, continued plaintext” — both go unrecorded, withstarttls-not-supportedfailure rows reserved for enforced postures that abort. Unreachable or timed-out hosts still record nothing, and MTA-STStesting-mode mismatches are warn-logged, never recorded. - No receiving side. Ingesting other operators’ TLS reports about your
own domains is not implemented — inbound reports are ordinary delivered
mail (there is no TLS-RPT sibling of the
DMARC
ruaingestion below). To request reports about a domain you operate, publish the TLSRPT record — see DNS setup.
RFCs 7372, 8301, and 8463: SPF and DKIM updates — and the RFC 9989 From-extraction disposition
The SPF and DKIM rows in the standards page carry three update RFCs, and the DMARC row one disposition, whose behavior deserves the same honest spelling-out as the sections around this one. None of this is a requirement of the economic protocol — it is what the reference server does, and the posture a custom server should match if it claims the same rows:
- RFC 7372: the SPF hardfail rejection code. A published SPF hardfail
(
v=spf1 -all) rejects atMAIL FROMwith554carrying enhanced status5.7.23— the “SPF validation failed” code RFC 7372 §3.2 registers for exactly this outcome. Anything short of a publishedFail— softfail, neutral, none, resolver errors — never rejects there; those verdicts land in theAuthentication-Resultsheader instead. The other 7372 codes with an emission site: the DMARC bounce rejects with554carrying5.7.26— the “multiple authentication checks failed” code RFC 7372 §3.3 registers for a DMARC rejection — still naming the offending From domain. The remaining 7372 codes have no emission site by policy design, not omission: SPF temperror/permerror never reject, and DKIM failures never reject standalone in anysender_authmode. - RFC 8301: the DKIM algorithm floor, held on both sides. The signer is
rsa-sha256-only by construction — no configuration can produce an
rsa-sha1signature, satisfying RFC 8301’s signer MUST NOT. On the verify side, the adoptedmail-auth(0.11.1) still verifiesrsa-sha1, so the server post-filters every DKIM verification result: a verifiedrsa-sha1signature is downgraded to failure before it reachesAuthentication-Results, aggregate/forensic reporting, or DMARC alignment input — report and disposition both see it as failed, with the signature evidence kept so reporting still names the signing domain. The filter is fenced in both directions (the downgrade is load-bearing for DMARC; rsa-sha256 flows through untouched), and one fence deliberately asserts thatmail-authstill verifiesrsa-sha1: a future dependency version that stops doing so turns that assertion red, which is the signal that the filter and its fences can retire. - RFC 8463: ed25519-sha256, verified and optionally dual-signed. Inbound
RFC 8463
ed25519-sha256signatures verify topass(fenced with the RFC’s own appendix-A test key). Outbound, each[spooler.dkim]entry can opt into dual-signing via theed25519_selector/ed25519_key_filepair — the message then carries twoDKIM-Signatureheaders, rsa-sha256 and ed25519-sha256, each signing the same headers and body independently, so verifiers honor whichever algorithm they support; with the pair absent (the default) the entry signs rsa-only, unchanged. The ed25519 public key needs its own selector because one_domainkeyDNS name publishes one key record, and that record’sp=is the raw 32-byte public key base64 (v=DKIM1; k=ed25519; p=…) — not a DER SubjectPublicKeyInfo like RSA’s. The key format and config shape are in the configuration reference. - RFC 9989 §5.3.1: DMARC terminates without a verdict on unextractable
From. When RFC5322.From yields zero author domains (an absent From, or
RFC 6854 group syntax such as
undisclosed-recipients:;) or several differing ones, DMARC evaluation terminates without producing a verdict — exactly what §5.3.1 prescribes (“DMARC validation is not possible and the process terminates”) and nothing more: no reject and no quarantine, even when an apparent sending domain publishesp=reject, and the message proceeds still subject to every other gate (the SPF hardfail rejection above, DNSBL, recipient postage). §5.3.1’s MAY — a receiver may still evaluate the multiple-domain case — is deliberately not taken, and §4.4 places handling of malformed, absent, or repeated From fields outside the spec’s scope. Both termination shapes are fenced as deliberate dispositions, not incidental fallthrough.
RFC 9990: ingesting DMARC aggregate reports
The emitting side of DMARC reporting is covered above; the reference
server also implements the receiving side of
RFC 9990 — what happens when
another operator’s receiver mails an aggregate (rua) report to a domain
you operate. The posture is deliberately
ingest, store, and surface — nothing more:
- Reaching the mailbox at all. An external reporter never holds a
prefunded frombox, so the postage gate would refuse it like any other
stranger. The
[smtp] postmaster_walletsetting implements the RFC 5321 §4.5.1 postmaster exemption in the SMTP driver (smtp_server/src/driver.rs):RCPTto barepostmasterorpostmaster@<local-domain>(case-insensitive, per §4.5.1) skips alias resolution and the frombox/postage check entirely and delivers to the configured wallet — a foreign-domain postmaster stays a relay request. Unset (the default), refusals are byte-identical to the unconfigured behavior. - Parse and store (
mail_spooler/src/adapters/dmarc_rua.rs). With[spooler.dmarc_rua_ingest]enabled, delivery to a matched local recipient (a bare local-part entry matches at any local domain, a full address exactly; default["postmaster"]) parses the raw message with mail-auth’s RFC 9990 parser — MIME wrapping and gzip/zip report bodies handled, and both thedmarc-2.0namespace and RFC 7489-era report bodies accepted — and stores the parsed report, serialized verbatim as JSON, at blob keydmarc_rua/<id>.json. Decoding is bounded: because a compressed attachment says nothing about what expanding it costs, the parser stops reading at 25 MiB decompressed — the same ceiling as[smtp] max_message_size, deliberately one size vocabulary rather than two. A report that exceeds it is simply not parsed, which by the best-effort rule below means it still delivers as ordinary mail. The id isorg_name!report_id!begin!end, modelled on the §3.5.2 report filename convention rather than copied from it, sanitized to[A-Za-z0-9._-](every other byte, including the!separators, becomes_; 200-byte cap). Ingestion is strictly additive: the message still delivers to the mailbox normally, a malformed report warns and delivers, redelivery overwrites the same key idempotently, and relay recipients never trigger it. - Surface. The account API’s admin routes
(
GET /v1/admin/dmarc-reportslist,GET /v1/admin/dmarc-reports/{id}fetch) read the stored JSON back — see account-api, and DNS setup for wiring therua=record to your own deployment.
What is deliberately not implemented, so an operator knows what this feature is not:
- No automated disposition. Auto-disabling or suspending accounts from RUA data was considered and rejected as a category error: aggregate-report rows carry no join key back to local wallets, and the rows failing your policy are almost always third-party spoofers, not your users. The reports exist for a human operator to read.
- No
rufingestion. Failure/forensic reports are emitted (above) but not consumed — inbound ARF messages are ordinary mail. - No pruning. Nothing deletes stored reports yet; they accumulate
under the
dmarc_rua/blob prefix until an operator clears them — a documented limitation, like the TLS-RPT gaps above.
Self-authenticating TLS (optional, for DHT discovery)
Everything above is what a server must do to participate in the economic
protocol. A server that additionally wants to be discovered over the DHT
(rather than published in DNS SRV/A records) opts into one more behavior —
self-authenticating TLS, the connection half of decentralized service
discovery:
- The node presents a self-signed certificate whose key is its delegated node
key. The certificate is not chained to a public CA. It carries the node’s
authority-signed
SignedDelegationin a custom X.509 v3 extension under OID1.3.6.1.4.1.58888.1.1(an unregistered placeholder enterprise number — not IANA-registered; an independent implementation must match the constant. Registering a real PEN is a tracked external prerequisite that MUST replace this arc before mainnet; the mainnet deploy preflight enforces it). The delegation binds{domain, proto, node_pubkey, expiry}and chains to the domain’s on-chainMailDomain.authority. - The client verifies against the chain, not a CA or the hostname. A
conforming client (SithBit’s
NodeDelegationVerifier, a rustlsServerCertVerifier) extracts the delegation from the leaf certificate, resolves the domain’s authority from chain, verifies the delegation’s signature against it, and enforces that the certificate’s public key equals the delegatednode_pubkey. Intermediates, the SNI/server name, and OCSP are deliberately ignored — trust flows only from the on-chain authority. This makes a node impersonation-proof even if the DHT record that pointed the client at it was poisoned: a bad address just fails the handshake’s chain check.
This is the inverse of the client-certificate SASL EXTERNAL mechanism (there
the client proves a wallet identity to the server; here the server proves a
delegated domain identity to the client). It is entirely optional: a server
published the classic way in DNS, presenting an ordinary CA-issued certificate,
is fully conformant — self-auth TLS matters only if you want the server found
and trusted through the DHT with no DNS and no public CA.
Should you graft this onto an existing MTA?
Given the above, the SMTP-accept edge — envelope validation, TLS, the
postage check — is the one piece with a plausible plugin surface in a
mature MTA: a Postfix Milter or an Exim ACL could call the mail_api gRPC
service to check and decrement stamps before accepting a message, in the
same shape as existing greylisting or reputation Milters.
Everything downstream of “accepted” does not have a comparable plugin surface:
- Local delivery must seal the body to the recipient’s key and land it in CID-addressed storage instead of a Maildir/mbox — no standard local delivery agent does this.
- IMAP/POP retrieval must decrypt from that store on fetch — Dovecot’s storage backends assume a conventional mailbox format.
- Wallet-signature SASL PLAIN, and the CRAM-MD5/APOP fallback that needs a server-held secret, has no drop-in mechanism in stock Cyrus SASL or Dovecot auth without custom code either way.
- DKIM signing on spool entry, the relay retry schedule, RFC 3464 DSNs, and
on-chain settlement bookkeeping are all
mail_spooler’s job regardless of which SMTP edge accepted the message.
So grafting SithBit support onto Postfix/Exim/Dovecot buys you a mature
MTA’s SMTP-edge tooling (anti-abuse, TLS hardening, operational familiarity)
at the acceptance step, but the storage, crypto, wallet auth, and settlement
layers still have to be written from scratch — which is what
mail_spooler already is. sithbitd is the reference implementation and
the recommended path for standing up a domain; a bespoke server is worth
building primarily if keeping an existing MTA’s edge tooling matters more
to you than the extra integration work, not because it’s meaningfully less
total effort.
Conformance checklist
- Build and submit valid
MailInstruction/AliasInstructiontransactions (mail_model,solana_common). - Check and decrement frombox stamps before accepting mail (
mail_apigRPC). - Seal bodies with
crypto_box_sealto the recipient’s wallet or published delegated key. - Store sealed bodies under a content address reachable at the URL you publish on-chain — real IPFS or a conventional store, your choice.
- (optional) Pin or announce that content on the public IPFS network for availability beyond your own infrastructure.
- Require TLS for submission and mail access, refusing credentials before
the connection is protected (RFC 8314) — SMTP
AUTH, IMAPLOGIN/AUTHENTICATE, POPUSER/PASS. - Accept wallet-signature SASL PLAIN and/or a stored mail password for clients limited to legacy SASL mechanisms.
- Sign outbound mail with DKIM; verify SPF/DMARC on relayed inbound mail.
Decentralized service discovery for mail-access servers
Status: implemented. SithBit clients can discover a domain’s POP/ IMAP servers over SithBit’s own DHT — no DNS
SRVrecords — and each node proves it is authorized to serve the domain cryptographically, chaining to the on-chain domain identity. This page describes the shipped system: the node-delegation certificates, the signed DHT service records, the self-authenticating TLS handshake, thesithbit discoverresolver, and the failover seam. It composes on top of the IPFS swarm and clustering work.Read the honest limitations before you lean on it: discovery and authentication are the easy parts; cross-node maildrop/IMAP-state consistency is the hard part and presupposes a shared cloud store.
The idea
Classically a mail client finds a domain’s POP/IMAP servers through the DNS
SRV/A records the operator publishes. SithBit already avoids DNS for
addressing (a wallet address is the mailbox; DNS is used only for domain
verification TXT records and MX). This closes that loop for the
mail-access protocols: clients discover a domain’s POP/IMAP nodes over
SithBit’s own DHT, and each node proves its authority to serve the domain
cryptographically rather than by DNS assertion.
Concretely:
- A node advertises itself under a DHT key derived from the on-chain domain
identity (
hash("sithbit/service-record/v1" ‖ domain ‖ proto)). - A node vouches for its authority by proving control of a key the domain
authority signed for it — chaining to the
MailDomainaccount’s recorded authority on-chain. - Clients discover a domain’s nodes via a DHT lookup + on-chain
verification, with no public DNS
SRVquery. - Operators scale by running more nodes; each advertises itself. A domain that runs a single SQLite node is the degenerate one-node case — the owner’s prerogative and their burden for their own correctness.
The guiding split is authority on-chain, endpoints and liveness off-chain: who may serve a domain changes rarely and belongs on the chain; which boxes are up right now changes constantly and belongs on the DHT.
What it is built from
Most of this is assembly of primitives SithBit already owned:
| Primitive | In the repo |
|---|---|
| Root of trust for “who owns this domain” | MailDomain PDA records the domain authority pubkey; SendMail gates signer == recipient domain authority. |
| A DHT to advertise on | ipfs_swarm runs libp2p Kademlia with put_record/get_record and a validation hook. |
| Fleet membership for one operator | The IPFS clustering wave: shared-bucket membership roster + rendezvous-hash keyspace partition. |
| Shared state across nodes | The cloud stores (AWS/Azure/Turso/Cloudflare) — many processes over one store. |
The genuinely new work — the node_cert crate (node-delegation
certificates), the signed service-record type in ipfs_swarm, the
self-authenticating TLS verifier in mail_client, the sithbit discover
resolver, and the failover seam in mail_store/imap_server — is what
the rest of this page describes.
Delegation — the key design decision
The domain’s on-chain authority private key never goes on a POP/IMAP box:
that key can authorize/deactivate domains and sign SendMail. Instead the
authority issues node-delegation certificates (the node_cert crate): a
short-lived, borsh-serialized NodeDelegation { domain, proto, node_pubkey, expiry }, Ed25519-signed by the authority into a SignedDelegation { delegation, sig }. A node holds only its delegated key; both the DHT record and
the TLS certificate are signed by that node key, and clients verify the
delegation chains to the on-chain MailDomain.authority.
node_cert is pure crypto — borsh plus Ed25519 sign/verify, no chain RPC and no
X.509 in the core type (the X.509 wrapping is a separate module). Proto is a
borsh enum (Pop, Imap) whose variant order is wire ABI: only ever append.
This mirrors, philosophically, SithBit’s existing delegated X25519
encryption-key pattern (mailbox key set) — a wallet publishing a delegated key
so signing-only wallets and MX servers never touch the root key. It is not
the same mechanism: node-delegation certs are self-delivered (carried in the
DHT record and the TLS handshake), not registered on-chain. A stolen node key is
bounded in time and scope; revocation is “let the delegation expire / rotate
it.”
Proving authority in two places (belt and suspenders)
The one node key the authority delegated signs both the discovery record and
the TLS certificate, and each proof independently chains back to the on-chain
MailDomain.authority:
1. In the discovery record
A node publishes a signed ServiceRecord { domain, proto, multiaddrs, ttl_secs, created_at, signed_delegation, node_sig } under the DHT key
hash("sithbit/service-record/v1" ‖ domain ‖ proto) — a keyspace separate
from the CID provider records — via Kademlia put_record. On get, a
validation hook runs ServiceRecord::validate, which is entirely
self-contained (no chain RPC on the DHT path):
- the node signature verifies against the key the delegation binds
(
node_pubkey) — a forged or tampered body is rejected; - the record’s
(domain, proto)matches the delegation’s (a delegation for one service can’t be replayed under another); - the delegation has not expired (
now < delegation.expiry); - the advertise TTL is fresh (
now < created_at + ttl_secs).
This stops anyone from planting a record for a domain they do not control, and ages out records from nodes that stopped heartbeating — without a chain lookup on the hot DHT path. The on-chain-authority check is the client’s job, below.
2. In the connection (self-authenticating TLS)
A POP/IMAP node presents a self-signed TLS certificate carrying its
SignedDelegation in a custom X.509 v3 extension under OID
1.3.6.1.4.1.58888.1.1, and the certificate’s public key is the
delegated node key. A client’s NodeDelegationVerifier (a rustls
ServerCertVerifier) extracts the delegation, resolves the domain’s
MailDomain.authority from chain, verifies the delegation against it, and
enforces that the certificate key equals the delegated node_pubkey. Trust
flows only from the chain: intermediates, the SNI/server name, and OCSP are
deliberately ignored. This is impersonation-proof even if the DHT is
poisoned — a poisoned record just points a client at a box whose TLS
handshake then fails the chain check.
The OID’s
58888arc is an unregistered placeholder private enterprise number — SithBit has not registered a PEN with IANA; the arc was picked arbitrarily. Anyone building an independent verifier must match this constant exactly. Registering a real PEN is a tracked external prerequisite (an IANA application, not in-code work) that MUST be completed and the OID updated before mainnet; until then this arc is unregistered and collision-prone. The promise is enforced: the mainnet deploy preflight (scripts/preflight-deploy.sh chain-mainnet) fails while the placeholder arc is still innode_cert. Whether to register a PEN or move to an extension form that needs no registration is a recorded open consideration.
Configuring service records ([swarm])
Service advertisement lives in the swarm config — [ipfs.swarm] for
sithbitd, [swarm] for sithbit-ipfsd — alongside the existing
listen/bootstrap/provide/identity settings. Two settings govern freshness;
both have in-code defaults, so an empty [swarm] is still valid (a plain
node that never advertises a service ignores them entirely):
| Key | Default | Meaning |
|---|---|---|
service_record_ttl_secs | 900 (15 min) | How long an advertised record stays fresh from created_at. Deliberately minutes-scale so a node that stops heartbeating ages out of discovery quickly — liveness wants minutes, not the ~22 h content-reprovide cadence. |
service_heartbeat_interval_secs | 300 (5 min) | How often the node re-stamps created_at and re-publishes its records. Must stay comfortably below the TTL so a record never lapses between heartbeats. |
See the configuration reference for the shared subsection.
Discovering nodes (sithbit discover)
The client-side resolver is sithbit discover pop|imap <domain>:
sithbit discover imap sithbit.com \
--bootstrap /ip4/203.0.113.7/tcp/4001/p2p/12D3Koo... \
--timeout-secs 20
It spins an ephemeral, discovery-only embedded swarm node (it never pins —
the repo store is inert), seeds the DHT routing table from the --bootstrap
peers (repeatable; without at least one reachable peer the routing table is
empty and nothing is found), looks the (domain, proto) service records up, and
prints only the endpoints whose node-delegation chains to the domain’s
on-chain MailDomain.authority. Verified multiaddrs go to stdout; the count
of records that survived DHT validation but failed the chain check goes to
stderr as a dropped-record note.
The resolver grants no trust: it only speeds discovery. A real client still verifies the TLS certificate against chain itself (the self-auth handshake above), so a resolver that returned a bad address could not fool the connection. Full in-client libp2p (Thunderbird/Outlook/wasm) remains a heavier later optimization; the resolver is the light path.
See sithbit-ipfsd: discovering a domain’s nodes for the operator-side view.
Failover consistency
Discovery is only safe if a client that fails over from node A to node B sees the same mailbox. Two seams make that hold over a shared store:
- A cluster-wide keyed lease
pop/{wallet}prevents two nodes serving one POP maildrop concurrently. The lease self-expires, so a node that dies mid-session frees the maildrop for the failover node; a living session renews before each mutation, so failing over does not let the two nodes expunge each other’s messages — see POP maildrop exclusivity. - A cross-process
MailboxNotifyseam —MailRepo::await_change, with a poll-backed default overchange_seq(theDEFAULT_WATCH_POLLinterval, 2 s) — lets a failed-over IMAP IDLE client on one node learn of mail delivered on another, on every backend including SQLite. Native per-backend push (PostgresLISTEN/NOTIFY, DynamoDB Streams) is a deferred drop-in behind the same seam.
This is only meaningful over a shared cloud store. On SQLite it is genuinely single-node by design — the accepted owner’s-prerogative case.
Honest limitations — the auth is the easy part
These are deliberate and unchanged from the original design note; do not read the “implemented” banner as “all hard problems solved”:
- Only meaningful over the shared cloud store. POP/IMAP are stateful and session-pinned. Cross-node failover requires a cloud store; on SQLite it is single-node. The SQLite→cloud migration tool is the on-ramp.
- Maildrop-lease and IMAP-state consistency is the deep part. The keyed
lease and the poll-backed
await_changeseam are shipped, but native cross-process IMAP-IDLE push per backend is still deferred — thechange_seqpoller is today’s cross-process path. This, not discovery/auth, is the real engineering. - Enumeration / DoS surface. A public DHT advertising POP/IMAP endpoints is
trivially scannable (DNS
SRVis at least obscure).server_common’s connection limits and DNSBL/DBL apply; high-value domains may want gated or encrypted-to-known-clients discovery records. - Placeholder OID/PEN. The
1.3.6.1.4.1.58888.1.1extension arc is an unregistered placeholder, not an IANA-registered enterprise number. Registering a real PEN is a tracked external prerequisite that MUST land before mainnet — and the mainnet deploy preflight refuses to proceed until it has. - Resolver, not client-native discovery.
sithbit discoveris a CLI resolver; discovery is not yet wired into the Thunderbird/Outlook/wasm clients themselves, nor into alias-based login. Those remain candidate follow-ups.
Verdict
A strong fit, and largely an assembly of pieces SithBit already owns —
on-chain domain authority, libp2p Kademlia, the clustering roster, Ed25519
self-auth certs, and the cloud shared store — now shipped. It is
philosophically on-brand: a decentralized mail protocol that already avoids DNS
for addressing now discovers its own access servers without DNS SRV. Go in
eyes-open that discovery is the easy part; cross-node maildrop/IMAP-state
consistency over the shared store is the hard part, and that the whole scheme
presupposes the cloud-store deployment.
Development and pilot servers
The workspace contains three additional server binaries you may notice
alongside the production ones: smtp-server, imap-server, and
pop-server (from the smtp_server, imap_server, and pop_server
crates).
Not for deployment. These are memory-backed dev/pilot binaries used to prove out the underlying sans-io protocol session logic before
sithbitdexisted to host it against real storage. They have no chain pipeline, no real persistent storage, and no postage enforcement — accepted mail is logged, not delivered. For running an actual mail server, see Running a mail server andsithbitd.
The sithbit-console admin TUI
sithbit-console is the terminal management console for a SithBit
deployment: a keyboard-driven UI that lets an operator inspect accounts,
mailboxes, and messages (with their on-chain delivery states), watch the
job queues, and requeue or discard dead-lettered jobs — without ever
touching the store or the chain state that the mail servers own.
Its defining design constraint: the console talks only to the account
API’s /v1/admin routes. Every read and every action round-trips
through the API, never the database directly. That keeps the operator
surface behind the same authentication and admin-wallet allowlist the API
already enforces, works identically against every storage backend (SQLite
or any of the cloud stores), and means the console can run from any
machine that can reach the API — it needs no store credentials at all.
The one deliberate exception is the read-only balances
pane, which reads public chain state through the
mail-grpc gateway and a Solana RPC node.
Prerequisites
- A running account API (standalone
account-api, or the one inside yoursithbitddeployment) reachable from the console’s machine. - Your wallet on the API’s admin allowlist. The console logs in with
a Solana wallet keypair; the API only serves
/v1/adminroutes to wallets listed in itsadmin_walletssetting. An emptyadmin_walletslist disables the admin surface entirely — every admin call (and therefore every console pane) replies 403. - The wallet’s keypair file on the console machine (a standard
Solana 64-byte JSON keypair; default
~/.config/solana/id.json). - Optionally, for the balances pane: a reachable mail-grpc gateway and a Solana JSON-RPC node. A console used only for the admin panes can ignore both.
Running it
cargo run -p mail-console --bin sithbit-console
With no configuration at all, the console targets the loopback dev stack:
the account API at http://127.0.0.1:8180, the mail-grpc gateway at
http://127.0.0.1:50051, and a local RPC node at http://127.0.0.1:8899,
signing in with ~/.config/solana/id.json.
On startup the console:
- loads its configuration (next section),
- loads the keypair and performs the wallet-challenge login
(
/v1/auth/nonce→ signature →/v1/auth/token→ JWT) — a failure here (API down, malformed keypair) exits with the error before any UI appears, - connects the balance client (the gateway channel dials lazily, so a down gateway does not block startup — only a syntactically bad endpoint URL does), and
- opens the terminal UI on the Accounts tab, loading the account list.
Note that the login succeeding does not yet prove the wallet is an
admin: the allowlist is checked per admin route, so a non-admin wallet
gets a working UI whose every pane reports a 403 error in the status bar.
Fix the API’s admin_wallets and press r.
Configuration
Config file sithbit_console.toml in the working directory (or the path
in SITHBIT_CONSOLE_CONFIG), env prefix SITHBIT_CONSOLE, resolved
through the standard layering (in-code default → TOML → .env →
.env.$APP_ENV → environment). Every entry defaults, so an empty or
absent file runs against the loopback dev stack:
# api_url = "http://127.0.0.1:8180"
# keypair_file = "~/.config/solana/id.json"
# gateway_endpoint = "http://127.0.0.1:50051"
# rpc_url = "http://127.0.0.1:8899"
The four keys are documented in the configuration reference.
The two tabs
The console has two top-level tabs, switched with Tab:
- Accounts — the wallet list, drilling down through mailboxes to messages, plus the balances pane.
- Queues — job-queue depths and the dead-letter table with its requeue/discard actions.
A status bar along the bottom shows the last result or error on the left
and the active tab’s key hints on the right. All data is fetched on
demand; r reloads the pane you are looking at.
Tutorial: inspecting accounts and mail
Accounts
The Accounts tab opens on the wallet list — every account the API knows,
one base58 wallet address per row. j/k (or the arrow keys) move the
selection; the status bar shows the total (N accounts).
Mailboxes
Press Enter on a wallet to list its mailboxes (INBOX, folders created
over IMAP, and so on). Unselectable hierarchy-only entries are marked
(noselect). Esc steps back to the wallet list.
Messages
Press Enter on a mailbox to open its message table:
| Column | Meaning |
|---|---|
uid | The message’s IMAP UID in this mailbox |
size | Stored size in bytes |
date | The message’s internal date (first 19 chars, YYYY-MM-DDTHH:MM:SS) |
chain | The delivery-pipeline chain state — local (dark gray) for a copy with no chain record |
cid | The IPFS CID of the sealed body, once pinned (empty until then) |
Rows are color-coded by chain state: terminal states draw attention, in-flight ones stay calm. This is the fastest way to answer “did that message actually make it on-chain, and what CID did it get?” for a specific user without querying the store by hand.
The balances pane
Press b on a wallet (in the wallet list, or anywhere deeper in that
wallet’s drill-down) to open its on-chain balances:
- Summary — native SOL (in SOL and lamports), the mailbox’s default stamp price in lamports, and its lifetime mail count.
- Senders — one row per other loaded wallet: the prepaid stamps it
holds toward this mailbox and the per-mail postage it owes
(
from/stamps/required (lamports)).
This is the console’s only direct-to-chain view: the summary and stamp
rows come from the mail-grpc gateway (GetMailbox, GetFrombox) and the
SOL balance from the JSON-RPC node — all read-only public chain state,
nothing signed. If neither endpoint is configured and reachable, the
pane reports an error while every admin pane keeps working.
Esc returns from any drill-down level toward the wallet list.
Tutorial: queues and dead letters
Switch to the Queues tab with Tab. The pane stacks two tables:
- Queues — one row per job queue
(
queue/depth/oldest), whereoldestis the age of the oldest waiting job in seconds. A depth the backend cannot report cheaply shows as?. - Dead letters — jobs that exhausted their retries
(
queue/type/attempts/died/reason), wheretypeis the payload’s job type tag.
Two actions work the dead-letter table, both requiring confirmation:
u— requeue the selected job (put it back on its queue for a fresh attempt, e.g. after fixing the outage that killed it).x— discard the selected job permanently.
Either key arms the action and the status bar asks
requeue <queue> job (<reason>)? y/n — press y to run it, any other
key to cancel. On cloud stores a listed dead job stays claimed for
about five minutes; requeue/discard echo the claim token back, so if the
claim has lapsed (or another operator acted first) the action fails
cleanly and a reload (r) shows the current truth.
Key reference
| Key | Context | Action |
|---|---|---|
j / ↓ | everywhere | Move selection down |
k / ↑ | everywhere | Move selection up |
Enter | Accounts tab | Drill down (wallet → mailboxes → messages) |
b | Accounts tab | Balances pane for the selected wallet |
Esc | Accounts tab | Step back up one level |
Tab | everywhere | Switch between the Accounts and Queues tabs |
r | everywhere | Reload the current pane |
u | Queues tab | Requeue the selected dead job (asks y/n) |
x | Queues tab | Discard the selected dead job (asks y/n) |
y | confirmation | Confirm the armed requeue/discard |
q | everywhere | Quit |
What the console deliberately does not do
- No store access. It links no storage backend; it cannot see rows
the API does not expose. (The
/v1/adminsurface is the contract — anything you can script against it, the console can show.) - No mail content. Message bodies are sealed to their recipients; the console shows metadata and chain state only.
- No chain writes. The balances pane is read-only; the console signs nothing but its own login challenge.
- No server management. Starting, stopping, and configuring the daemons stays with your process supervisor and config files.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Exits immediately with logging in to <url> | Account API down or unreachable at api_url, or the keypair file is missing/malformed |
| Every pane shows a 403 error | The logged-in wallet is not in the API’s admin_wallets allowlist (or the list is empty, which disables the admin surface) |
| Balances pane errors, admin panes fine | gateway_endpoint / rpc_url not reachable — expected on an admin-only setup; the pane needs both |
| Requeue/discard fails after sitting on the pane | The cloud store’s ~5-minute dead-job claim lapsed; press r and act on the fresh listing |
Change history
A running log of documentation-affecting changes, newest first. Each entry links to the section that changed (or that describes the change) so you can jump straight to it instead of re-reading the whole page.
Versioning. Sections are tagged with a date and a protocol version
(MAJOR.MINOR.PATCH):
- MAJOR — a breaking change to the SithBit public ABI (removing or
reordering an instruction variant, changing an account layout that clients
read, or repurposing an error code). Instruction enums and error codes stay
append-only and the protocol is pre-launch, so MAJOR remains
0for now. Pre-launch, breaking changes ride the MINOR digit — the cargo 0.x convention, where the leftmost non-zero digit is the one a break moves — and the entry carries the word BREAKING in its heading and states what stopped working in its first line, the way v0.7.0 did for alias-transfer consent. A break is not confined to the on-chain ABI: retiring a credential shape clients still hold breaks them just as squarely, and is tagged the same way. - MINOR — an additive public-ABI change (a new instruction, error, or
field), a significant change to the economic model that changes how end
users use the system (fees, pricing, prepayment rules), or — widened at
v0.10.0 — a significant additive capability or default-behavior change that
affects deployments (a new enforcement default, a new storage/config
backend kind). Earlier entries tagged such changes PATCH. It is also where
a breaking change lands while MAJOR is held at
0, per the rule above — the digit is shared, so the heading is what tells the two apart. - PATCH — documentation-only or otherwise non-behavioral changes.
A run of documentation-only edits no longer keeps one version across several dated sections: since v0.71.4 each such change has taken its own PATCH bump, so MAJOR.MINOR tags the protocol state while PATCH tracks the docs’ own movement and the date tags when they moved. One dated section is still shared when several changes of the same class land together on the same date.
2026-09-01 — v0.118.3 (a JMAP feasibility recon lands, deciding nothing)
PATCH: documentation-only. One new page,
JMAP feasibility — 2026-09, wired into
SUMMARY.md under Appendix: Reference beside its two structural
siblings, the dependency audits and the
RFC-updates recon. No code, no config, no
protocol surface moved — and, like the recon page, nothing on it is a
decision: it records findings and recommendations as inputs to a future
planning conversation.
- What it assesses. Whether a JMAP server surface (RFC 8620 core, RFC 8621 mail, eight published extension RFCs, plus RFC 9698 JMAPACCESS on the IMAP side) is feasible here, what the storage kernel can answer today, and what it would cost. Verdict: feasible, with a good HTTP substrate fit — but roughly half the work is backend capability the kernel has never needed, principally an account-scoped change log with expunge tombstones.
- The finding that is not an engineering cost. JMAP assumes the server can parse every message; for sealed-at-rest accounts that is only true inside a live session holding a reading secret. The page states the three ways through and notes that the choice gates the schema design, so it cannot be deferred into a build.
- An adoption section, because the answer is not obvious. JMAP is a finished standard with a healthy server ecosystem (Stalwart, Apache James, Cyrus) and essentially one provider of scale (Fastmail); Thunderbird, Apple Mail and Outlook do not speak it today. The page says plainly that adding JMAP would not in the near term make those clients work better against a SithBit mailbox.
- One prior decision re-read rather than inherited. RFC 8474 (OBJECTID)
is recorded declined, final in the recon page, which calls it “the
JMAP-era resync feature set” — but the stated reason was an
imap-typesfork block, which does not transfer to JMAP. The page records that the decline is not a precedent against this work, while the underlying storage capability remains unbuilt.
2026-09-01 — v0.118.2 (the campaign quote stops calling itself a ceiling)
PATCH: documentation-only, plus two shipped strings — campaign quote --sender’s help and the note the quote prints when no sender is given. No
pricing behaviour changed; only the sentences describing it did.
v0.118.1
retired the last “every match” over-promise about who a campaign reaches.
This entry retires a second, separate over-promise about what a campaign
costs.
- “The conservative ceiling” was not a ceiling. Without
--sender, the quote said every recipient was priced as a zero-spend first contact and called that the conservative ceiling. Two things were wrong. Recipients after the first are not priced at zero spend: each first-contact escrow folds into a simulated running spend that prices the next recipient, which the campaign guide already described correctly. And the result is not an upper bound in either direction that matters — pricing is not monotone in recorded reputation spend. A higher recorded spend earns a cheaper first contact, which folds a smaller escrow, which can leave a later recipient below a reputation tier boundary the zero-spend run had already crossed. A worked case: a senderless quote of 2.898 SOL against 3.029 SOL actually paid by a sender carrying 0.18 SOL of recorded spend — 4.5% over the quoted “ceiling”, for the same three recipients. - What the surfaces say now. The senderless quote states what it actually
assumes — no fromboxes are read and the sender is treated as having no
recorded spend — and says plainly that this is an assumption and not an
upper bound, so a real sender can be quoted more. The campaign guide’s
--senderentry carries the mechanism and points at the fold it already documented. - The excluded classes are no longer half-enumerated. An intermediate
wording named the unmailable and IPFS opt-out classes as the ones excluded
rather than priced, and omitted the over-
--max-postageclass — which is the one that fires most visibly, and which excludes recipients that are otherwise perfectly mailable. A vague over-promise had become a precise false list. The shipped text no longer enumerates a subset.
2026-09-01 — v0.118.1 (the campaign lifecycle diagram and the last CLI surfaces stop promising every match)
PATCH: documentation-only, plus two shipped --help strings and one module
rustdoc comment — all non-behavioural. Nothing about who receives a campaign
message changed; only the sentences describing it did.
v0.117.1
closed with a bullet saying the diagram was not swept and that the label
and its alt text had to move together or neither should. They have now moved
together, so that bullet no longer describes open work.
- The picture said what the prose had stopped saying. The campaign
lifecycle diagram’s stage-3 box read “Quote the matched set, then send one
bountied message each” — the same claim
v0.117.1
removed from six text sites, and the reason it was left there: correcting
the alt text alone would have made the alt text contradict the image. The
in-box label, the SVG’s
<title>, and the page’s alt text were rewritten as one change. The box now reads “Quote the matched set, note the skips, then one bountied message to each remaining match”, and the<title>and alt text — which have no width limit — name the three skipped classes outright. - Two CLI surfaces still carried it, and one of them shipped.
sithbit campaign quote --helpenumerated the pricing classification but named only five of its six classes, omitting the IPFS opt-out entirely, so the one class a reader could not have guessed was the one left out. Andsithbit campaign --helpdescribedsendas delivering “bountied mail those recipients can claim”, where “those recipients” pointed back at the full matched set. Both now name the classes a send actually reaches — the prepaid, top-up and first-contact matches the quote keeps — and the three it never does. - The CLI reference’s opening sentence, and one module comment. The
sithbit campaignreference page introduced the tree by saying a campaign wallet “prices a bountied send to the matched set, and sends it”, while the same page stated the exclusion rule correctly two hundred lines below; the lede now defers to that section. Thecampaignmodule’s own rustdoc had the identical construction and is corrected the same way. - A diagram note for whoever edits it next. The stage-3 box is now six
lines at a 16px pitch, and its longest line measures 118px inside a 156px
box. There is no seventh line: further detail belongs in the
<title>and the alt text, which are unbounded. The label is five — now six — sibling<text>elements, because SVG text does not wrap and every line break is a separate element.
2026-09-01 — v0.118.0 (an undatable block is never garbage-collected, at any grace)
MINOR, not PATCH: this changes what a GC sweep deletes. No ABI, instruction,
error-code or configuration-key change — gc_grace_secs is still the same
key with the same 3600 default — but the outcome of an unchanged
configuration moves at one value, so it is a behaviour change and takes the
digit that says so. It supersedes the note in
v0.116.1
that said correcting the code was backlogged.
- The contract said never, and the arithmetic said otherwise. A backend
that cannot date a block reports it as
u64::MAX— “just written” — so that age-based consumers never treat it as old enough to delete. The sweep computednow.saturating_sub(u64::MAX), which saturates to0, and tested0 < grace_secs. At a grace of0that is false, so the block took the delete branch: the one value at which the guarantee was needed was the one value at which it did not hold. - The sentinel is now matched on its own terms, ahead of the arithmetic, rather than relying on a subtraction that happens to be large. The alternative — rejecting a zero grace at config load — was considered and not taken: it would have closed the reachable path while leaving the stated contract false for any direct caller.
- Where such a block is counted. As for any block, on the mark set: a
referenced one counts as
live, an unreferenced one askept_young. Both arms are executed by tests rather than asserted in prose. - This is a floor that does not drain, and the reason matters to an
operator. Two of the three producers of the sentinel re-derive it from the
same stored object on every listing — an unparseable S3
LastModifiedand an absent Azurelast_modified— so over such a bucket a standing non-zerokept_youngin the sweep’s telemetry is expected steady state, not a growing backlog. The third, an unreadable local file mtime, is plausibly transient. The sweep cannot currently tell an operator which kind it is looking at. - Not fixed here, and still open: the grace is off by one against its own configured value, because two independently floored clocks are subtracted. v0.116.1 documents that at seven sites and it remains accurate; the comparison it describes is untouched here. Whether a fix should address truncation only or clock skew too is an open question, not a decision this change made.
2026-09-01 — v0.117.1 (the campaign docs and CLI stop promising every mailable recipient)
No ABI, instruction, error-code, economic or configuration-key change, and no
behaviour moved: the excluded classes are the three sithbit campaign send
has enforced since v0.117.0, and this entry only makes what we say about
them true. A PATCH bump — two prose pages, one --help string, two rustdoc
comments and one printed parenthetical, all non-behavioural.
- “Every mailable recipient” was never true after v0.117.0. An IPFS opt-out recipient is mailable in the plain sense the docs define at the quote classes — it has a mailbox — yet the batch excludes it. The claim survived in six places, of which the original filing named one. Each now names the classes that are sent to (prepaid, top-up and first contact) rather than implying the complement of “unmailable”.
- The worst of the six was shipped
--help.sithbit campaign send --helpenumerated the excluded classes and named only two, omitting the IPFS opt-out entirely — so the CLI’s own help contradicted the release note that introduced the class. The send reference’s lede carried the same over-promise eleven lines above the paragraph that corrects it. - The advertiser-facing page promised reach it does not deliver. For advertisers said you “reach exactly that set”, and the page mentioned the IPFS opt-out nowhere at all — its only opt-out is closing a beacon. The targeting claim survives, because it is true and is the point: the filter is a bitmap AND over self-declared tags, never a lookalike model. What moved is that the quote is now where the advertiser learns which matched wallets the send will skip, and that those cost nothing.
- The two sample transcripts had never agreed. The quote block reported 5 matched recipients while the send block reported 3, for the same invocation, since before v0.117.0. Both print through one function over the full matched set, so they must agree; the send block’s every other figure already described the 5-recipient match. The header is now 5.
- Not swept, and deliberately so. The campaign-lifecycle diagram’s stage-3 label carries the same claim, and the page’s alt text mirrors it — correcting the text alone would make the alt text contradict the image, so both move together or neither does.
2026-08-31 — v0.117.0 (campaign quote and campaign send honour the recipient’s no_ipfs opt-out)
No ABI, instruction, error-code or configuration-key change: the opt-out is the mailbox flag that already existed, read where the campaign already read the mailbox. MINOR because it is a new enforcement default — a class of recipient the campaign used to quote, bill and mail is now excluded outright, so the outcome of an unchanged command moves.
- Why a campaign cannot honour the opt-out any other way. A campaign send
seals nothing and uploads nothing: it composes the message in the clear
(
Subject:header, blank line, raw body) and puts that plaintext’s client-computed content address on chain. There is no operator store behind the body to keep the copy privately, the way ordinary SMTP delivery does, so publishing the address is exactly what theno_ipfsopt-out refuses. The directsithbit mail sendalready refused an opted-out recipient for the same reason; a batch excludes rather than aborting. - The exclusion is unconditional. It is decided from the mailbox flag alone, before the sender’s frombox is looked at, so a recipient with prepaid stamps standing — whose send would otherwise cost nothing to arrange — is excluded too. A paid stamp does not buy the right to publish an address the owner opted out of.
- What
campaign quoteshows. A sixth recipient class alongside prepaid / top-up / first contact / unmailable / over max-postage: the per-recipient line readsopted out of IPFS body storage — excluded, and the per-class summary gains anipfs opt-outrow. It is counted in its own row rather than folded intounmailable— these recipients do have a mailbox — and contributes zero lamports to every subtotal and to the grand total. - What
campaign senddoes. The opted-out wallets drop out of the batch, joining unmailable and over---max-postagerecipients; the page’s “excluded from the batch” sentence now names three classes rather than two. The unprepaid-stamp warning is unaffected — it counts only kept top-up and first-contact recipients. - Two pages corrected in the same release: the direct-send refusal was
explained wrongly. The
no_ipfsopt-out section — the page this entry links to for the reason — and themail sendpreconditions both saidsithbit mail sendpins the body client-side. It does not:--pathcomputes a local file’s content identifier from its bytes and--cidtakes an identifier the sender already holds, and the send commits that identifier on chain while uploading and pinning nothing. On the send-mail page the claim also contradicted that page’s own--pathnote (“does not upload or pin the content anywhere”) further up the same page; it now cross-references that note instead. That page’s lead-in read “Two preconditions must hold or the transaction is rejected on-chain” while listing three, so it now says three and marks the IPFS one as enforced by the CLI before it builds the transaction rather than on chain. The refusal itself did not change — only its explanation was wrong. Sealing and pinning belong to the spooler’sencrypt → IPFS pin → SendMailpipeline, not to either CLI path; pages describing that pipeline were outside this sweep.
2026-08-31 — v0.116.1 (gc_grace_secs is not the hard minimum it was documented as)
No ABI, instruction, error-code, economic or configuration-key change, and no
default moved: the key is still gc_grace_secs and it still defaults to
3600. A PATCH bump — prose only, plus the three Rust doc comments, across
two crates, that the prose paraphrases; those are equally non-behavioural.
No sweep behaviour changed, and one behavioural defect the sweep to-do list
now carries is deliberately documented rather than fixed here.
- The grace is approximate, not a floor. The cluster GC sweep decides
eligibility by subtracting a block’s recorded write time from the sweeping
node’s own clock, and both are whole unix seconds, floored independently.
The difference of two floors can already reach
Nwhen barely more thanN - 1seconds of real time have passed, so a configured grace ofNguarantees onlyN - 1seconds and a grace of1guarantees essentially none. - On a shared bucket the two are not even the same clock. A cluster’s
canonical storage is the shared S3/Azure bucket, where the block’s write
time is the object store’s
LastModifiedwhilenowis the node’sunix_now(). They drift independently, and that skew is signed: it moves the boundary in either direction, a store stamping ahead of the node lengthening the margin while a node running fast — or a store stamping behind — shortens it and can erase the grace outright. Every site this entry lists either states that mechanism or points at the site that does. The standing advice that goes with it, keep the default’s slack instead of tuning the value down to a pin’s measured upload time, is spelled out on the prose page and in both crates’ rustdoc; the reference cell and the two example configs stay terse and leave it to them. - Where this was corrected. The
[ipfs]reference table, the shared-bucket cluster’s GC invariant, both example configs (sithbitd.example.toml,sithbit_ipfsd.example.toml), and three rustdoc comments in two crates — the field’s own comment inipfs_swarm’sClusterConfig, plus both the module doc and thegcfunction doc inipfs_repo, which stated the hard minimum where the code lives. The[cluster]table on the IPFS services page needed nothing: it defers to sithbitd’s section by reference rather than restating the claim. This list is what was swept, not a claim that no other page mentions the setting. - A separate defect, documented not fixed:
gc_grace_secs = 0deletes undateable blocks. A backend that cannot date a block reports it asu64::MAXand the docs promised it was never collectable. At a grace of0that is false —now.saturating_sub(u64::MAX)is0, and0 < 0is false, so the block takes the delete branch. The sweep’s rustdoc now says “never eligible at any non-zero grace” instead. Correcting the code is backlogged; the default and every documented value are non-zero, so no shipped configuration hits it.
2026-08-31 — v0.116.0 (beacon tag vocabulary 24 → 71; campaign quote reads real prices)
No ABI or instruction change (tag bits are client-defined; no program redeploy). Two related changes land together:
- The participant-beacon tag vocabulary grew from 24 to 71 tags — append-only,
bits 24–70. Two new groups join interest/skill/age/region:
language.*(12 ISO 639-1 codes) androle.*(6 coarse occupation stages); region gains 9 subregions alongside the three broad bands. See CLI campaigns → Tags, the marketplace client, and the participant-marketplace note. sithbit campaign quoteandcampaign send’s confirmation now read each matched recipient’s live chain state instead of assuming the 1 SOL default: an existing frombox’s standing price and prepaid stamps, the reputation-scaled first-contact price, the stamp-purchase protocol fee the old quote omitted, the once-per-campaign sender-reputation account rent, and both signature fees for unprepaid recipients.--postageis replaced by--max-postage(a per-recipient ceiling that excludes too-expensive recipients);quotegains optional--sender; recipients whose owner has no mailbox are reported and skipped. See CLI campaigns and economics → Campaigns.
2026-08-31 — v0.115.1 (the fixture guard’s README catches up with the guard)
No ABI, instruction, error-code, economic or configuration-key change, and no book page changed a byte — a PATCH bump, v0.73.6’s class: docs tooling only, one section of the screenshot rig’s README.
- “Checking the chain-account fixtures” now describes the checker that
exists.
check-fixtures.mjsoutgrew its own documentation across the #205/#206 hardening: the README still described four assertions and a four-item self-test, while the checker runs a stderr fence plus nine named audits (thirteenaudit*functions counting the helpers inside them) and its self-test drives eighteen seeded regressions inMUTATIONS— plus the direction the old text did not know existed, a one-entryEQUIVALENTSlist of byte-changing but meaning-preserving edits the guard must NOT flag. The live equivalent is the internaldate spelling (+00:00vs.000Z, one instant either way), and the direction carries the #206 lesson the rewritten section states: a guard that reds on correct input is worse than no guard, because it fails an author for doing the right thing and the cheapest fix is weakening the guard. The section also keeps the #205 reason the served-mail audits exist, the stale-frames limit, and the exit-2 rule for self-test scaffolding that no longer matchesfixtures.mjs. No checker code changed a byte.
2026-08-31 — v0.115.0 (the folder rail pins INBOX/Sent/Drafts, marks special folders, and folds a long list behind More)
No ABI, instruction, error-code or economic change. MINOR per the 2026-08-30 decision and the wave-1/3 precedent: More/Less is a new client affordance, not a restyle.
- The rail opens with a pinned section. INBOX, then Sent, then Drafts, in that fixed order, each hoisted with whatever nests under it, above a divider rule; the rest of the tree follows unchanged. A key the account has no folder for contributes nothing — the rail still never invents a row the server did not list.
- Special-use folders draw an icon. Sent, Drafts, Archive, Trash and
Junk/Spam are matched on the whole folder name, case-insensitively, the
same rule
imap_session’sSpecialUse::for_mailboxapplies on the server; nested names never match, and INBOX carries no role. Folders of your own draw none. - A long list folds behind More. 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; it reads Less while the rest are showing. Only that section is ever cut: the More link never hides the pinned three, nor the folder you are currently reading, even when it sorts past the cut — a subtree you folded yourself stays folded in either section, so your own fold can still hide the folder you are reading. The link renders only while a row is genuinely hidden — an account one folder over the line with that folder selected draws everything and offers no link.
- Creating a folder navigates to it. The rail reloads, unfolds any parent you nested it under, and selects what you made, so a folder created past the cut is drawn rather than hidden behind it. That selection closes whatever message the reader had open, exactly as picking any folder does.
- The webmail three-pane section documents all of it. The committed inbox frame is a genuine re-shoot: the fixture account grew four ordinary folders so the rail actually exceeds the threshold, and the frame AE-reads 12428 against its predecessor — 11900 of that in the rail itself, the rest a wider reader Move to… control, whose width tracks the longest folder name.
2026-08-30 — v0.114.2 (folder rails bold the names of folders holding unread mail)
No ABI, instruction, error-code, economic or configuration-key change — a PATCH per the 2026-08-30 decision: a client-side restyle, not a new capability (the rail’s unseen counts shipped with the original shell).
- Folders with unread mail render their name bold. The webmail and Outlook folder rails bold a row’s name (weight 700, the unread message-row cue) when that folder’s own unseen count is above zero — no subtree bubble-up: an unread subfolder does not bold its parent, because the server computes no per-subtree aggregate. The webmail three-pane section now says so; the Chrome popup stays unstyled per its plain-list precedent. The committed inbox frame is unchanged by measurement, not neglect: a fresh eight-frame re-shoot AE-compared 0 everywhere (12-px known gradient noise aside), because the fixtures’ one unread folder is the selected INBOX, whose weight-600 highlight already rasterizes with the rig font’s only bold face.
2026-08-30 — v0.114.1 (sibling one-step .p12 import claims swept behind the Thunderbird known issue)
No ABI, instruction, error-code, economic or configuration-key change — a PATCH: documentation prose only, aligning sibling pages with the v0.113.1 known issue.
- No page claims an unqualified one-step
.p12import anymore. Thecreate-certreference keeps Thunderbird in its SASL EXTERNAL client list but points straight at the known issue, and its.p12bullet now says what the bundle is for rather than promising a single-step import. The Outlook walk-through hedges rather than condemns: its certmgr / Keychain import path is untested here so far, while Thunderbird’s store is known to refuse the bundle. The Thunderbird auth-paths diagram’s title and its alt text now mark SASL EXTERNAL as currently unusable in Thunderbird. The.p12/ PEM pair stays documented as a valid credential for other clients.
2026-08-30 — v0.114.0 (the web clients’ folder rail manages folders: create, rename, delete)
No ABI, instruction, error-code, economic or configuration-key change — a MINOR for a client-side capability, the v0.84.0/v0.96.0/v0.113.0 class: the shared mail panes (webmail, Outlook task pane, Chrome popup) gained behavior users see.
- The folder rail can create, rename, and delete folders now. A
New folder affordance under the list opens a dialog with a
nest-under parent picker (any listed folder, or the top level), and
each row’s ⋮ menu — revealed on hover or keyboard focus in the styled
shells — offers Rename, Delete, and New subfolder. Rename edits the
folder’s full name in a single input, so changing the path part
re-parents it and its subtree moves along (the dialog warns when
there is one); a selection the rename moved follows to its new name.
Delete confirms first, then takes the one mailbox and its messages
and keeps its subfolders — an orphaned child re-renders flat under
its literal full name, the rail’s no-phantom-parents rendering. A
\Noselectplaceholder row offers only New subfolder: there is no real mailbox there to rename or delete. Failures stay on each dialog’s own error line with the typed input intact. See the webmail three-pane view — the third of the folder-management waves filed 2026-08-30.
2026-08-30 — v0.113.1 (known issue: Thunderbird cannot import the SithBit .p12)
No ABI, instruction, error-code, economic or configuration-key change — a PATCH: documentation prose only, correcting a client walk-through claim against live evidence.
- The Thunderbird one-step
.p12import claim is now a documented known issue. A live walk on Thunderbird 140 ESR showed Your Certificates → Import refusingsithbit mailbox create-cert’s password-less bundle (“Failed to decode the file”), and a repack into NSS’s preferred shape (sha256 MAC, PBES2-shrouded key bag) still failing (“The PKCS #12 operation failed for unknown reasons”) — consistent with NSS refusing Ed25519 private-key import into its soft token. The Thunderbird client-certificate section now states plainly that SASL EXTERNAL from Thunderbird is effectively unusable until NSS accepts the key, keeps the mint instructions and the.p12’s purpose for other clients, and points at the mail password as the working Thunderbird sign-in. The certificate writer is unchanged by decision (2026-08-30) — its output stays RFC 7292-legal and deterministic, and the PEM pair remains valid everywhere.
2026-08-30 — v0.113.0 (the web clients’ folder rail renders the folder hierarchy)
No ABI, instruction, error-code, economic or configuration-key change — a MINOR for a client-side capability, the v0.84.0/v0.96.0 class: the shared mail panes (webmail, Outlook task pane, Chrome popup) gained behavior users see.
- The folder rail is a tree now, not a flat list of full names.
Folder names nest on the
/hierarchy separator the/v1/mailsurface already carried: nested rows indent and show only their last path segment, and a parent row gains a disclosure triangle that folds its subtree shut and open (the fold survives the count refresh a successful send fires). A folder whose parent chain is not in the listing renders flat under its literal full name — the rail never synthesizes a parent row the server did not list. A\Noselectplaceholder (deleted-with-children) stays unselectable but still folds. See the webmail three-pane view; the first of the folder-management waves filed 2026-08-30 (the rest: pinned special folders, folder create/rename/delete, unread styling).
2026-08-30 — v0.112.2 (concurrent first boot of a fresh SQLite store is safe; the boot-lock sidecar)
No ABI, instruction, error-code, economic or configuration-key change — a PATCH: a defect fix with one operator-visible artifact.
- Starting
sithbitdandaccount-apitogether against one fresh SQLite store no longer kills the loser. Both processes run schema migrations at open, and nothing serialized them across processes (the observed deaths:duplicate column name,table … already exists); the same window could generate two different credential keys.Storeopen now takes an exclusive advisory lock for its first-boot work, so the pair boots in either order or simultaneously. - A
<database>.boot-locksidecar file appears beside the SQLite database — documented in the[store]table. It is never written, only locked; it releases with the process (a crashed holder cannot wedge later boots) and is harmless to leave in place.
2026-08-30 — v0.112.1 (audience-facing prose revised against the four-cluster market research)
No on-chain ABI, instruction, error-code, economic or configuration
change — a PATCH: prose only, reorganized and reworded so each of the four
audiences in branding/marketing/market-research.md (recipients, payers,
owners, builders) meets its own appeal and proof points.
- The Introduction is reorganized. It now leads with fraud and AI-written phishing alongside spam (with the FBI IC3 and vendor figures linked), and its sections follow the message hierarchy: Sender-pays postage, Get paid to be emailed, Paid is delivered, No token, Your address is yours, and your mail is sealed, Anyone with a domain can run one, and a closing Find your path that routes each kind of reader to their chapters. “Blockchain Email” and “Priced in SOL — No New Token to Trust” are gone as headings.
- The 1 SOL default is never shown bare. Every mention in the edited pages — the landing page, Getting started step 3, Economics, Mailboxes and Fromboxes — now says it is a wall the owner lowers per sender.
- Three facts are stated where they were only derivable: the recipient keeps roughly 90% of postage (Economics, landing page, Introduction); aliases and domain authorizations never expire and carry no renewal fee (Aliases, Trading names); and the operator’s 10% share now appears on Domains and at the top of Running a mail server, with the point that chain-native delivery has no IP reputation in its path.
- Spam is no longer the only value word. Phishing, invoice fraud and deliverability enter Verified-sender attestation, Addresses, Standards, the Prelude and Lockbox; Campaigns and Beacons carry the advertiser and participant appeals (“quote the whole campaign before a lamport moves”, “you keep the postage even if you never reply”).
- New appendix, Tracking pixels, consent, and the paid inbox, under Trust & Security: takes the tracking-pixel and consent problems the Introduction’s cited regulator report describes and answers each with the mechanism SithBit already has (the sandboxed webmail reader, the on-chain delivery record, reply bounties, beacons as separate revocable consent), plus what it does not fix. The Introduction footnotes it.
- “Blockchain” is no longer the lead noun on What’s public and private or Addresses; the landing page keeps its structure and card order, with “no token” and the 90% share added to the hero.
2026-08-30 — v0.112.0 (the account API trusts a private Redis CA: [cache] redis_ca)
No on-chain ABI, instruction, error-code or economic change; a new
configuration key that changes what a deployment can connect to — a
MINOR, like v0.111.0’s redis_auth.
account_api.toml’s[cache]section gainedredis_ca— a key source (file /akv/asm/gsm, theredis_authshape) whose bytes are one or more PEM certificates that replace the platform root store for therediss://connection. It exists for Google Memorystore, whoseSERVER_AUTHENTICATIONTLS presents an instance-specific Google-managed CA (server_ca_certs) no platform store holds; ElastiCache and Azure Cache for Redis are publicly signed and need nothing. Refused underkind = "local", beside a plainredis://URL, and — the fence that matters — when the value is unreadable, empty or holds no certificate: therediscrate would otherwise build an empty root store that trusts nothing and fails open on every read, indistinguishable from a down server. Row in the account-api configuration table and a “The server’s CA” bullet under A shared backend for a fleet.iac/gcpprovisions the trust whendeploy_redisis on: the instance’sserver_ca_certsis written to a module-created Secret Manager secret (sithbit-account-api-cache-ca, outputredis_ca_secret— public material already in state, not a token), mounted into the Cloud Run service as a secret volume at/etc/sithbit/redis-ca/ca.pem, andACCOUNT_API_CACHE__REDIS_CAnames that path; the service account getssecretAccessoron it. Theiac:terraform-gcpsuite now asserts sixACCOUNT_API_CACHE__*entries, the secret version’s PEM, the grant and the volume/mount pair (a plantedserver_ca_certsviaoverride_resource). The Memorystore subsection drops its “not yet solved in the image” caveat;iac/README.md’sdeploy_redisrow and “Validation gates” say so. Still no live end-to-end deploy (alpha).
2026-08-29 — v0.111.2 (terraform test fences for the iac/aws and iac/gcp modules)
No on-chain ABI, instruction, error-code, economic or configuration change — a PATCH: the two Terraform gate legs grew a test step, and the docs that name them say so.
iac:terraform-awsandiac:terraform-gcpnow end withterraform test, overiac/aws/tests/account_api_cache.tftest.hclandiac/gcp/tests/redis.tftest.hcl. The suites run under a mock provider — no credentials, no network, nothing created — and evaluate whatterraform validatestructurally cannot: thelifecycle { precondition }blocks and thelocal.*env and policy maps (a misspelled attribute on a null-defaulted object variable validates green; the suites turn red on it). Each fences the opt-in summary cache: off by default with no[cache]env entry, half-configured refused by the preconditions, fully configured wiring exactly the fiveACCOUNT_API_CACHE__*selector entries with the token never in an env value. The leg count stays 38. The Hosting on AWS and Hosting on Google Cloud cache subsections andiac/README.md’s “Validation gates” name the step; no managed cache has been deployed end to end yet (alpha) — these fences are the only exercise the two modules get.
2026-08-29 — v0.111.1 (managed Redis for the account API’s shared cache in all three iac/ modules)
No on-chain ABI, instruction, error-code or economic change, and no new configuration key — a PATCH: three infrastructure modules and the deploy chapter that documents them.
iac/gained an opt-in managed cache per cloud for the shared summary-cache backend ([cache] kind = "redis"), each off by default and each wiringACCOUNT_API_CACHE__KIND/__REDIS_URL/__REDIS_AUTH__*into the account-api unit with a cloud key-source selector, so the token never appears in an env value:iac/aws/elasticache.tf(create_account_api_cache, a one-node ElastiCache Valkey group in the private subnets behind a security-group pair — Hosting on AWS),iac/gcp/redis.tf(deploy_redis, Memorystore for Redis BASIC 1 GB over private-services-access — Hosting on Google Cloud), andiac/azure/main.bicep(deployRedisCache, Azure Cache for Redis Basic C0 behind a private endpoint and private DNS zone on a BYO undelegated subnet — Hosting on Azure). The deploy chapter gained the “Hosting on AWS” and “Hosting on Azure” sections these live under;iac/README.md’s mapping tables gained the rows.- Each cloud has an operator step for the token, because no module
reads or writes a secret store (the no-vault rule): on AWS the same
token goes in
account_api_cache_auth_tokenand the Secrets Manager secret named byaccount_api_cache_auth_asm; on Google Cloudterraform output -raw redis_auth_stringis copied into the Secret Manager secret named byaccount_api_cache_auth_gsm; on Azure the cache’s primary key is stored in the account-api Key Vault underaccountApiCacheSecretName, which the vault-wide secret-read grant already covers. One caveat is recorded plainly: Memorystore’sSERVER_AUTHENTICATIONTLS presents a Google-managed CA the container must trust (server_ca_certs), a step the image does not yet provision. None of the three caches has had a live end-to-end deployment yet (the project is alpha); each is validated statically by itsiac:*gate leg.
2026-08-29 — v0.111.0 (the account API’s shared cache takes its password from a key source: [cache] redis_auth)
No on-chain ABI, instruction, error-code or economic change. A MINOR bump (the v0.110.0 precedent: a new configuration key on a deployment surface).
account_api.toml’s[cache]section gainedredis_auth— a key source selector (a file path, or anakv/asm/gsmcloud secret, the shapejwt.key_fileuses) whose bytes become the Redis password, so a managed cache’s token never sits inredis_url. The row is on the configuration reference and the semantics under A shared backend for a fleet: trailing whitespace is trimmed, the selector wins over a password embedded in the URL, an unreadable or empty secret refuses to start (an empty password would sendAUTH ""), and setting it underkind = "local"refuses too rather than silently ignoring a secret. The managed-Redis sentence there no longer points atBACKLOG.md: the per-cloud units land in this release series and the deploy chapter records each as it arrives.- The workspace gate gained three
iac:*legs —iac:terraform-aws,iac:terraform-gcp(fmt -check, offlineinit,validate) andiac:bicep-azure(az bicep build+build-params) — so the three infrastructure trees are statically validated on every run (38 legs, from 35). Two defects that had sat outside any gate’s reach were fixed on the way in: two@descriptionstrings iniac/azure/main.bicepwith unescaped apostrophes (the template did not compile), andiac/gcp/mail_grpc.tf’s alignment (terraform fmt -checkfailed).
2026-08-28 — v0.110.0 (the account API’s plaintext summary cache can be shared across replicas: [cache] kind = "redis")
No on-chain ABI, instruction, error-code or economic change. A MINOR bump (the v0.109.0 precedent, and the v0.10.0 rule: a new configuration surface and a new optional deployment component).
account_api.toml’s[cache]section gained three keys —kind("local", the default, or"redis"),redis_urlandredis_ttl_secs— so a fleet ofaccount-apireplicas behind a load balancer can share one plaintext summary cache in a Redis server instead of warming N private ones. The rows are on the configuration reference and the semantics under its new A shared backend for a fleet: the sealed-summary cache and the reading secrets stay per replica by design; the backend is fail-open (a down server is a miss, bounded at about two seconds per call, never a refusal to start — only a malformed URL is); keys aresithbit:summary:v1:<blob key>with JSON values;redis_ttl_secsbounds the server’s memory, withmaxmemory-policy allkeys-lruas an optional second bound;rediss://is TLS.- The
rediscargo feature is off by default.kind = "redis"on a binary built without it refuses to start, naming the feature; the docker image’sallfeature set includes it. docker-compose.prod.example.ymlcarries a commented-outredis:service and the two commentedACCOUNT_API_CACHE__KIND/ACCOUNT_API_CACHE__REDIS_URLlines onaccount-api; a managed Redis iniac/(ElastiCache, Memorystore, Azure Cache for Redis) is deferred to its ownBACKLOG.mdentry.- Scaling out’s account-API cache item now
says which cache a fleet can share and which two cannot, and that
summary_capacityis unused under"redis". - Monitoring:
under the shared backend
sithbit.api.cache.hits/missesare still tallied per process, whileevictions,entriesandcapacityread0forcache="summary"— the server does not report them to one client, so the four-state sizing table does not apply there. - The conformance emulator set gained Redis (
redis:7-alpine, exported asSITHBIT_TEST_REDIS_URL), so the backend’s live round-trip test runs under the same env-gated skip idiom as the cloud stores.
2026-08-28 — v0.109.1 (eviction no longer scans, so a bigger account-API cache costs only memory)
Documentation-only, and a PATCH: no ABI, no configuration key, no default and no economic change. What changed underneath is an implementation detail with no user-visible behaviour — eviction still picks exactly the least-recently-used entry — but it invalidates cost guidance the previous two entries gave, so the guidance is corrected here.
- The “cost is not monotonic in the size” advice is retired. Both
account-API summary caches used to find their eviction victim by
ranking every resident entry, so one eviction did work proportional to
the capacity, while holding the one lock every request to that cache
contends for. A stamp-ordered index now names the victim directly, and
an eviction examines a single entry at any capacity. The practical
consequence for an operator: sizing
summary_capacityis a memory question and a hit-rate question, and no longer a lock-contention one — the farm values are a straight memory trade. See Tuning the account-API caches. - The
summary_capacity = 65536row on the configuration reference no longer warns of “a proportionally longer locked eviction scan”, because there is no longer a scan to lengthen. - Still true, and still the first thing to read: sitting just below the working set remains the worst place to be, because nearly every insert evicts. That was always about the eviction rate, which this change does not touch — only the cost of each one.
2026-08-28 — v0.109.0 (the account API’s summary caches are configurable: the [cache] section, and smaller zero-config defaults)
No on-chain ABI, instruction, error-code or economic change. A MINOR bump (the v0.89.0 precedent): four new configuration keys, and two default sizes changed, which an operator sees as memory.
account_api.tomlgained a[cache]section —summary_capacity,session_summary_capacity,max_cached_sessionsandmax_session_secrets— sizing the four bounded in-memory caches that were compile-time constants until now. Every key is optional and defaulted; the section’s meaning, what each bound does when it is exceeded, and the two startup validations (a too-small plaintext cache is clamped with a warning; a too-small per-session quota or a zero bound refuses to start) are in the configuration reference.- Two zero-config defaults changed, to size the small self-contained
deployment a no-config startup represents. The shared plaintext
summary cache grew from 1024 to 4096 entries (a single session’s
working set now fits, which also stops the eviction scans an under-sized
cache paid on every insert), and the sealed-summary cache’s session
bound fell from 32 to 8 full-quota sessions (its ceiling from 32 768 to
8 192 summaries, the biggest single saving). Net, the API’s resident
memory ceiling is smaller than before; a deployment holding more
than eight concurrent keyed sessions sees the least-recently-used
session re-decrypt on its next read, and sets
max_cached_sessionsback up. The per-session quota and the session-secret bound did not change. - Farm-scale starting values, and the per-replica trap, are recorded
beside the section:
summary_capacity = 16384or65536andmax_cached_sessions = 32or more, sized by the concurrent sessions one replica sees — each replica holds its own cache, so sizing from the fleet total over-provisions memory N-fold. The shipped AWS production document (iac/appconfig/aws/account-api.toml, and the Azure kvset generated from it) now writes those values out. - The account API exports its cache metrics, so the
[cache]values can be decided from telemetry instead of taken on trust. Ten new series in the OTLP metrics table:sithbit.api.cache.hits/.misses/.evictions/.entries/.capacity, onecachelabel (summaryfor the shared plaintext cache,session_summaryfor the per-session sealed cache, whosecapacityis its ceiling); the unlabeledsithbit.api.cache.session_dropsfor whole-session drops at themax_cached_sessionsceiling, kept apart from quota evictions; andsithbit.api.session_secrets.entries/.capacity/.evictionsfor the reading-secret stash, where an eviction logs a user out. All are per process — each replica holds its own caches — and cost nothing with no[observability.otlp]section (the instruments are observable readings of tallies the caches keep under the lock they already hold). - A tuning guide reads those series into actions. The new
Tuning the account-API caches
section derives the hit rate, gives a four-state decision table
(never fills / thrashing / hot set captured / steady state) naming the
[cache]key each action changes, a sizing estimate off the measured grid (hit rate H at capacity C puts the working set near C / H, an upper bound to step toward, not jump to), the non-monotonic cost (just below the working set is the worst place: a poor hit rate and an O(capacity) locked eviction scan that stops once it fits), a memory conversion (~0.5–1 KB per summary, an estimate), and the per-replica trap. Four pages now point at it: eachcache.*row of the configuration reference (“how to decide the value”), a new Sizing the summary caches section on the service page, a Known seams bullet on the scaling page, and the day-2 handoff at the end of the deploy guide.
2026-08-27 — v0.108.4 (the 1232-byte packet statements are qualified as legacy/v0 now that reads admit transaction v1)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump: doc comments and two mdBook sentences.
- The “1232-byte transaction packet” statements on the two DNSSEC-witness
pages and in the three witness-size constants now say which transaction
format they describe. The RPC read path’s
max_supported_transaction_versionceiling was raised to admit transaction v1 (SIMD-0385, feature gatetxv1aq4pp281K9um3tnPgkfX8UqtFT6wcVW3hNezGLL, inactive on every cluster as of this date), with v1 decode/ingest tests fencing it. v1 raises the serialized envelope from the ~1232-byte legacy/v0 packet to 4096 bytes and carries compute config in the message header, so a bare “1232-byte packet” became format-specific prose. The proof-of-behaviour appendix and the authorize-by-proof page now say legacy/v0, note the v1 4096-byte envelope, and state that SithBit’s own producers still emit legacy transactions — so the witness chunking and the inline-witness caps stay sized to 1232 until a v1 emit path lands. TheMAX_PROOF_WITNESS_LEN/MAX_PROOF_WITNESS_CHUNK/MAX_RRSIG_WITNESS_LENdoc comments and the alias-create packing budget’s doc comment say the same; no constant changed.
2026-08-27 — v0.108.3 (the IMAP4rev2 row says exactly which rev2 fold-ins are shipped, and which are not)
- The IMAP standards table’s
RFC 9051 row now enumerates rev2’s Appendix E fold-ins against what SithBit
ships. Implemented: NAMESPACE, UNSELECT, UIDPLUS, ENABLE, IDLE, SASL-IR,
MOVE,
LITERAL-, the RFC 5530 response codes and the SPECIAL-USE attribute list. Deferred behind the parser fork:ESEARCH, SEARCHRES,LIST-EXTENDED,LIST-STATUS, BINARY’s FETCH side,STATUS SIZE/STATUS DELETED, 64-bit sizes and theCLOSEDresponse code — the last four of those were previously tracked nowhere. Two framings corrected:CONDSTORE/QRESYNCare not part of rev2 (it borrows only theCLOSEDresponse code), and rev2 keepsSTARTTLSandLOGINDISABLEDmandatory — implicit-TLS-only is a project choice under RFC 8314, not a rev2 requirement. No behaviour changed.
2026-08-27 — v0.108.2 (the placeholder-PEN promise is enforced at mainnet preflight)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump: two appendix sentences and a deploy-tooling guard.
- The mainnet deploy preflight refuses to run while the node-delegation
OID’s enterprise arc is still the placeholder. The
service-discovery appendix
and the
conformance appendix
have said since the appendix was written (2026-07-09) that the
1.3.6.1.4.1.58888arc is an unregistered placeholder that MUST be replaced by a real IANA Private Enterprise Number before mainnet. That was prose; nothing checked it.scripts/preflight-deploy.sh chain-mainnetnow carries a “PEN arc is registered” row that fails whilenode_cert’s constant is58888or its doc comment still calls the arc a placeholder, so the deploy guard blocks a mainnet deploy the same way it blocks a committed upgrade authority. Both pages note the enforcement; the honest limitations bullet says so too. The remaining three limitations on that list stay recorded caveats with named reopen triggers (a user decision recorded in the workspace’s durable record), and a certificate form needing no registration remains a considered alternative.
Operator action: none until a mainnet deploy is planned; then register a PEN (or adopt the registration-free form) before running the preflight.
2026-08-27 — v0.108.1 (the default-value fence follows a field’s serde rename)
No on-chain ABI, instruction, error-code, economic or behavioral change, and no book page changed a byte — a PATCH bump, v0.73.6’s class: docs tooling only, one checker and the gate README.
check_config_keys.py’s value fence looks a row up by its wire spelling. A field carrying#[serde(rename = "…")]is documented under the name a TOML file uses — the account API’sstatic_filesis the[[static]]row — and the fence, which matched rows to fields by the Rust name alone, filed such a field as “no row states a default for” while its row sat one name away. Harmless while that cell is prose; silent the day a renamed field gains a value cell. The rename is now read off the struct declaration on both parse paths and the row is found by it; findings keep quoting the Rust field name. On the live reference the summary moves one field from undocumented to a prose row. Two self-test cases (renamed-doc-drift, and a row keyed by the Rust name of a renamed field, which is nobody’s row) join the stand-in’scleanproof; the README’s counts move to 158 and forty-nine.
Operator action: none.
2026-08-27 — v0.108.0 (Address resolution is literal-first everywhere)
What changes: two resolvers — the sithbit library’s C entry point
resolve_alias and the gRPC gateway’s ResolveAlias — looked the alias
registry up before recognising a local part as a wallet address, so an
alias registered under an address’s lowercased spelling (which the
case-folded alias namespace accepts) redirected mail sent to that address.
Both now answer a wallet literal with itself before any alias lookup,
through one shared ordering rule, matching what every server path
(MX, spooler, DSN, account API, CLI send) already did. The
Mailboxes
and Aliases pages,
and the Create an alias
and Reserve aliases in bulk
references, no longer describe the automatic self-alias as a spoof guard:
it is kept, deliberately, as a namespace reservation.
Operator action: none. Non-Rust hosts embedding the sithbit shared
library get the corrected resolve_alias on their next rebuild.
2026-08-26 — v0.107.2 (configuration reference: two footnotes and a swarm row)
What changes: documentation only. The domain-sithbit
and mail-grpc pages each
referenced a Solana-CLI-config footnote that only the env-files page
defined, so the marker rendered as literal text; each page now carries its
own definition. The sithbitd [ipfs.swarm] table gains the
ipfs.swarm.reprovide_interval_secs
row (default 22 h) it previously only mentioned in passing, worded as the
sithbit-ipfsd table’s row is.
Operator action: none.
2026-08-26 — v0.107.1 (configuration reference: three settings the examples never showed)
What changes: documentation only. Three settings that existed in
code but in no example file or reference table are now in both:
account-api’s mail.auto_mark_seen
(mark a fetched message \Seen, off by default) and sithbit-ipfsd’s
reprovide_interval_secs and kad_protocol
under [swarm]. Every per-binary table of the reference also closes
with the [health], [observability] cross-reference row the gateway
and standalone-server pages already had, pointing at the shared section
and at the Monitoring table that lists each binary’s health port.
Operator action: none.
2026-08-26 — v0.107.0 (sithbitd refuses report sections without a reporter identity)
What changes: a new enforcement default. sithbitd now refuses to
start when [spooler.dmarc_report], [spooler.dmarc_ruf] or
[spooler.tlsrpt] has enabled = true while org_name or email is
empty after trimming, with an error naming the section, the empty
setting and the remedy (set both — they are the reporter identity — or
disable the section). A disabled section may stay empty, as before.
Until now an enabled reporter with no identity started cleanly and sent
reports whose org_name/organization-name and From: were blank. The
six org_name / email
rows
state the refusal, and the annotated example TOML carries it beside the
REQUIRED (public) when enabled marker.
Operator action: a deployment that enabled any of the three
reporters without an identity is refused at its next restart — fill in
org_name and email, or set enabled = false.
2026-08-26 — v0.106.0 (sithbit-migrate refuses mismatched credential keys)
What changes: a new enforcement default. sithbit-migrate now compares
the [source] and [target] credential_key_file keys before any
migration step — dry run and --commit alike — and refuses the run when
they differ, with an error naming both settings and the remedy (copy the
source key file to the target). Sealed mail secrets are copied as
ciphertext and never re-sealed, so before this a target opened under a
different key ran to a successful-looking summary and left every stored
mail password unreadable, to be discovered by a user who could no longer
log in. The target store is opened before the check fires, so a target
pointed at a key file that does not exist yet has one generated fresh, is
refused as a mismatch, and leaves that generated file behind — copying
the source key over it resolves both. The
target.credential_key_file row
no longer says “nothing compares the two keys”, and the
v1 caveats keep
the not-re-sealed limitation while stating the refusal.
Operator action: none for a correctly carried key. A migration that
previously “succeeded” with an orphaned key would now be refused — copy
the source credential.key to the target’s configured path before
re-running.
2026-08-26 — v0.105.0 (domain-sithbit names its RPC endpoint)
What changes: domain-sithbit gains a
json_rpc_url
setting — the Solana JSON-RPC endpoint the delegate’s on-chain domain
authorization is sent through. Unset or blank is no override: the
endpoint resolves the way the sithbit CLI does, from the Solana CLI
config (~/.config/solana/cli/config.yml) or a bare JSON_RPC_URL
environment variable, and failing both from the CLI’s default cluster,
mainnet-beta. That last fallback is the reason the setting exists: a
container with no CLI config previously signed against mainnet
silently. The row carries the REQUIRED (public) marker, the
annotated example TOML shows it commented out, and the iac/appconfig/
production document names it with a CHANGE placeholder (the Azure
flavor inherits it unchanged — a URL has no cloud-specific form).
Operator action: any domain-sithbit deployment without a Solana
CLI config on the host should set json_rpc_url (or
DOMAIN_SITHBIT_JSON_RPC_URL) to the intended cluster’s RPC provider.
2026-08-26 — v0.104.0 (sithbit-ipfsd refuses a non-loopback bind without a token)
What changes: a new enforcement default. sithbit-ipfsd now validates
its configuration before it listens: a non-loopback bind_addr with
auth_token unset (or blank) is refused at startup with an error naming
both settings and the remedy — set the token, or bind a loopback address.
IPv4 and IPv6 loopback are exempt, so an empty config still runs the
zero-config dev instance; an IPv4-mapped [::ffff:127.0.0.1] is refused,
fail-closed, as mail-grpc does. The pin API stores and unpins blocks for
whoever calls it, and an absent token passes every request, so before this
a reachable daemon with no token silently handed that write surface to the
network. The
configuration reference
legend now names two binaries that couple a non-loopback bind to a
credential — mail-grpc ([auth]) and sithbit-ipfsd (auth_token) —
and the sithbit-ipfsd rows, the
shipped example TOML, the iac/appconfig/ production documents (both
clouds) and the recipient pin provider
page all say so instead of “nothing enforces it”.
2026-08-26 — v0.103.1 (configuration reference: what must change when going public)
What changes: documentation only. Every per-binary page of the
configuration reference
now marks the rows an operator has to revisit before a listener leaves
loopback — REQUIRED (public) for a setting a reachable deployment is
unsafe or non-functional without (bind addresses, the TLS behind them,
hostnames, shared stores, secrets, the gateway’s mutual-TLS tables) and
RECOMMENDED (public) for a safe default that a public deployment
should choose consciously (rate limits, blocklists, sender policy,
quotas, telemetry). Unmarked rows keep the shipped default. The same
markers head the matching comment blocks in the annotated example
TOMLs and the iac/appconfig/ production documents, so all three
surfaces read alike. The legend states the one fact that keeps the
markers honest: they are operator obligations, not startup checks —
only mail-grpc couples a non-loopback bind to its [auth] section.
Three sentences were corrected on the way: the standalone SMTP
server’s shipped file never set require_tls = false (it defaults off
in MX mode), the DMARC-report and TLSRPT org_name/email rows said
“required when enabled” where nothing enforces it, and account-api’s
[chain.grpc_tls] row scoped its “refuses to start” to the
scheme-versus-table mismatch the process can actually detect.
Operator action: none; the markers describe the checks the production deployment chapter already expects.
2026-08-26 — v0.103.0 (the gateway’s callers speak mutual TLS)
What changes: v0.102.0 stated the gateway authenticates its callers;
this entry is where every side of that becomes real. The gateway now
enforces [auth] at accept time — an unknown or missing client
certificate is dropped during the handshake, before any request is read —
and the three callers gain the matching client half:
sithbitd’s [grpc.tls],
the standalone SMTP server’s
[grpc_tls], and
account-api’s [chain.grpc_tls].
Each takes cert and key (the caller’s own PEM Ed25519 certificate, a
path or a cloud secret source) and gateway_key — the base58 Ed25519 key
in the gateway’s certificate, pinned: no CA, no hostname check, one
key written on each side. The endpoint dialed becomes https://, and a
scheme that disagrees with the table refuses to start rather than fail
per-call.
Two consequences worth reading twice. The gateway’s [auth] cert must
be an Ed25519 certificate, since that is the key type callers pin.
And sithbit-console carries no client table: it reaches only an
unauthenticated (loopback) gateway.
Operator action: every caller of a non-loopback gateway needs its own
certificate, its key, and the gateway’s key; the iac/ templates carry
them for account-api (account_api_chain.grpc_tls / accountApiChainTls*)
and the app-config documents for sithbitd. Absent the table, every caller
keeps its plaintext dev shape.
2026-08-26 — v0.102.0 (BREAKING: the gRPC gateway authenticates its callers)
What stopped working: allow_remote_bind is removed. A mail_grpc.toml
(or MAIL_GRPC_ALLOW_REMOTE_BIND environment variable) that still sets it now
fails to load, because the config struct rejects unknown fields. A gateway
bound to a non-loopback address no longer starts by asserting its network
segment is private; it starts by authenticating its callers.
What changes: a new
[auth] section configures mutual TLS on
the gRPC surface. The rule is: loopback with no [auth] serves
unauthenticated — which keeps a zero-config dev run and the test harness
working — while any non-loopback bind requires [auth] and is refused at
startup without it. An [auth] section that is present is enforced on every
bind, loopback included, so configured authentication is never silently
ignored. A half-written section is refused rather than served unauthenticated.
Callers are identified by the Ed25519 public key in their client certificate,
checked against authorized_keys — the same 32-byte transport identity the MX
servers already bind for SASL EXTERNAL. An empty authorized_keys is not
“allow all”: it is an incomplete section and startup is refused.
Why the reversal: the gateway’s private-network-only posture was recorded twice, most recently in August 2026 when a security review raised the unauthenticated write surface and three guardrails were taken instead of authentication. That decision named its own reopen trigger — a gateway reachable across a segment that is not trusted — and it has been exercised deliberately, on a zero-trust reading of the network. The gateway spends its fee-paying wallet on behalf of whoever calls it, so reachability alone is no longer accepted as the boundary.
Operator action: every non-loopback deployment needs a certificate, a key
and an allow-list before it will start. The iac/ templates for all three
clouds carry the new settings as operator-supplied variables, and
iac/appconfig/aws/mail-grpc.toml shows the shape.
2026-08-26 — v0.101.0 (a whole message’s attachments can be bounded)
What changes: a new, off-by-default
aggregate_bytes
setting on [spooler.offload], capping the decoded attachment bytes one
message may leave inline in total. Delivered bytes are unchanged unless an
operator sets it.
The gap it closes. threshold_bytes is compared per part and only per
part, so twenty 1 MB attachments under a 5 MiB threshold ride inline as a
20 MB message — every part innocent on its own. That is a property of the
per-part rule, not a defect introduced by any recent change, and until now the
only way to bound such a message was to refuse it outright
(max_message_size
or max_wallet_bytes). aggregate_bytes
shrinks it instead.
Which parts leave. The largest eligible parts are offloaded first, and the pass stops the moment what remains inline fits the budget — the ordering that reaches the budget while turning the fewest attachments into links, so a recipient keeps as many inline files as the arithmetic allows. The two size rules compose as a union: the threshold takes what it takes, and the budget tops the selection up from what is left.
Three things it deliberately does not do. It does not count the text or
HTML bodies — they can never be offloaded, and a budget metered on something
the feature cannot shrink would be unsatisfiable by construction, so a
body-heavy message can still exceed it. It does not override
content_id:
a part held inline counts toward the budget but is never taken to satisfy it,
which makes the budget a target rather than a guarantee. And it never refuses
mail — a budget it cannot meet takes what it may and delivers.
Arming. Either size rule now arms the offload on its own, so
aggregate_bytes with threshold_bytes left at 0 is a valid “cap the
total, ignore part size” policy. Both at 0 remains the default and delivers
today’s bytes unparsed. As before, a size rule set on a daemon with no pinning
provider is inert and says so at boot — that warning now fires for either
rule, not just the threshold.
See Large attachments and IPFS offload → Two size rules, and why the second exists.
2026-08-26 — v0.100.0 (oversized inline parts can be offloaded)
What changes: a new, off-by-default
content_id
setting on [spooler.offload], and a new X-SithBit-Offload-Cid header on
every offload placeholder. Delivered bytes are unchanged unless an operator
sets content_id, apart from that one added header.
The gap it closes. The offload refused any part carrying a
Content-ID, at any size, because an HTML body referencing it as cid:…
would be left pointing at nothing — so a 40 MB inline image rode inline and
the threshold could not save you from it. But most mail clients stamp a
Content-ID on every part they build, so the rule also pinned down
ordinary oversized attachments that nothing in the message ever referenced.
spooler.offload.content_id("never", the default) keeps that behavior exactly."orphaned"offloads aContent-IDpart no body references — nothing points at it, so nothing can dangle."all"offloads referenced ones too, rewriting each<img>that rendered one into a link.- Under
"all"the inline rendering is lost and cannot be kept: the file is sealed and its key rides in the link’s#fragment, which is never sent to a server, so no<img src>could ever render it. A link is the honest degradation; a broken image is not. - A reference that cannot be rewritten holds its part inline. A CSS
url(cid:…), abackground=attribute, a plain-text body, or anycid:reference resolving to no part of the message leaves everything where it is. See What is offloaded — and what never is. X-SithBit-Offload-Cidcarries the bare cid beside the existing-Urlheader, for clients and re-gatewaying tools that need the content address rather than one operator’s hostname. It does not repeat the key.- The threshold stays per part:
content_iddecides which parts are eligible, never how big one must be.
2026-08-26 — v0.99.0 (stored-password accounts can be sealed at rest)
What changes: a new, off-by-default
[account_keys]
section on sithbitd. It gives the daemon an operator-held root from
which each account’s at-rest key is derived, so the one population
at-rest sealing leaves in
plaintext can be sealed too.
The gap it closes. Sealing encrypts a stored body to the account’s own reading key — but an account that has ever configured a stored mail password is deliberately excluded, because its CRAM-MD5 and APOP logins prove a password and never carry a key that could unwrap a sealed body. Those accounts’ mail has therefore been stored in the clear, protected only by whatever the backend encrypts at rest, and the threat model has said so since v0.76.0. Nothing closed it until now.
account_keys.root(unset = off) is the operator root secret, given as a key source: a file path or a cloud secret-manager entry. Material over 32 bytes is stretched through a key-derivation function, so it need not be a formatted key.
What it protects. A stolen bucket, a leaked table export or a restored backup yields ciphertext, because the root lives outside the store. It does not protect against a compromised running server: these accounts’ mail must be readable on demand, which is what reading mail with a password means, so the root is necessarily in the daemon’s memory. The threat model now states that boundary explicitly.
Existing mail is not migrated. Only deliveries made after the root is configured are sealed; each stored body is read according to its own at-rest header, so plaintext and sealed rows coexist with no flag day and the exposure shrinks as mailboxes turn over.
Failure behavior differs by side, deliberately. A configured root that cannot be loaded fails startup, rather than silently storing mail in the clear — the one outcome an operator cannot detect from outside. At read time an unresolvable key refuses that single message transiently and leaves the rest of the mailbox listable.
2026-08-25 — v0.98.0 (on-chain publication can be rate-limited)
What changes: a new, off-by-default
[spooler.chain_budget]
section on sithbitd. It bounds how fast delivered mail is published to
Solana, and with it how fast the daemon spends from the gateway’s
fee-payer wallet.
The gap it closes. Recipients pay postage, but the transaction fee
for every SendMail comes from the operator’s wallet. [smtp] postmaster_wallet must accept mail from any sender (RFC 5321 §4.5.1) and
so skips the postage check by design — leaving an unauthenticated sender
able to drive fee-payer spend at whatever rate the connection limits
allow. v0.76.0 metered that (sithbit.chain.sendmail) and wrote it into
the threat model; nothing bounded it until
now.
spooler.chain_budget.max_per_window(0= unlimited) caps publications per window across every sender. This is the budget that bounds spend, and the one to set wheneverpostmaster_walletis set.spooler.chain_budget.max_per_sender_per_window(0= unlimited) caps one envelope sender. Fairness only — on the exempted path the sender is unauthenticated and freely varied, so it cannot bound total spend on its own. The threat model now says so explicitly rather than leaving it to be discovered.spooler.chain_budget.window_secs(60) is the reset span.
Over-budget mail is paced, never refused. It is accepted, stored and readable over IMAP/POP immediately; only the chain job waits, enqueued invisible until its turn. Nothing bounces, and a sustained flood grows the chain queue rather than being shed — mail is not destroyed to protect the wallet.
Nothing changes for an existing deployment. Both budgets default to
0, which is unlimited and costs no store round trip, so a daemon whose
operator sets nothing publishes exactly as immediately as before. The
production config documents spell the section out at those defaults; they
do not set postmaster_wallet, so they have no exposure to size a budget
against.
Two properties worth reading before relying on it: the window is fixed, so a burst straddling a boundary can reach up to twice the budget; and a store that cannot answer a charge fails open, publishing unpaced rather than stalling mail. Report traffic the daemon generates itself (DSNs, DMARC forensic, TLS-RPT) is deliberately unbudgeted, as it is already exempt from the per-wallet storage cap.
2026-08-25 — v0.97.1 (the durable switch is reachable from the production config documents)
What changes: documentation, and the shipped deployment artifacts it
describes. v0.97.0 gave both account-API rate-limit budgets a
durable
switch, but iac/appconfig/’s ready-to-import production documents carried
no rate-limit section at all — so on both clouds the switch could be set
only through an ACCOUNT_API_RATE_LIMIT__DURABLE environment variable, not
through the config store the rest of the deployment is configured from.
iac/appconfig/aws/account-api.toml now spells
[rate_limit]
and
[nonce_rate_limit]
out in full, and the Azure key/value set is regenerated from it, so the
switch is one edit and one regeneration away on either cloud.
The durable section gains a paragraph naming where to set it, and the
ordering that goes with it: iac/aws pins the account-api service to a
single task because, without a shared JWT signing key, each replica mints
its own and a token issued by one is rejected by the next. Give the
replicas a shared signing key first, scale out second, turn the switch on
third. The login-challenge budget’s example block also gains the durable
line its own key table already documented.
What you need to do: nothing. Both sections ship at the in-code
defaults — durable = false included — so no deployment’s behaviour
changes, and an operator who had already set the environment variable
still wins, since the real process environment outranks the cloud
app-config tier.
2026-08-25 — v0.97.0 (rate-limit budgets can be shared across API replicas)
What changes: the account API’s two rate-limit budgets —
[rate_limit]
over the sensitive account mutations and
[nonce_rate_limit]
over login-challenge issuance — gain a
durable
switch. Their windows have always lived in the API process’s own memory, so
two replicas behind a load balancer granted the same wallet two budgets and
three granted three; the effective ceiling scaled with the instance count.
With durable = true the windows move into the [store] every replica
already shares, and the budget is one budget however many instances charge
it. Every backend supports it — SQLite, PostgreSQL, DynamoDB, Azure Tables,
Turso/libSQL and Cloudflare D1.
- Defaults off, and nothing changes for an existing deployment. A single replica gains nothing from a store round trip per guarded request, and off is what lets a developer run with an empty config file and no store wiring. The two budgets carry the switch independently.
- It trades away the monotonic clock, which is forced rather than chosen. An in-memory window is timed by a monotonic instant no wall-clock change can move. A window shared between processes has to be comparable between them, and a monotonic instant means nothing outside the process that read it — so a durable window rides the charging replica’s wall clock. A backwards jump there can reopen a window early, and replicas whose clocks disagree disagree about a window’s edge. Keep the fleet on NTP, which every lease in the store already requires.
- A store error admits, and logs. The limiter blunts abuse; it is not an authorization boundary, and refusing during a store outage would lock every account out of its own settings and every user out of logging in. That is the same fail-open direction the in-memory table already takes when full.
- Lapsed windows are swept on a timer (five minutes, not a knob), because the stored table has no fixed size and the login-challenge budget is keyed on an unauthenticated, caller-supplied pubkey. On DynamoDB the sweep is the store’s own TTL rather than a delete, so removal is asynchronous and can lag by up to about two days — that bounds growth, which is the point, and a lapsed window is already ignored by the charge whether or not its row is gone.
Who is affected: operators running more than one account-api replica
against one store, who can now size a budget once rather than per replica.
Single-replica deployments and every client are unaffected: the refusal
wording, the 429, the Retry-After header and both budgets’ defaults are
unchanged.
2026-08-25 — v0.96.0 (the client-side attachment offload is actually reachable)
What changes: the client-side large-attachment offload described at v0.84.0 now runs in the shipping plugins. It was complete and tested but inert: both the Thunderbird and Outlook shells built their lockbox sealer without a pin function, and the offload is skipped whenever one is absent — so no SithBit client could produce an offloaded attachment, while the documentation described the capability as shipped. Reading offloaded mail was unaffected and always worked, including mail offloaded by a server-side sender.
- What a user sees. With a pin service saved in connection settings, attachments over 1.5 MiB are encrypted, pinned, and replaced by a reference before the 12 MiB envelope cap is measured — so messages that previously refused at the cap now send. Without one, nothing changes.
- What “configured” means, now that it decides something. A pin service URL must have been saved in connection settings; the field being pre-filled with the loopback development default is not enough. Arming on the value alone would point every install at a port that usually has nothing behind it and turn a large-attachment send that seals fine today into a failure.
- Where an offloaded attachment is pinned. Under
offload/{uuid}— the same pin namespace the server-side offload uses, so one operator sweep over the prefix covers both producers, and the name discloses neither sender nor recipient. Operators should note that a client-pinned attachment has no spooler job behind it, so nothing releases it automatically: it belongs to the same operator-sweep population as a relayed submission’s pins, and the shared prefix is deliberately what makes one sweep cover both.
Who is affected: plugin users sending large attachments with a pin service configured — for whom the feature now works rather than silently sealing inline or refusing at the cap. No server, deployment, or wire change.
2026-08-25 — v0.95.0 (stored messages keep their final CRLF)
A truncation fix, not a format change. The adopted smtp-proto grammar’s
DATA receiver was deleting three bytes at the <CRLF>.<CRLF> terminator where
it should delete one. Two of those three were the message’s own final CRLF:
RFC 5321 §4.5.2 has the sender end its
content on a CRLF and then append the terminating dot, so that line break is
body content. Every message SithBit received over DATA was therefore stored
one line break short, and a trailing blank line was lost outright. Upstream
fixed this in smtp-proto 0.2.2; the workspace pin has now moved to =0.2.3.
What changed for you. A message stored from now on ends exactly as its sender wrote it. In practice this is a single trailing CRLF, visible mainly where a body deliberately ends in a blank line or where a MIME closing boundary was left without its line break. Nothing already stored is rewritten, and nothing that worked before stops working.
What is not affected. Relayed mail never carried the defect: the outbound path re-adds a trailing CRLF when one is missing, so bytes leaving the relay were correct under both versions. DKIM signatures are unaffected in both directions — relaxed body canonicalization absorbs a trailing CRLF, so the body hash is identical either way, which is now pinned by a test rather than inferred from the RFC.
2026-08-25 — v0.94.0 (BREAKING: the CLI now content-addresses on CIDv1, as documented)
This is a conformance fix, not a new contract. The docs already specified
one import profile: SithBit “uses the modern CIDv1 form of these fingerprints
exclusively”, and the glossary
defines a single UnixFS import
profile — CIDv1, sha2-256, raw
leaves, 256 KiB balanced dag-pb, byte-identical to Kubo. The self-hosted node,
the gateway and the spooler all matched it. The sithbit command-line tool
did not: it computed CIDv0 with dag-pb leaves (Qm…), so those two sentences
were untrue of CLI-sent mail. The CLI now matches, and they are true.
What changed for you. sithbit mail send, sithbit campaign send and
sithbit campaign detail commit a different — and now correct — CID for the
same body: a 59-character bafk…/bafy… address instead of a 46-character
Qm… one. Nothing already sent is altered; a message’s CID is fixed in its own
on-chain record and stays resolvable wherever its bytes are pinned.
What breaks. sithbit mailbox pin
refuses a message whose on-chain CID is a legacy Qm…, by name rather than as
a mismatch, and does not pin it. This build cannot recompute a CIDv0, so it
cannot distinguish substituted content from a stale address — it fails closed
rather than misreport the cause. Messages sent by the CLI before this version
must be re-sent to gain a verifiable address.
The bug this closes. A body pinned through a self-hosted node
([ipfs] kind = "remote") was already addressed as CIDv1. sithbit mailbox pin recomputed it as CIDv0 and rejected it as a “CID mismatch” — reporting a
corrupt gateway when the real cause was two disagreeing profiles inside
SithBit itself. One profile, computed in one place, is what closes it.
2026-08-25 — v0.93.0 (BREAKING: the two report cadences become fixed periods)
What stopped working: window_hours and interval_hours under
[spooler.dmarc_report]
and
[spooler.tlsrpt]
no longer change anything. Both aggregate-reporting periods are now compiled
constants fixed at 24 hours. An operator who had set any of the four to another
value will find their reports move to a daily cadence on the next restart.
Your config still boots. The four keys are deliberately still accepted —
deleting the fields would make deny_unknown_fields refuse the whole document
at load, so an upgrade would stop the daemon over a setting that no longer
matters. Instead each key that was set to something other than 24 is named in a
warning at boot, with the value being dropped. A document that never mentioned
them, or that spelled out 24, warns about nothing.
Why fix them. A daily period is an interoperability contract rather than a local preference: RFC 9990 §3.1 aggregate reporting and RFC 8460 TLS reporting are both daily, and the receivers and report-ingest tooling on the other end expect a day. The pair could also be set to disagree with each other — the window is metadata claiming which period a report covers, so a drain cadence that differed from it shipped a report whose date range and rows did not match. One constant per protocol removes that. The two constants are independent despite both reading 24: they answer to different RFCs, and one moving must not silently move the other.
Scope. This is the first item of the configuration review’s Tier 3
(knob → compiled constant). The other thirteen candidates were considered and
deliberately kept configurable — the 0-means-disable escape hatches on the
server handshake and write deadlines, the cluster-sizing cadences a growing
fleet needs, the RPC- and billing-sensitive poll intervals, and [smtp] greeting, which is branding text. Only these four were fixed by a spec rather
than by preference.
2026-08-25 — v0.92.0 (sithbit-console joins the roster; every config-taking binary is now listed)
What changes: the configuration reference now lists the admin TUI’s
annotated example file, mail_console/sithbit_console.toml, and carries its
sithbit_console.toml / SITHBIT_CONSOLE_CONFIG / SITHBIT_CONSOLE row in
the per-binary table. Its four settings were already documented under
sithbit-console and
are unchanged. No key is added, removed or renamed, and no default moves.
What was wrong. This page said two binaries “ship no annotated example file”. That was true of the migrator until v0.91.0 — and it was never true of the console, whose example file has shipped all along with every entry commented out at its default. The sentence is gone, along with the last reason to keep either binary off the roster: eleven binaries take an annotated TOML file of their own, and the page now names all eleven.
Why it is worth an entry at all. The roster is not decoration — it is what the docs gate diffs. A binary on it has its example file’s key set checked against this reference in both directions, so a setting can no longer ship undocumented and this page can no longer describe a key the file does not show. Both newly-rostered binaries were outside that check until now, and the console’s own parse test said so in a comment for as long as it was true.
2026-08-25 — v0.91.0 (sithbit-migrate becomes a documented, example-shipping service)
What changes: the store-migration tool now ships an annotated
mail_migrate/sithbit_migrate.example.toml and has its own page on the
configuration reference,
sithbit-migrate settings.
It is the tenth binary on the reference’s roster — the intro count and the
canonical-example list both name it, and the per-binary table carries its
sithbit_migrate.toml / SITHBIT_MIGRATE_CONFIG / SITHBIT_MIGRATE row.
Nothing about the tool’s behaviour changes: no key is added, removed or
renamed, and a deployment that already runs it needs no edit. What changes is
that its settings are now documented and fenced rather than described in
passing.
Why it needed both halves at once. sithbit-migrate sat in the docs
gate’s EXCLUDED_PREFIXES with the recorded reason “no annotated example
TOML on the reference page”. Shipping the example file without writing the
page would have made that exclusion a lie; writing the page without the file
would have left the roster row asserting a file that does not exist. They land
together, and the exclusion is deleted.
The migrator’s shape, for the reader who meets it here first. Every other
binary carries one [store] section; the migrator reads one store and writes
another, so it carries two — [source] and [target], each a complete
[store] shape with the same per-backend sub-sections and the same defaults.
The new page documents the two and points at
[store]
for the sub-sections, since only the prefix differs. The one shape that does
not simply follow the prefix is Cloudflare, whose blobs come from
[target.cloudflare.r2] rather than [target.blobs].
Moving a store between backends keeps the operational
narrative — what moves, dry-run versus --commit, and the caveats — and now
points at the reference for the keys.
A fence the tool had been missing. Every other config-taking crate has a test that uncomments its shipped example one level and deserializes the result, so a renamed field cannot first surface on an operator’s uncommented line. The migrator had no example file and so no such test; it has both now.
2026-08-25 — v0.90.0 (the last two shared listener values: enable_stored_passwords and max_message_size)
What changes: sithbitd.toml gained the two top-level settings that
v0.89.0 could not carry —
enable_stored_passwords
and
max_message_size.
Both are additive defaults, like the four before them: a listener that
writes its own always wins, and a config file that never mentions either
behaves exactly as it did. Nothing is removed and no default moves, so an
existing deployment needs no edit.
enable_stored_passwords fills in [smtp], [submission] and [pop] —
the three listeners that advertise CRAM-MD5. [imap] has no such key,
since IMAP never offers the mechanism. Retiring stored mail passwords
fleet-wide remains a two-document change: this covers the daemon’s
listeners, and account-api’s key of the same
name is what stops new passwords
being stored.
max_message_size fills in [smtp], [submission] and [imap] — the
three that carry mail; [pop] accepts no uploads and has no ceiling. The
SMTP and IMAP ceilings were already documented as deliberately the same
number, so that a message which arrived can always be uploaded back; this
is what lets a deployment state it once instead of three times and keep it
true.
Why these two took a second pass. hostname and local_domains each
have a sentinel no operator writes on purpose — the built-in "localhost",
an empty list — so “wrote nothing” is recognisable. A lone bool and a
lone number have none: enable_stored_passwords = true on a section is
byte-identical to that section saying nothing. Both listener fields are
therefore optional in the code and read everywhere else through an
accessor supplying the default, so a shared value fills in the silent
listeners without overwriting one an operator deliberately wrote out.
One input is refused rather than inherited. A top-level
max_message_size = 0 fails startup, naming the key. To the SMTP listeners
0 means advertise SIZE with no fixed limit, while [imap] feeds the
same number to its literal cap and advertises it as
APPENDLIMIT,
where 0 refuses every APPEND — one shared key cannot mean both. Per
section it is untouched: [smtp] max_message_size = 0 still means what it
always did.
One doc row changed spelling, and the reason generalizes. The
[smtp]/[submission] table’s max_message_size row is now written
smtp.max_message_size, submission.max_message_size rather than bare.
A bare Key cell resolves at the top level as well as under its section, so
once a top-level key of the same name existed the bare row read as
documenting that one too. Any future top-level setting sharing a name with
a listener key meets the same wall.
2026-08-24 — v0.89.0 (sithbitd writes shared listener values once: top-level [tls], hostname, local_domains, [quota])
What changes: sithbitd.toml gained four top-level settings that fill
in the listener sections which named none of their own. Every one is an
additive default — a section that wrote the setting is untouched, so an
existing config file behaves exactly as it did. What changes is how much a
new one has to repeat.
| New key | Fills in | Left alone when |
|---|---|---|
[tls] | [smtp.tls], [submission.tls], [imap.tls], [pop.tls] | the listener wrote its own [*.tls] |
hostname | [smtp], [submission], [imap], [pop], [spooler] | the section names a hostname of its own |
local_domains | [smtp], [submission], [spooler] | the section’s own list is non-empty |
[quota] | [smtp.quota], [submission.quota] | the role wrote its own table |
Four listeners on one host almost always terminate the same wildcard
certificate — they must, since clients reach mail., imap. and pop.
of the same domain — so writing it four times meant four places to miss on
a renewal. The shipped production documents now name that certificate’s
two secrets once instead of four times, and the deployment’s hostname once
instead of five times. [imap] and [pop] keep their own hostnames,
which is the override path doing its job.
hostname and local_domains are bare keys, so — like
login_requires_mailbox and max_wallet_bytes before them — they must be
written above the first section header, or TOML reads them as keys of
whichever section precedes them. [tls] and [quota] are tables and can
sit anywhere.
A [submission] section now runs as submission without saying so.
mode = "submission" under a table named submission was ceremony; the
daemon fills it in when the table names no role. Writing mode = "mx"
there is still honoured and still runs a second MX listener — the value is
filled in, never forced, because that choice also decides the listener’s
sender-authentication posture and silently flipping it would change more
than a label.
Who is affected: nobody is required to change anything — this release
adds defaults, it does not remove settings. Operators writing a new
sithbitd.toml (or trimming an existing one) can collapse the repeated
blocks. One note for anyone copying the shipped production document: its
[submission] section previously named neither hostname nor
local_domains and fell back to boot-time chain discovery; it now
inherits the explicit shared values written at the top. That is the same
identity in every deployment the document describes, stated rather than
discovered.
2026-08-24 — v0.88.0 (BREAKING: two dead config keys removed; a silently-ignored gateway setting now says so)
What changes: three settings that parsed and then reached nothing are
gone, and one that quietly does nothing under sithbitd now announces
itself at boot.
[store.cloudflare] kv_namespace_idis removed. It was retired on 2026-07-19 when leases moved from Workers KV to D1’sleasestable, and kept as an accepted-but-ignored key for a migration window. That window is over: the section rejects unknown keys, so a config still naming it now fails at load with a message naming the key instead of starting while the operator believes a KV namespace is in use. Delete the line.[spooler.dmarc_ruf] extra_contact_infois removed. It was documented as “informational only”, which overstated it — the value was never copied into the forensic reporter’s settings, and the ARF forensic format has no field that could carry it. It now fails at load.[spooler.dmarc_report]keeps its ownextra_contact_info, which is real and is published.[smtp] grpc_endpoint/[submission] grpc_endpointundersithbitdnow warn at boot. They are read only by the standalonesmtp-serverbinary; under the daemon the gateway comes from the top-level[grpc]section, and these parsed and vanished. They still parse — the standalone binary needs the field — but the daemon now says it is ignoring them and points at[grpc] endpoint, the way it already does for a per-protocolauth_rate_limit. Silence there read as “the gateway is wired”, which was exactly backwards.
Also: sithbit-console’s shipped sithbit_console.toml gained the
uncomment-and-parse test every other shipped example already had, and the
duplicate mail_grpc/mail_grpc.toml and domain_sithbit/domain_sithbit.toml
are deleted — each was a byte-identical copy of the .example.toml beside
it, sitting under the exact filename its binary loads from the working
directory.
Who is affected: deployments carrying either removed key — a one-line
deletion each, and the load error names the key. Deployments that set a
per-role grpc_endpoint under sithbitd see a new boot warning describing
configuration that was never in effect; nothing about their behaviour
changes.
2026-08-24 — v0.87.0 (BREAKING: the account API’s [tls] keys are certs / key, matching every other listener)
What changes: account-api’s
[tls] section is now
spelled the way the mail listeners have always spelled theirs —
certs and key instead of cert_file and key_file. The section is
the same shape everywhere because it is now literally the same type: one
[tls] block definition shared by smtp-server, imap-server,
pop-server, sithbitd’s four listeners and the account API, so the two
names cannot drift apart again. Nothing else about the section changes —
both entries are still required when it is present, each is still a key
source
(a local file by default, or an akv / asm / gsm secret holding the
PEM), and an absent section still means plain HTTP.
There is no compatibility alias. An account_api.toml that still names
cert_file / key_file under [tls] fails at startup rather than
quietly listening on plain HTTP — the section rejects unknown keys, which
is what turns a stale name into a loud error instead of a silent downgrade
from https. Rename the two keys before upgrading.
Who is affected: only deployments that terminate TLS on the account API
itself — a two-line rename in one file. Deployments fronted by a reverse
proxy (the production recommendation) carry no [tls] section and are
unaffected, as are all mail-listener configs, which already used these
names.
2026-08-24 — v0.86.0 (the dashboards fit without scrolling: a five-tab account strip and a Mail / Account / Marketplace switch)
What changes: every graphical client’s signed-in dashboard — webmail, the Thunderbird and Outlook extensions, and the Chrome popup — used to be one long column: ten account panes, the extension-local Certificate sign-in section, and the marketplace, stacked, so routine actions sat well below the fold. The shared panes now sit under a five-tab strip — Wallet (Balances, Encryption key), Mailbox (Mailbox, Do not disturb, Close mailbox), Names (Aliases, Domains), Services (Reply bounties, Pinning leases) and Sign-in (mail password Settings, plus each client’s Certificate sign-in and connection settings) — and every client gains a top-level Mail / Account (Settings) / Marketplace switch, so the marketplace is a view of its own rather than the tail of every column. Hovering a tab for half a second shows a plain-language note of what it holds. A Lease this message click still lands on the Pinning leases pane: the shell switches to the account view and the strip opens its Services tab. Nothing moves on-chain or in any configuration: the panes, their controls, and the wire contracts are unchanged; only where they sit on the page is.
Who is affected: users of the four graphical clients, who find every control one or two clicks away instead of a scroll down. Nobody else — the CLI, the servers, and the protocol are untouched.
2026-08-24 — v0.85.1 (the Thunderbird message pane’s attachment save button actually saves)
What changes: nothing about which messages open or what they show. v0.85.0’s
Sealed attachments save button
looked correct in every test that did not run inside a real Thunderbird: a
live-verify walk found the button silently did nothing, because the
message-display script’s document is not the dashboard tab, and Thunderbird
does not let that content-script context trigger a download itself — the
identical anchor-click shape works fine from the dashboard. The already-
decrypted bytes are now handed to the extension background instead, which
saves them via the downloads API — the one context always running and
privileged enough to do it, needing no wallet and no dashboard tab.
Who is affected: Thunderbird extension users saving a sealed attachment
from the message pane, who previously got no file and no error. The
extension requests one additional permission (downloads) to save it.
2026-08-24 — v0.85.0 (lockbox mail opens in Thunderbird’s own message pane)
What changes: the Thunderbird extension now opens Lockbox mail where Thunderbird users actually read — the message pane itself — rather than only in the dashboard. Opening a sealed message replaces the armored block with its decrypted text under a notice line, lists the envelope’s Sealed attachments with per-file save (inline attachments decode locally; offloaded ones fetch from your own IPFS gateway and decrypt on your machine), and says so plainly when a message was sealed to a different recipient.
Two limits are deliberate and documented rather than worked around. The message pane holds no wallet: it hands the sealed block to the SithBit tab, which must be open with a wallet unlocked, so key material stays in the one context that already had it — closed or locked degrades to the armored block plus an unlock hint. And a sealed message’s rich HTML is not rendered in the message pane: the web clients have a sandboxed view for that and this pane does not, so sender-authored HTML is never parsed there.
Who is affected: Thunderbird extension users reading sealed mail. The
extension requests one additional permission (scripting) to install its
message-display script. No server, deployment, or on-chain change.
2026-08-24 — v0.84.0 (lockbox mail opens in the web clients, and large attachments offload client-side)
What changes: two client capabilities on the lockbox page, no ABI or wire change — MINOR per the widened rule.
- Reading sealed mail in the web clients. The shared mail reader — the webmail app, the Chrome extension popup, and the Outlook task pane — now opens a sealed message with the unlocked wallet: decrypted text and rich HTML render through the existing sandboxed views, a notice line names the state (opened, sealed to another recipient, or unlock-to-read), and the envelope’s attachments are listed under Sealed attachments with per-file downloads. An offloaded attachment is fetched from the recipient’s own configured IPFS gateway and decrypted locally; a fetch whose bytes disagree with the declared size is refused. Previously the web clients showed the armored block as plain text and no client rendered a sealed envelope’s attachments at all.
- Client-side large-attachment offload at send time. When the sending client has an IPFS pin service configured, attachments over 1.5 MiB (an eighth of the 12 MiB envelope cap, derived from it) are individually encrypted, pinned, and replaced in the envelope by a content id + key + size reference before the cap is measured — so messages that could not previously be sealed now can. The reference carries no URL (a content id outlives any gateway hostname), and configuring the pin service is the entire on/off switch. Without one, behaviour is unchanged: inline seal or a clear refusal at the cap.
Who is affected: web-client users (sealed mail and its attachments are now readable there); plugin users sending large attachments with a pin service configured. No server or deployment change.
2026-08-23 — v0.83.5 (classic Outlook: the XML manifest is the real path, and sealing fails closed there)
What changes: the Outlook client page no longer
claims classic Windows desktop Outlook runs the add-in via the unified
JSON manifest: live probes on classic desktop Outlook (16.0.20326) proved
that host never invokes the unified manifest’s messageSending handler,
its JS-only send runtime accepts no ES modules, and it has no outbound
network from the send hook. Classic Windows desktop now installs via the
add-in-only XML manifest (rendered to package/manifest.xml), its send
hook is a self-contained script, and Lockbox sealing is not available
at send time there — the hook states the reason in the message and the
send proceeds as typed. The sideloading section gains the classic-desktop
path; the Outlook-for-Mac XML fallback is unchanged.
Who is affected: classic Windows desktop Outlook users (their mail sends unsealed, with the reason stated); operators sideloading the add-in on classic desktop.
2026-08-23 — v0.83.4 (the dependency audit’s wildcard follow-up lands)
What changes: the 2026-08 dependency audit’s
Follow-up: the remaining wildcards
section now records its own completion: mail_client’s four wildcard
dev-dependencies were pinned on 2026-08-23 (assert_cmd = "2",
predicates = "3", regex = "1", and tempfile moved to workspace
inheritance of the root’s tempfile = "3"), with Cargo.lock
byte-identical — a notation-only manifest tightening, closing the last
wildcard anywhere in the workspace’s manifests.
Who is affected: contributors reading the audit; no runtime or test behavior changed.
2026-08-23 — v0.83.3 (the configuration reference splits into subtopic pages)
What changes: operate/configuration.md had grown to 2325 lines
covering every binary’s settings in one page; it is now a slimmed-down
overview — resolution order, key sources, cloud app-config sources, the
shared [health]/[observability] section, and a
What’s on each page index —
plus ten subtopic pages under operate/configuration/: sithbitd-core,
sithbitd-smtp, sithbitd-imap-pop-security, and sithbitd-spooler
(the daemon’s own settings, which were the bulk of the original page);
account-api (with sithbit-console, which shares its region);
domain-sithbit; ipfs-services (sithbit-ipfsd + [swarm] +
sithbit-gateway); mail-grpc; standalone-servers
(pop-server/imap-server/smtp-server); and env-files. Every setting
table and every cross-reference into the old single page — across the rest
of the book and this change history — moved with the section that
documents it; no setting’s default, meaning, or key spelling changed.
Who is affected: operators and integrators reading the docs; no runtime behavior changed.
2026-08-23 — v0.83.2 (mail-grpc and domain-sithbit get route indexes)
What changes: operate/mail-grpc.md and operate/domain-sithbit.md each
gain a Route index section, extending the
pattern account-api just adopted to the
other two services: every mail-grpc SolanaMail RPC (20 methods, not the
6 the page’s intro previously named — ListAliases, RefundMail,
ClaimBounty, RefundBounty, GetTransactionStatus, FindMessage,
GetMailDomain, ListAuthoritativeDomains, BrowseListings, ListSales,
ListParticipants, GetSenderAttestation, GetPinLease, and
GetSenderReputation were already served and undocumented at this level),
grouped by area with request/response type and one-line purpose; and every
domain-sithbit HTTP route, including GET /domain/{domain} — a DNS-only
verification-status check that needs no delegate key, previously undocumented
anywhere on the page.
Who is affected: operators and integrators reading the docs; no runtime behavior changed.
2026-08-23 — v0.83.1 (account-api gets a route index)
What changes: operate/account-api.md gains a
Route index section — every
account-api route, grouped by resource, with method + path + auth +
one-line purpose, linking into the page’s existing behavioral sections where
one exists. It replaces reading the crate’s lib.rs module doc comment (or
grepping the router) as the fast way to answer “does this route exist and
what does it need” without touching the Rust source; the crate’s own
lib.rs doc comment gained the three /v1/chain routes and the
POST /v1/mail/send route it had drifted out of sync with the router on
(browse_listings, sales, account, send_message — all already served,
none of it new behavior).
Who is affected: operators and integrators reading the docs; no runtime behavior changed.
2026-08-23 — v0.83.0 (the 2026-08 audit’s ready tier lands, with a bounded DMARC report decode)
What changes: the three dependency bumps the
2026-08 audit marked ready are taken —
jsonwebtoken 10→11, base64 0.22→0.23, and mail-auth 0.11→0.12. Two are
manifest-only. The third carries the one behavioral change, which is why this
is a MINOR rather than a PATCH entry: mail-auth 0.12 requires a max_size
bound on parse_rfc5322, so inbound DMARC aggregate reports are now decoded
under a 25 MiB decompressed ceiling.
That ceiling is a new enforcement default. Aggregate reports arrive as gzip/zip
attachments, so the wire size the SMTP SIZE limit already bounds says nothing
about the cost of expanding them; without a cap a small message could
decompress without limit. The value is deliberately the same 25 MiB as
[smtp] max_message_size,
keeping one size vocabulary instead of introducing a second. It carries no
knob: exceeding it is benign by construction, because ingestion is best-effort
— an unparsed report still delivers to the mailbox as ordinary mail, exactly
as a malformed one always has.
Who is affected: operators running [spooler.dmarc_rua_ingest], and only
those receiving aggregate reports above 25 MiB decompressed — such a report is
no longer parsed into the GET /v1/admin/dmarc-reports surface, though the
message itself is unaffected. Nobody else sees a change.
Where to read more: ingesting DMARC aggregate reports states the bound; the audit’s Tier 3 table records the remaining backlog and two corrections the bumps turned up.
2026-08-22 — v0.82.1 (a live, MSRV-aware dependency audit)
What changes: documentation and dependency hygiene only — no protocol, ABI, or behavioral change. A new Dependency audit — 2026-08 supersedes the 2026-07 audit, which now carries a pointer forward. The new pass reads “latest” from the live crates.io sparse index rather than the local index cache, covers all 121 workspace entries rather than only the major-version-relevant ones, and adds two dimensions the earlier audit had no coverage of.
The workspace is built by two toolchains nine Rust versions apart — the host
at rustc 1.98, and cargo build-sbf --tools-version v1.53 at rustc 1.89 — and
the workspace gate deliberately excludes the build-sbf legs. A dependency
raising its MSRV past 1.89 therefore breaks the on-chain programs while every
gate leg stays green. In this audit MSRV blocked nothing: the only packages
above 1.89 are the AWS SDK cluster at 1.94.1, none of which reaches the
bytecode.
What did block it was a wincode split. The solana crates are mid-migration
between two majors of that serialization-schema crate: solana-address,
solana-hash, solana-message, solana-signature, solana-transaction and
two others have moved to wincode 0.6, while
solana-transaction-status-client-types still requires wincode 0.5 — at its
newest release as well as its current one. A blanket cargo update resolves
cleanly and then fails to compile, because the two copies of the
wincode::SchemaWrite trait are different types. The solana stack must move
as one coordinated set, and today it cannot move at all.
Landed alongside the audit: its safe tier — 43 non-solana packages, lockfile
only, verified through both toolchains — and a fix for mail_client’s
ipfs-cid = "*", the last wildcard in a production dependency table, which
resolved safely only because the newer major happens to be unreachable through
a yanked transitive.
2026-08-22 — v0.82.0 (Lockbox: the plugins seal HTML and attachments, not just the body)
What changes: the Thunderbird and Outlook plugins now read the whole
composed message out of the host mail client and seal it as one unit — the
text body, the rich HTML body, and every attachment — where before they handed
the sealing envelope only the plaintext body. The envelope format itself is
unchanged: it has carried html and attachments since the engine landed, so
this release is the clients catching up to it, and messages sealed by earlier
plugin versions remain readable. Attachment bytes ride inside the envelope,
so the originals are detached from the outgoing message once the seal
succeeds, and the 12 MiB pre-seal cap now bites on body-plus-attachments
together. A plain-text message with no attachments still seals to exactly the
bytes it did before.
Two message shapes now refuse to seal, both failing closed — the message is sent unencrypted, exactly as typed, and the plugin says why; it is never sent partly sealed, and the send is never blocked:
- inline (embedded) images, which live in the body as
cid:references that neither host’s plugin API can enumerate — so they cannot be sealed, and rewriting the body around them would either destroy the image or leave it readable beside a sealed body; - cloud attachments and attached messages, which the host hands over as a link or an opaque item rather than as bytes.
Both are described, with the workaround for each, in Lockbox mail.
2026-08-20 — v0.81.0 (IMAP QUOTA: clients can read the storage cap)
What changes: the reference IMAP server now serves
RFC 9208 QUOTA — GETQUOTA and
GETQUOTAROOT over one per-wallet quota root (the conventional ""),
advertised as QUOTA plus QUOTA=RES-STORAGE after authentication. The
root exists exactly while
max_wallet_bytes
sets a cap; an unbounded account (the default, 0) reports no quota roots.
SETQUOTA parses but is always refused — quota limits are operator
configuration — and QUOTASET is never advertised. STORAGE reports the
enforced, per-blob-deduplicated stored-bytes meter (the very number
[OVERQUOTA] refusals are measured against) in 1024-octet units, usage
rounded up and the limit rounded down; the deliberate deviation from
RFC 9208’s per-copy sum, and the client-reconciliation caveat it implies,
are recorded in
the conformance appendix.
Enforcement itself is unchanged — this release makes the cap visible to
mail clients, it does not alter what is refused.
2026-08-19 — v0.80.0 (the oversized-body refusal becomes machine-readable)
What changes: the 422 Unprocessable Entity that account-api answers when
a request body crosses
the request-body ceiling
now carries the machine-readable token {"error":"body_too_large"} in place
of the prose {"error":"request body too large"}. The status, the ceiling
itself (2 MiB, axum’s own default named explicitly), and which requests are
accepted are all unchanged — only the body string moved, from a sentence to a
token a client can branch on, in the step_up_required mold. The old prose
was never documented as a contract; the token now is, byte-exact.
What does not move with it: every other 422 on the surface keeps its
per-route prose — the token belongs to the ceiling alone, so matching the
exact string is safe where matching the bare status is not. And 413 still
means over storage quota and only that (v0.79.0’s cap, item 64’s split), so
the client contract stays one line: 413 = free space, 422 body_too_large = send less.
2026-08-19 — v0.79.0 (an aggregate per-wallet storage cap, enforced on every delivery path but one)
What is new: max_wallet_bytes, a top-level setting on both
sithbitd
and account-api, capping the aggregate bytes one wallet may hold — every
mailbox it owns, with a blob counted once per wallet however many of that
wallet’s mailboxes reference it. 0, the default, is unbounded, and an
uncapped deployment issues no extra store read at all, so no existing
deployment changes behaviour or cost.
What a full wallet now sees. An SMTP delivery answers 452 4.2.2 Mailbox full, an IMAP APPEND answers NO [OVERQUOTA], and account-api’s compose
answers 413 Payload Too Large. All three are transient: a recipient who
prunes receives the mail on the sending server’s next retry, with no bounce in
between. Neither the configured limit nor the attempted total appears in any of
those replies — they reach the operator’s log and nothing else.
Bounce reports are exempt, and that is a decision rather than an oversight.
A delivery-status notification for a message the wallet itself sent is written
directly to its INBOX, never through the capped path, because the one report
that must survive a full mailbox is the one saying the mailbox is full. The
cost is a bounded trickle — a sender can push their own stored bytes slightly
past the ceiling by sending mail that bounces, bounded by their own outbound
volume, which [quota] already limits.
Why two config surfaces rather than one. POST /v1/mail/send builds its own
spool from account-api’s config, not from sithbitd’s, so a ceiling held only by
the mail servers is one any sender walks around by composing in webmail. The two
keys must be kept in step by the operator; no process reads the other’s file, so
nothing checks the agreement at startup.
The SMTP verdict answers for the whole envelope, as delivery verdicts always have: a message to five recipients, one of them full, is refused to all five and re-sent to all five.
2026-08-19 — v0.78.1 (a bad key-source kind value and a wrong field type are named)
What changes: nothing about which configurations load. A
key source
whose kind names no kind (kind = "avk"), and one whose field carries the
wrong type (path = 5), were already refused — but both failed while the table
was still being parsed, where the untagged form retried its other shapes and
reported only serde’s “data did not match any variant”. Both are now named:
kind = "avk" is not a known kind; expected one of "file", "akv", "asm", "gsm" and table field `path` must be a string, found integer. The list of
kinds quoted in the message is the same list that accepts a spelling, so a kind
can never be accepted without appearing in the error that rejects its
neighbours.
This completes what v0.77.0 and v0.78.0 began. Those two named a field belonging to a different kind and to no kind — both about the field’s name. This one covers its value and its type, which were the remaining ways to reach the opaque message.
Kind spellings are exact and case-sensitive, and are now fenced against
widening as well as narrowing: a matcher that grew laxer would let
kind = "AKV" reach a cloud secret manager without any configuration
breaking, so nothing would report it.
Still not named: a key that is neither a string nor a table (key = 5)
matches no shape at all and still reports the untagged message.
2026-08-19 — v0.78.0 (BREAKING: a key-source table refuses a key belonging to no kind)
What stops working: a key source
table carrying a key that no kind defines — a typo such as regoin for
region — now refuses to load, naming the key. Two shapes that
previously loaded clean are affected: a misspelled optional field,
which used to be discarded and leave the setting at its default, and a
stray key alongside an otherwise-valid table. A misspelled required
field already failed, but blamed the missing field rather than the typo
that caused it; it now names the typo.
This completes what v0.77.0
began. That entry made a table refuse a field belonging to a different
kind; this one makes it refuse a field belonging to no kind. The two
messages differ so an operator can tell the mistakes apart —
kind = "asm" does not accept `path` versus
table has an unknown field `regoin` .
Every documented shape parses exactly as before — bare string, kind-less
table, and all four explicit kinds — and no checked-in config, example, or
IaC profile in this repository carries an unknown key, so nothing in-tree
changes behavior. As with v0.77.0 the break is for out-of-tree deployments,
and the likeliest shape is a typo that has been silently doing nothing for
some time: the setting an operator believed they had configured was never
in effect, and the refusal is how they find out.
Still not named: a kind whose value is unknown (kind = "avk") and
a field of the right name but the wrong type (path = 5) are refused, but
report only serde’s “data did not match any variant”. Both fail while the
table is still being parsed, where the untagged form retries its other
shapes; naming them needs those fields widened and validated afterwards.
(Superseded by v0.78.1 above, which did exactly that — both are now
named. This paragraph is kept as the record of what was true at v0.78.0.)
2026-08-19 — v0.77.1 (the nonce budget’s table is described as its own)
The paragraph introducing
[nonce_rate_limit]
listed the ways the login-challenge budget matches [rate_limit] and
included “the same bounded fail-open table”. Read on its own that phrase
suggests the two budgets share one map. They do not: each limiter
builds its own table, and the sentence already said so a few words later
(“a separate budget: spending one never touches the other”). The
phrase now reads “a table of its own, bounded and fail-open the same
way”, so the sameness claim is about the design and cannot be misread
as a shared instance. Wording only — no behavior, defaults, or knobs
changed.
2026-08-19 — v0.77.0 (BREAKING: a key-source table refuses a field belonging to a different kind)
What stops working: a key source
table that mixes kinds — vault_uri under kind = "file", project
under kind = "asm" — now refuses to load, with an error naming the
offending field. That combination previously parsed: the chosen kind’s
fields were read and every foreign field was silently discarded, so a
half-finished migration between secret managers went on reading from the
source the operator believed they had left behind. No checked-in config,
example, or IaC profile in this repository sets a foreign field, so
nothing in-tree changes behavior; the break is for out-of-tree
deployments carrying a stale one. The likeliest shape is an environment
override rather than a TOML edit — {PREFIX}_..._KIND switched while the
previous kind’s ..._VAULT_URI or ..._SECRET_ID stayed exported, since
the env tier layers on top of the file.
A MINOR bump under the pre-launch rule: the break rides the MINOR digit, and the change is a new enforcement default affecting deployments.
This brings key sources level with the [store.blobs] table, which
already refused a foreign field by name; the two are now validated the
same way, each rejecting on the first foreign field in declaration order.
One narrower gap stays open and is documented where it bites: an
unknown key (a typo such as pth = …) is still ignored for key
sources, because the form is an untagged serde enum — any failure inside
a variant makes serde retry the next one and report only “data did not
match any variant”, which would destroy the field-naming diagnostics the
missing-field errors depend on. Foreign-field rejection therefore happens
after deserialization, where the field names are still known.
2026-08-18 — v0.76.1 (the MODSEQ-parentheses wait: upstream fix merged, release still pending)
No behavior change; the QRESYNC blocker’s status moved. The
RFC 7162 conformance section’s
documented wire deviation — imap-codec (pinned 2.0.0-alpha.9) encodes
the FETCH MODSEQ data item without RFC 7162 §7’s required parentheses
— now records that
imap-codec#723, the fix
for the tracking issue this project filed
(imap-codec#722),
merged into upstream main on 2026-08-18. Checked the same day:
crates.io’s newest published imap-codec version is still 2.0.0-alpha.9
(2026-07-19) — the fix has not shipped in a release yet, so the pin and
the deviation are unchanged. The RFC 7162 QRESYNC follow-up wave stays
queued; its unblock signal moves from “fix merged” to “fix released” —
see HANDOFF.md’s backlog entry for the wave.
2026-08-18 — v0.76.0 (BREAKING: proxy_protocol requires a proxy_trusted allowlist; gateway spend guardrails)
What stops working: a listener configured with proxy_protocol = true and
no proxy_trusted entries now refuses to start. That combination previously
ran, trusting every peer to assert any client address. Operators running PROXY
protocol must name the balancer’s CIDRs — or, to keep the old
trust-everyone behavior deliberately, list ["0.0.0.0/0", "::/0"]. No shipped
config, compose file, or IaC profile in this repository enables
proxy_protocol, so nothing in-tree changes behavior; the break is for
out-of-tree deployments that turned it on.
A MINOR bump under the pre-launch rule: the break rides the MINOR digit, and the security remediations below are default-behavior changes affecting deployments.
proxy_trustedis now required wheneverproxy_protocolis on. The allowlist is what keeps a reachable port from accepting a spoofed client address, and a spoofed address feeds DNSBL decisions, per-peer connection limits, SPF, the cross-connection login budget, and audit attribution alike. An empty allowlist now permits no peer rather than every peer, and the proxy-on/empty-list combination fails at server construction with an error naming the key and the wildcard escape hatch. The four shipped example configs show a populated allowlist, and Scaling out, Deployment and the glossary state the requirement.- Two new mail-grpc keys guard the
gateway’s wallet. The gRPC surface authenticates nobody by design —
reachability is the boundary
— so
allow_remote_bind(defaultfalse) makes widening that boundary explicit: a non-loopbackbind_addrnow fails startup naming the address and the key.max_bounty_lamports(default100000000, 0.1 SOL) caps aSendMailrequest’sbounty_lamports, which the gateway escrows from its own wallet and which was previously unbounded and caller-chosen; over-cap requests are refused withINVALID_ARGUMENTbefore reaching the chain. Everyiac/template sets the opt-in beside its0.0.0.0bind, so no shipped deployment changes behavior. The topology appendix gains the guardrails and hot-wallet funding guidance for the signing key. - A fee-payer balance floor stops a drained gateway from failing every
write the slow way.
fee_payer_floor_lamports(default10000000, 0.01 SOL) refuses the five write RPCs withUNAVAILABLEand reports/readyznot-ready once the signing wallet falls below it, so an unfunded gateway is one refusal and an alert rather than a queue of preflight failures. The balance is sampled on a background timer, so the check adds no per-request round trip, and a balance never read successfully permits writes. Monitoring gains thefee_payerreadiness flag. - Chain rejections and infrastructure trouble now reach callers as
different gRPC codes. A transaction the cluster or program rejected
answers
FAILED_PRECONDITION(resubmitting the same bytes can never land), while an exhausted poll budget, repeated blockhash expiry, or a drained fee payer answersUNAVAILABLE(an identical request may succeed later). Both previously arrived as an opaqueUNKNOWN, which left callers unable to tell doomed work from recoverable work. No proto change — the RPC signatures are untouched. - A rejected copy no longer burns eight retries to reach the same
answer. The chain worker reads the codes above: a rejection marks the
copy
chain_failedand buries the job on the first answer, while transient trouble retries as before (an unrecognized code stays transient, so an older gateway behaves exactly as it does today). Previously every failure — including a permanent one like a missing stamp — consumed all eight attempts, each up to five submissions and a minute of polling inside the gateway. - New metric
sithbit.chain.sendmailcounts submissions by outcome (sent/deduped/fatal/retry) — the volume signal behind the gateway’s wallet spend. See Monitoring. - The threat model states who pays for chain
writes. Stamps price the sender, but the transaction fee comes from
the operator’s gateway wallet, so cheap postage moves that cost onto the
operator. The new section names what bounds it and the two gaps left
open deliberately: the
postmaster_walletexemption enqueues chain jobs without a postage check, and there is no per-sender chain-submission budget. - Operators can now retire stored mail passwords. A new
enable_stored_passwords(defaulttrue, so nothing changes unless set) appears on account-api, wherefalserefuses to store a new password, and on the SMTP and POP listeners, wherefalsestops advertising CRAM-MD5. Setting all of them makes a deployment wallet-signature-only and therefore sealed at rest for every account — previously an operator had no way to decline the mode, and any account that set a password got readable server-side copies. Passwords already stored keep verifying until cleared withDELETE /v1/account/password; clearing and declaring the wallet-auth state stay allowed with the switch off, since both move accounts toward sealed storage. IMAP never offered CRAM-MD5 and is unaffected. The threat model gains the entry it was missing on what a stored password costs, and privacy states the trade in the reader’s terms.
2026-08-17 — v0.75.2 (operator setup moves out of the GUI-clients pages into the technical reference)
A PATCH bump: nothing behavioral moves. No on-chain ABI, instruction,
error-code, economic or default-behavior change, no config key added, removed
or renamed, and no command, route or [[static]] rule reworded. What moves is
which page states the operator’s half of the browser clients. The
GUI clients topic is written for the person using a
client, and four of its pages opened with a What the operator must run
section — account-api config, [chain] requirements, [[static]] mounts — that
a reader who was handed a URL has no use for and cannot act on. Those sections
are operator content sitting in an end-user topic.
- New page: Serving the browser clients. Under
Operating a SithBit Server → Go-live essentials, beside the
configuration reference and
DNS setup. It collects, per client, what account-api must
have configured, and the
[[static]]mounts andbuild.shinvocations for the two bundles an operator actually serves — the webmail app and the standalone marketplace page. The[[static]]array-of-tables rule, which the three server-hosted pages each restated in their own words, is now stated once and points at the[[static]]list for the refusals. - The four What the operator must run sections are gone from the client pages, replaced by what a reader needs to know: which panes go unavailable when their operator has not configured the chain surface, and an operator-addressed pointer to the new page. Webmail’s Building and serving and the marketplace’s Building and serving the standalone page moved wholesale — there is no end-user in either, since both bundles are served, not installed.
- The three extensions keep their build-and-install sections, because with
no store listing published yet (Chrome,
Outlook, Thunderbird) building
the package is the only way a user installs one today. They are reworded as
the install route they are rather than left reading as deployment steps. The
one operator-only fragment among them, Outlook’s
[[static]]mount for the taskpane origin, moved to the new page. - Nothing was deleted. Every moved paragraph is on the new page; the only
prose that changed is the framing sentences around it and the three
[[static]]restatements collapsed into one. - The pin lifecycle caveat stays where it is — it already announces its audience in its own heading, and it is an argument about a client’s behavior that a reader of the trustless page needs in place. The new page links to it instead.
2026-08-17 — v0.75.1 (a throttled login challenge is no longer told it made too many account changes)
A PATCH bump: no on-chain ABI, instruction, error-code, economic or
default-behavior change, no config key moves, and no budget switched, sized
or keyed differently. What moves is the sentence inside one refusal body —
the machine-readable half of that response, the 429 status and the
Retry-After header, is byte-identical to before, so a client that reads a
429 the way these pages have always told it to notices nothing. It corrects
the one thing
v0.75.0
left reading oddly: the new per-pubkey budget reused the mutation budget’s
refusal verbatim, so a caller who had asked for nothing but a login challenge
was told it had made too many account changes. That entry stands as the
record of what v0.75.0 shipped; this one states the change.
POST /v1/auth/noncewords its own 429. Over-budget challenge issuance now answers{"error": "too many login challenges; retry in N seconds"}. The five step-up-gated mutations still answer{"error": "too many account changes; retry in N seconds"}, byte for byte, so nothing that matches the mutation string moves. Each limiter now names the budget it enforces and stamps that identity into every refusal it hands back, which is what lets the prose name what the caller actually spent: the two are wordings of one shape rather than one shared string, and everything else about them is unchanged —429,Retry-Afterin whole seconds floored at1, and a fixed window per budget, each spent without touching the other. The[nonce_rate_limit]section no longer claims the two share a rendering, and the refusal table splits its shared row in two — one budget per row, both carrying the header. That split is why the table’s heading is now Which refusals carryRetry-After, and which cannot: the count came out of the heading deliberately, an ordinal being the one thing a heading cannot keep once a row is split. The advice both pages now give a client author is the durable form of all this — key off a 429’s status and its header, never off its prose.
2026-08-17 — v0.75.0 (login-challenge issuance is rate-limited per pubkey, and the daemon sweeps expired challenges hourly)
A MINOR bump on the count v0.10.0 widened the digit to cover: two default-behavior changes that affect every deployment — a rate limit that ships on for a public route, and a new unconditional background deleter. No on-chain ABI, instruction, error-code or economic change. Both changes close the residue v0.74.0 left stated in the open — the challenge row written unauthenticated, on an un-rate-limited route, with no expiry sweep — on its second and third counts; the row itself is still written unauthenticated, which is what a login challenge is.
- New config section, on by default:
[nonce_rate_limit].POST /v1/auth/noncenow answers to a budget of its own — the same knobs, defaults (30 per 300-second fixed window) and semantics as the account API’s[rate_limit], but keyed on the ed25519 pubkey in the request body, since the route is unauthenticated and names no wallet. It is charged after the pubkey parses (a malformed request never spends a slot) and before the store is touched (a refused caller never writes a row). The refusal is the mutation budget’s exact rendering —429withRetry-Afterin whole seconds, and the shared body prosetoo many account changes; retry in N seconds, deliberately reused rather than forked even though no account change was asked for. Two honest boundaries ride the section’s docs: the counters are per-replica in memory (two replicas grant a pubkey two budgets), and the budget caps per-key hammering only — each fresh pubkey arrives with a fresh budget, so the challenge-row residue’s distinct-keys bound is not tightened. The step-up challenge (POST /v1/auth/step-up) stays deliberately unlimited: it demands a valid token and can only clobber its holder’s own slot. See[nonce_rate_limit]and Which refusals carryRetry-After. - Every deployment running
sithbitd: expired login challenges are now deleted hourly. The daemon grows an unconditional nonce-prune worker — no config knob, on purpose: each challenge row carries its own ~300-second expiry set at issuance, so an expired one is garbage by definition and there is no retention policy to configure, unlikedead_retention_days’ buried jobs an operator may want to inspect. It runs even with[spooler] enabled = false, because a listeners-only instance shares the store the rows live in. The one posture unchanged: a deployment running the account API with no daemon over its store still retains expired challenges between logins — the API deletes a challenge only when it is consumed. Noted with the daemon’s other prunes under[spooler], and in the residue paragraph of When the account row is created, which no longer claims the route is un-rate-limited or the rows retained unconditionally — both halves of that sentence went false this release.
2026-08-17 — v0.74.0 (asking for a login challenge no longer creates an account, and an IMAP or POP login can be made to require an on-chain mailbox first)
A MINOR bump on both counts the versioning preamble names: a default-behavior change that affects every deployment, and a significant additive capability. No on-chain ABI, instruction, error-code or economic change. Both halves close one finding — a SithBit login is self-proving, so the credential proves a keypair rather than an account, and until now anyone who could generate a keypair could make the servers write storage for the address it names.
- Every deployment, whatever it configures:
POST /v1/auth/nonceno longer creates the account row. The account API used to write the row while handing out the login challenge — before any signature had been looked at — so an unauthenticated caller minted an account row for any address merely by asking for a challenge. Provisioning now runs insidePOST /v1/auth/token, the moment the signature verifies and ahead of the JWT; a store failure is a500with no session handed out rather than a token for an account that was never written. No operator action, no migration, and nothing an honest client can notice — first-use provisioning moved, it did not go away — but the observable effect is real: a run of challenge requests for addresses nobody holds a key for now leaves no accounts behind. See When the account row is created. The residue is stated there too, because it is still open: the challenge row itself is still written unauthenticated, on an un-rate-limited route, with no expiry sweep. - New switch, off by default:
login_requires_mailbox. With it set,sithbitdadmits an IMAP or POP session only for a wallet that owns an on-chain mailbox, asked of the chain gateway before the session-open writes anything — the INBOX row on IMAP, the maildrop lease and the INBOX row on POP. It is a top-level key rather than one under[imap]or[pop], so the two protocols cannot end up with different postures, and it shipsfalse: nothing about an existing deployment changes until an operator turns it on. Seelogin_requires_mailbox— the on-chain mailbox login gate. - What a refused login is told, and what an outage is told instead. The
refusal is one text on both protocols, byte for byte —
no on-chain mailbox for this account; create one before logging in— underNO [AUTHORIZATIONFAILED]on IMAP and-ERR [SYS/PERM]on POP. Both codes say authorization, not authentication, because the credential proved out and sending the holder back to re-enter a working password would be a lie; the remedy issithbit mailbox create. A gateway or chain outage is told something else entirely — an IMAP temporary authentication failure, a POP[SYS/TEMP]— since a passing outage must never lock a real account out. - The trap to read before setting it.
login_requires_mailbox = trueon a daemon with no[grpc]section has no gateway to ask, and the gate then admits everyone. The daemon warns loudly at boot and starts anyway: refusing to boot would break the zero-config dev stack, which is a promise the whole configuration layering rests on. Treat that warning as “the gate is off”. - What the gate does not reach. SMTP submission authentication is not gated — it creates no account storage of its own — and neither is the account API’s wallet-challenge login, a separate binary with no switch of its own. That surface answers the same finding the other way, in the first bullet above.
- The example file shows the new key at its default.
sithbitd.example.tomlcarries# login_requires_mailbox = falsecommented out with the rest, above the first section header — where a top-level key has to be written, or uncommenting it in place would file it under whichever section came before.
2026-08-17 — v0.73.20 (the scaling page stops naming store-backend gate variables, and the book’s last restated roster family goes to its owner)
No on-chain ABI, instruction, error-code, economic or behavioral change — a
PATCH bump, v0.73.19’s class one day later. The version rolls because the docs
moved again, not because anything about the protocol did. It closes the move
v0.73.18
opened and
v0.73.19
continued: of the three SITHBIT_TEST_* gate-variable families (key sources,
cloud app-config, store backends), the store-backend family was the last one
this book still restated anywhere, and it now defers to its owner the same
way the other two do.
- What already just works and
Which stores support which
split no longer spell out
store-backend gate variables. The scaling page held the family’s two
remaining mentions: the POP lease provenance caveat waved at the roster
with a bare
SITHBIT_TEST_*glob, and the postgres role-split walk namedSITHBIT_TEST_POSTGRES_URLin full. Both now send the reader tomail_store/README.md’s Tests section, written as a repository path rather than a link with the “(outside this book)” note, the way v0.73.18 wrotekey_source/README.mdand v0.73.19 wroteapp_config/README.md— and this owner is the strongest of the three: the crate README spells all nine names out and is pinned name-for-name bymail_store/src/readme_pin.rs, a scan that lives outside every cargo feature gate precisely so a backend-slim build cannot compile the fence away. The glob went too, not just the spelled-out name, matching the zero the precedent pages kept. The semantics stay: the caveat still says the conformance suite runs in every build on SQLite, Turso and D1 while the DynamoDB, Azure Tables and Postgres implementations wait on configured test endpoints, and the role-split walk is still gated on a postgres endpoint booting from the checker-tracked[store] kind = "postgres"citation, which is untouched byte for byte. The book’s only remainingSITHBIT_TEST_spellings are this page’s own historical entries, which record moves rather than restating a roster.
2026-08-16 — v0.73.19 (the cloud app-config section stops restating its live probes’ environment variables too, and the book’s last unfenced copy of a gate-variable roster goes)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.18’s class and v0.73.18’s date. The version rolls because the docs moved again, not because anything about the protocol did. It finishes, one paragraph further down the same page, the move v0.73.18 made for the key sources — and it is the case v0.73.18 examined and declined, reopened on the argument that a copy nothing fences is worth retiring before it rots rather than after.
- Cloud app-config sources
no longer names the
#[ignore]d live probes’ environment variables. Unlike the key-source copy, this one had not drifted: all eight names it spelled out still agreed withapp_config/README.mdname for name on the day it was replaced, which is exactly why v0.73.18 left it standing. What moves it anyway is that nothing was holding it there:app_config’s own guard — the testreadme_documents_live_test_gate_vars, which scans the crate’s sources forSITHBIT_TEST_*literals and fails if any is missing from the crate README — sees the crate’s two copies and is structurally blind to a third inside this book, which is precisely the mechanism that had already cost the key-source paragraph two names before v0.73.18 found them missing. Agreeing today is a fact about today, not a fence. The prose keeps what a pointer cannot say — that the mapping logic (bootstrap parsing, TOML merge, key nesting) is unit-tested against injected fake fetches, so CI covers it without credentials; that the AWS probe can be aimed at an emulator instead of the real service; and that the AWS round trip needs ambient AWS credentials while the Azure one needs an ambient managed identity — and sends the roster itself toapp_config/README.md’s Tests section, written as a repository path rather than a link, the way v0.73.18 wrotekey_source/README.md. A checker pinning this page to the crate was declined here for the same reason it was there: fencing a third copy keeps three copies, and one owner is the cheaper shape. v0.73.18’s closing note that this paragraph was deliberately left alone records the book as it stood that day, not as it stands now.
2026-08-16 — v0.73.18 (the key-source section stops restating the live probes’ environment variables and defers to the crate that owns the roster)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.17’s class and v0.73.17’s date. The version rolls because the docs moved again, not because anything about the protocol did. It is the same move as v0.73.16’s — a page that had been restating an enumeration hands it to the place that owns it — made for the first time toward an owner that lives outside this book.
- Key sources
no longer names the
#[ignore]d live probes’ environment variables. The closing paragraph spelled six of them out, and it had already drifted:key_source/README.mddocuments eight, the two missing here being the optional Key Vault secret name (which defaults totest-secret) and the optional GSM secret version (which defaults to the API’slatest) — both omitted, not renamed, so a reader following this page alone could not tell a probe to read anything but the default. That made this the third copy of one roster, the crate’s tests holding the first and its README the second, and only those two are fenced against each other: a test inkey_sourcescans the crate’s sources for gate-variable literals and fails if any is missing from the README, which is exactly why the README is the copy that cannot fall behind and this page was the copy that had. The prose now keeps only what a pointer cannot say — that there is no local Key Vault emulator, so the AKV round trip is only ever an#[ignore]d probe against a real vault; that an ASM probe can aim at LocalStack instead; and that per-kind dispatch is unit-tested against a fake fetcher, so CI covers the dispatch while a real cloud covers the round trip — and sends the roster itself tokey_source/README.md’s Tests section, written as a repository path rather than a link, the wayiac/README.mdandwebclients/README.mdare named elsewhere in the book. A checker pinning this page to the crate was considered and declined: fencing the third copy keeps three copies, and one owner is the cheaper shape. The cloud app-config paragraph a screen below still spells its ownSITHBIT_TEST_AWSAPPCONFIG_*/_AZAPPCONFIG_*variables out and was deliberately left alone — it matchesapp_config/README.mdname for name, so there is nothing rotted there to fix.
2026-08-16 — v0.73.17 (the icon legend’s frozen table loses the method line beneath it, and the sweep that table scoped reads as finished)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.16’s class and v0.73.16’s date. The date does not roll and the version does: the docs moved a second time today, and nothing about the protocol moved at all. Both edits are the same page settling after v0.73.16 dated its term-frequency table — prose that the dated paragraph had made redundant, and a lead-in still written as though the work that table scoped were ahead of the reader rather than behind them.
- The icon legend’s frozen
term-frequency table no longer carries a method line beneath it. The
sentence read “These counts are a point-in-time snapshot of the term
distribution that justified the icon set, not a live tally — adding pages
naturally shifts them — and each was measured with
grep -rlwi <term> mail_docs/src --include=*.md”, and every proposition in it is already made, and made better, by the dated-provenance paragraph standing above the table — which additionally names the day (2026-07-12) and the docs version (v0.2.1) this line never did. What the line added over that paragraph was the command, and a command printed under a table that is frozen on purpose promises a reproducibility no commit delivers: replaying it against the commit that introduced the table reproduces seven of the fourteen rows and not the other seven, twenty-five consecutive commits were scanned without one matching all fourteen, and the mismatches run in both directions — so it is not a denominator effect that a corrected command would fix. The honest record of a figure nothing reproduces is its date, which the paragraph above already carries. The sentence went whole rather than being trimmed to its still-accurate half: a fragment would have left a weaker restatement of the paragraph above sitting under the table, which is the same-page duplication v0.73.16 spent two entries removing. Not one row of the table moved, and none was re-measured. - The same page’s lead-in stops calling a finished sweep “later”. The sentence under which the table sits said the count “also scopes the later book-wide application sweep — the higher the count, the more pages the sweep touches”, and that sweep finished when the icon set landed, on 2026-07-12; the present tense read as though a book-wide edit were still pending. It now reads retrospectively — the count “also scoped the book-wide application sweep that followed — the higher the count, the more pages that sweep touched”. The argument is unchanged: the table’s ranking is still what sized that sweep, and saying so in the past tense is the only form of it that is true.
- The IPFS note’s “the same command” now resolves to the command that can
produce the figure it cites. This one is a consequence of the deletion above
rather than an edit of its own, and it fixed a defect nobody had filed. The
note says IPFS was measured “with the same command”, and until the method line
went away that line was its nearest antecedent — a
grepcarrying no--exclude=SUMMARY.md, which counts the table of contents as a content page and so structurally cannot yield the 106 the very same sentence asserts. The intended antecedent was always the campaign and beacon command written out in full by v0.73.15, which does excludeSUMMARY.mdand states 106 itself. With the method line gone that is the only command on the page, so the reference has one thing it can mean. The two paragraphs are joined by that: pruning the campaign and beacon method would now strand the IPFS note with no antecedent at all.
2026-08-16 — v0.73.16 (documentation-only: pages say where their figures and lists come from)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.15’s class and v0.73.15’s date. The version rolls because the docs moved again, not because anything about the protocol did. The entries below are of one kind: something a page had been stating on its own authority now names where it came from instead — a date and a docs version for a measurement, the page that owns an enumeration for a list.
- The icon legend’s term-frequency table is dated now, and says why it is frozen. The lead-in described the table as counting distinct Markdown pages “out of the 66 content pages measured when the set was chosen” — a phrase that names no day and no version, so a reader had no way to tell how old the figures are and read them as a count of the book in front of them, which has since grown well past that denominator. Not one figure moved, and none was re-measured: the table is a deliberate snapshot, and v0.73.13 already recorded why refreshing a row of it would be worse than leaving it alone — a table mixing two denominators states nothing. What the page gains is the provenance: those figures landed with the page on 2026-07-12, at docs v0.2.1, against the book as it stood that day, and no row has moved since. The contrast with the campaign and beacon figures further down is deliberate — those carry a date and a version because they are measured against the book of their own day, and that is the form to copy when a count has to be current.
- The privacy page’s send-metadata bullet defers the
Emailaccount’s field list to the reference row that owns it — and the reference page stops restating that row one paragraph below it. What a send writes was enumerated in three places: the exposure bullet under On-chain: public and permanent, the “what a send leaks on-chain” paragraph in the field reference, and theMessage (Email)row a screen above that paragraph. As with the beacon lists v0.73.15 reconciled, the copies had already drifted: the privacy bullet itemized the sender wallet but left the recipient to trailing prose (“which wallet mailed which wallet”), the appendix paragraph itemized both, and neither namedbounty_lamports,expires_atorreply_to_hash, which the row has carried all along. The privacy bullet now names the account’s key (the recipient wallet), the sender wallet, the hashedfromand the timestamp — the four facts its address book, not the message argument rests on — and hands the rest to the row by link, down to the body’s storage locator. The appendix paragraph keeps only what the row structurally cannot say: that the recipient wallet is the PDA seed rather than a stored field, and what the frombox instructions andReclaimFromboxStampscarry in their payloads, which is instruction leakage rather than account state and belongs to no row. The one fact that paragraph held alone — thatcidtakes itsb3:local-only form when the recipient setno_ipfs— moved into the row’s notes rather than being dropped. Deferring rather than deleting, again: neither site is reduced to a bare cross-reference, and a field added to theEmailaccount now has exactly one prose place to be added.
2026-08-16 — v0.73.15 (the icon legend’s campaign and beacon figures become a dated measurement instead of a current fact, and the privacy page’s two beacon field lists defer to the reference row that owns them)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.14’s class and v0.73.14’s date. The version rolls because the docs moved again, not because anything about the protocol did.
- The icon legend’s campaign and
beacon figures are dated now rather than current. The paragraph read
“measured with the same command against the book’s current 106 Markdown
pages”, and a count stated as current stops being true the moment a page
lands — nothing re-measures it, so the word promises what the page cannot
keep. It now reads as the measurement it is: taken on 2026-08-16 at v0.73.15,
campaign on 13 content pages and beacon on 10 out of the 106 the book then
held. The figures themselves did not move — 106 was right, and re-measuring
found the same 13 and 10 — so what changed is that a reader can now tell when
they were taken and check them, the
grep -rlwiandfindcommands being written out in full rather than referred to as “the same command”. Both excludeSUMMARY.md, which is the content-page convention v0.73.13 fixed the IPFS note onto and the one every count on the page is now stated in. The-wgets a sentence of its own, because whole-word matching is what makes the numbers mean what they say — beacon does not match beacons, POP does not match POP3. The argument the paragraph exists to make is why it is dated rather than a casualty of the dating: campaign and beacon carry icons because marking them wherever they appear was judged worth it, which is a call and not a count, and a count that drifts unannounced is the weaker half of that sentence pretending to be the stronger one. - The privacy page’s two
ParticipantBeaconfield lists defer to the field reference instead of repeating it. The beacon account’s fields were enumerated in three places — the exposure bullet under On-chain: public and permanent, the closing digest under The honest limitations that v0.73.14 added, and the reference row that is meant to be the authoritative inventory. Three copies is three things to keep in step, and they were already out of step: the digest named the wallet, the tags and the publish/change times but not the CID of the attached profile, so it read as though nothing about that profile was exposed, where the reference row says its existence and update times are public and only its content is sealed. Both privacy-page sites now name your wallet and the topic tags you picked — enough concreteness for the argument each is making to land on a reader — and hand the full enumeration to the reference row by link, so there is one place a field can be added to. The exposure bullet keeps the timestamps as a class rather than a list (“down to the timestamps”) because when you last touched it is part of what the bullet is warning about; the digest, being a one-line-claim list, keeps only the two named fields. Deferring rather than deleting is the point: neither site is reduced to a bare cross-reference, and neither now claims to be complete. The reference row itself is unchanged.
2026-08-16 — v0.73.14 (the icon legend’s opening paragraph stops stating the frequency-only rule the page itself retired, and the privacy page’s summary list catches up with the exposure list above it)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.13’s class one day past its date. What moves is prose the book had already outgrown, brought back into step with the pages carrying it.
- The icon legend’s lead-in caught up with its own admission bar. v0.73.13 replaced the page’s frequency-only rule with two routes — a term recurs widely enough across the book to be worth marking, or admitting it was a deliberate editorial call — but everything it touched sat under the Why these terms heading. The opening paragraph, which is the sentence a reader scanning the page meets first, still said the set “covers only the handful of terms that recur across the whole book”, so the page shipped the retired rule and its replacement at once. The lead-in now names both routes and points down at the section holding the frequency ranking and the record of the calls; that section, its heading and the table are untouched.
- The privacy page’s summary list caught up with the exposure list above it. v0.73.13 added marketplace opt-in to On-chain: public and permanent and stopped there, so the page’s closing digest — the three-item list a reader who skims takes away — named the social graph, public ciphertext and marketplace purchases while staying silent about the one exposure a reader deliberately opts into. It now carries a fourth item: publishing a beacon puts your wallet, its topic tags, and the times you published or changed it in the on-chain campaign pool, while the attached profile stays sealed. The claim is the one the list above already makes, shortened to the digest’s one-line-claim voice rather than restated differently. A fourth item rather than a widening of the purchases item, because opting in is not a purchase and the two sit as separate bullets in the list above; the paragraph beneath the list still reads correctly, since a public campaign pool is as much a cost of settling on a public chain as the other three.
2026-08-15 — v0.73.13 (opting in to campaigns joins the list of what is public, the icon legend stops asserting a rule its own table breaks, and a term icon’s four artefacts are checked against each other)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.12’s class and v0.73.12’s date. Three changes of that one class landed together, so they share this section instead of taking three of their own: one book page gains a bullet it was missing, one stops stating a rule it does not follow, and one docs-gate checker learns to notice a term icon that shipped incomplete.
- Opting in to campaigns belongs on the list of what is public.
On-chain: public and permanent
named wallets and their settings, send metadata, money, and marketplace
purchases — but not marketplace opt-in, which is the one item in that list
a reader takes a deliberate decision to do. Publishing a
beacon, the page v0.73.12 wrote for
exactly that reader, puts your wallet in the campaign pool on-chain together
with the topic tags you picked, the CID of any encrypted profile you attach,
and when you published or last changed it. The profile’s contents stay
sealed; the fact that you have one does not. Each of those claims is read off
the
ParticipantBeaconrow of the privacy field reference — the account’s own field list — rather than paraphrased from the bullets around it. - The icon legend stops asserting an admission rule its own table breaks. “Terms that recur on a third or more of the pages carry an icon”, with the client icons as “the one deliberate exception”, was false against the table printed six lines beneath it: a third of 66 pages is 22, and the table’s own bottom rows are POP and IMAP at 19, SOL at 17 and daemon at 12 — all carrying icons. No adjusted threshold rescues it either: campaign and beacon post-date the table entirely, and re-measured against the book’s current 106 pages they appear on 13 and 10 of them. The bar is now a route rather than a number: a term carries an icon because it recurs widely enough to be worth marking, or because admitting it was a deliberate editorial call, with the paragraphs beneath as the record of those calls — the four client icons becoming the largest of them rather than the sole exception, and campaign and beacon joining as calls and not counts. No rationale is invented for the two new ones: the book holds no frequency argument for either, so none is made. The table itself is unchanged and still the ranking that fixed the core of the set; what moved is the claim about what it proves.
- The IPFS note’s page count stopped mixing two conventions. It read “48 of
those 106 pages (45%)”, and the two numbers were counted differently — the 48
included
SUMMARY.md, which is a table of contents rather than a content page and which the 106 excludes. Measured both ways on the same convention it is 47 of 106 (44%), which is the figure the note now carries. The conclusion it supports — IPFS clearing the one-third mark on frequency alone — is unaffected; a percentage assembled from two denominators just states nothing, whichever way it rounds. - A term icon is four artefacts, and nothing checked that a term had all
four. Shipping one means a
.ticon-<term>rule incss/icons.css, aTARGETentry injs/topic-icons.jsfor the chapter the glyph links to, a row in the legend, and thesrc/images/icon-<term>.svgthe CSS mask points at — and a term holding three of the four rendered as an inert or unexplained glyph with the whole gate green.check_topic_icons.pygained a third pass comparing those four sets for agreement; all four measure twenty-two today with every pairwise difference empty, so the fence landed green rather than with a backlog to work off. Building its fixtures found a hole rather than confirming one: a.ticon-rule commented out with a CSS block comment — what an edit in progress actually looks like — was counted by the raw scan as a defined term, a false member of the set, which is the worse direction for a checker to be wrong in. Block comments are now blanked first, the stylesheet twin of the line-comment blanking the JS side already did. Each of the three sets read off disk also gained a floor: one that parses to nothing is the scan reported broken (exit 2), not twenty-two findings pointing at the wrong file. The fixture trees go from seven to twelve, the gate README’s description of the checker moved with the behavior, and the gate stays at nineteen legs.
2026-08-15 — v0.73.12 (beacons are defined in plain language before the docs use the word, and the term gets a glyph)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.11’s class and date: the participant beacon itself shipped at v0.9.0 and the browser pane that publishes, disables and closes one at v0.49.0, so nothing about the product moved here. One new book page, the links that reach it, and one term icon.
- Beacons, written for the person deciding whether to opt in. The Marketplace topic explained campaigns but never the thing you publish to join one: “beacon” arrived mid-sentence with nothing having defined it, and the fuller tellings were the CLI reference’s flags and the design note’s rationale — neither written for a reader weighing the decision. The new page answers what that reader actually asks: what a beacon says (coarse labels off a shared list, and optionally the address of an encrypted profile — there is no free-text box to over-share in), that a wallet has at most one and publishing replaces it wholesale rather than amending it, how an advertiser’s search matches (every topic named, so naming more narrows the result and never widens it), what is permanently public versus what stays sealed, and the difference between disable — a browser-side republish with no topics, which keeps the account and its deposit — and close, which refunds the deposit and leaves nothing behind. No CLI flags and no account layout: it links out for both.
- The price sentence is read off the account, not paraphrased from the
neighbouring prose.
ParticipantBeaconcarries tags, a detail CID, its two timestamps and its owner, and no price field at all — so the page can say outright that opting in adds no second price to keep in step: what an advertiser pays is the mailbox’s own default postage. - Reached from where the word is first read, not only from the sidebar. The
SUMMARY.mdbullet sits between Trading names and Campaigns so the concept precedes its use; the first “beacon” in both Campaigns and the marketplace pane’s Participants tab now links it; and the Marketplace hub’s “Where to go next” gained its row — a new page its own hub does not list is the defect the page exists to fix. - A
beaconterm icon joins the set, taking it to twenty-two terms. The glyph is a lighthouse rather than a second megaphone besidecampaign: a beacon is a standing sign left lit to be found by, not an outbound blast. It ships the whole four artefacts the system needs — the legend row, the CSS mask rule, the SVG the mask points at, and the link target the in-text glyph resolves to, which is the concept page rather than the CLI reference.
2026-08-15 — v0.73.11 (the Welcome page rewritten around the fight for your attention)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump. One page of book prose moved; no screenshots or reference tables changed.
- Welcome hero and feature cards rewritten. The hero tagline is now “Your inbox has never known peace. Now the fight pays you.”, and the seven cards were reframed as a single progression — the quiet inbox was always a lie; price your attention; get paid for your inbox; end spam at the source; an address nobody can take from you; a protocol, not a product; no conversion required — with parallel “Through X, Y” openings carrying the argument from recipient-set pricing through to a network nobody owns. The features described are unchanged; only the framing and copy moved.
2026-08-15 — v0.73.10 (the settings frame carried the same clipped-heading sliver)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump. One committed screenshot and the rig README’s geometry tables moved; no book prose changed.
webmail-settings.pnghad v0.73.9’s defect too, found by scanning every committed frame’s bottom row for ink. The uncropped 1280x900 frame ended on the top half of the same stray “Marketplace” heading, visible above Sealed rows and your reading key. Now cropped1280x856+0+0: the teased Mailbox pane heading’s last inked row is raw 841 and the sliver’s first is raw 885, so the crop ends at raw 855 — 14 blank rows below the Mailbox heading, 29 clear of the sliver. The two frames that also touch their bottom row (webmail-inbox,marketplace-listings) are not this defect — their fold cuts a continuing list/form mid-row, the intended “more below” look — and the rig README now records that distinction beside the crop recipes.
2026-08-15 — v0.73.9 (the balances frame’s clipped “Marketplace” sliver is cropped away)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump. One committed screenshot and the rig README’s geometry tables moved; no book prose changed.
webmail-balances.pngno longer ends mid-heading. The frame is shot at the rig’s one deliberate 1280x1000 viewport exception and was committed uncropped, but the viewport’s bottom “air” held the top half of the app’s next section heading (“Marketplace”) — which read as half-cut letters at the image’s bottom edge on the webmail Balances section. The committed frame is now cropped1280x971+0+0, measured the way the two password frames’ heights were: the Set price button’s last inked row is raw 956 and the stray heading’s first is raw 985, so the crop ends at raw 970 — 14 blank rows below the button, 14 clear of the heading. The raw capture is unchanged (same viewport, same driver); re-shoots reproduce the crop per the geometry table inscreenshot-tools/README.md, whose both tables and content-derived-heights note now carry this frame.
2026-08-15 — v0.73.8 (the marketplace pane rewire is pixel-neutral; two hashes re-pinned)
No on-chain ABI, instruction, error-code, economic or behavioral change, and no
book page or screenshot changed a byte — a PATCH bump, and the docs’ fifth
movement of the day. Only screenshots.manifest.json moved.
- A webclients-only checkpoint reddened the docs gate without touching
mail_docs/.check_screenshots.pypins each client to a hash of its UI source, and the my-beacon pane’s move onto themail_wasmbeacon exports editedshared/marketplace-panes.js— a file inside both the webmail and the marketplace entry-point closures. The screenshots had been captured and pinned earlier the same day, before that edit landed, so twosource_hashvalues went stale with no rendered pixel involved. - All eight affected frames were measured, not assumed. Against a fresh
capture at each frame’s documented crop:
webmail-first-run,-settings,-remove-password,-rotate-password,-balancesandmarketplace-sign-inreproduce at AE 0, andwebmail-inboxat 12, its documented bistability floor. A same-rig A/B — one bundle built from the current tree, one with the pre-changemarketplace-panes.jsswapped into all three app bundles, captured on one Chrome — returns AE 0 on every frame (inbox 12). The pane change is visually neutral, so nothing was re-shot and every committed PNG keeps its bytes. marketplace-listings’s large number is the rasterizer, and the A/B says so. It reads AE 113441 against a fresh capture, but 0 across the A/B. Its committed frame carries 25194 colour-fringed pixels of 840960 against a fresh capture’s 2352 — the LCD-subpixel era it was last shot in (2026-07-23), which the screenshot rig’s README documents and which is deliberately not a reason to re-shoot. The differing pixels span every text-bearing band of the page rather than any one pane, and the content is identical listing for listing.- Hash-only re-pin.
webmailandmarketplacewere re-pinned withcheck_screenshots.py --update;onboarding’s hash is unmoved, since itssourceslist does not reachmarketplace-panes.js. Nosourcesorscreenshotsvalue moved, and no PNG was re-baselined.
2026-08-15 — v0.73.7 (the stored-password removal control gets its screenshot)
No on-chain ABI, instruction, error-code, economic or behavioral change, and no client or server code changed a byte — a PATCH bump, v0.73.6’s class and v0.73.6’s date: the docs’ fourth movement of the day, so it takes a section of its own. One new book screenshot, the prose reference that places it, and the capture-rig changes that make it reproducible.
- Removing the stored mail password
now shows the armed control. The section has described the two-step gesture
in prose since v0.59.0 gave the control to the Settings pane, while its
sibling rotation
section has carried a picture of exactly the equivalent moment since v0.58.0.
The new
webmail-remove-password.pngcloses that asymmetry with the state a reader has to recognise before pressing anything: the heading, the what-you-are-trading paragraph, the armed warning, and the Remove it now / Keep my stored password pair. The removal is armed but never clicked — the resulting frame would document the outcome, and hide the very block it is about, instead of the decision. - Making the frame reproducible needed a fixture flip, and that flip is a
capture input rather than cosmetics.
screenshot-tools/fixtures.mjsnow answersmail_password_set: true, because the whole removal block renders only for an account that actually has a stored password: atfalsethe block does not exist and the frame cannot be shot. In its resting state the flip inserts a heading, a paragraph and a button into the settings pane above the wallet-derived section, so every frame shot on that route can move with it — which is why the fixture file now carries a comment saying exactly that. - Exactly one neighbouring frame moved, and it was measured rather than
assumed.
webmail-rotate-password.pngsits below the insertion and came back rasterized one device pixel higher — content byte-identical, every differing band reaching an exact zero difference at a 1px shift, because the driver’s scroll now lands on a fractional offset that rounds the other way. It was re-baselined; its geometry is unchanged. Every other frame (webmail-settings,-first-run,-inbox,-balances) reproduces unchanged, andmarketplace-listingsstays excluded for the pre-existing Chrome grayscale-AA drift the screenshot rig’s own README documents — an appearance change no content change caused, which is not a reason to re-shoot. - The new crop is content-derived, so it is written down.
1280x300+0+22out of the pinned 1280x900 raw frame: the+22drops the clipped sticky topbar and keeps its bottom border as the frame’s top rule, and the height ends 14 blank rows below the confirm buttons and 9 clear of the next heading. The screenshot-tools README’s geometry table carries the row and the measurement recipe, since a height that follows the text has to be re-measured on any re-shoot rather than copied.
2026-08-15 — v0.73.6 (the citations checker learns to watch its own source, and an accepted gap goes on record)
No on-chain ABI, instruction, error-code, economic or behavioral change, and no
book page changed a byte — a PATCH bump, v0.73.5’s class and v0.73.5’s date:
the docs’ third movement of the day, so it takes a section of its own. Docs
tooling only, v0.73.3’s shape: two new self-test guards in
check_config_citations.py and one paragraph of record in the gate README.
- The capitalized-fix branch gets a structural guard. v0.73.5 taught a drifted count word’s suggested fix to arrive capitalized when its claim opens a sentence — a branch reached only through a template that opens with its count word, and exactly one does (the discovery set’s). The count-fence cases derive their expectations from the templates themselves, so rewording that last sentence-initial template would have retired the branch’s only exerciser with every case still green. A guard in the self-test now reds instead, naming the branch that would be left running unexercised.
- No module-level
defmay be declared twice, in either checker. Python keeps the last column-zerodefof a name and silently shadows the rest, with every call site still green — and in files this long (~160 module-level defs in the citations checker, ~180 in the keys one) a fixture set pasted in under an existing name is a real hazard, not a hypothetical: one landed in wave #99 and worked only by accident of declaration order. The resolution is code rather than a convention, the discovery guard’s own technique — a line-anchored scan over the source text — swept over both checkers’ sources, because this checker imports the other’s functions, so a def shadowed over there runs (or silently stops running) in this very process. A source in which the scan finds nodefat all is refused as the pattern rotting, never reported clean. - The coverage fences’ missing root hook is now a taken decision, not a
gap. Neither counted-versus-run fence — v0.73.4’s citations side nor
v0.73.5’s keys side — has a mirror-tree hook of its own, and the gate README
and a comment at the fence now say why together: the run side of the
equality can only come from
inspect.getsourceon the imported module, which no mirror tree can perturb, so a hook could redirect nothing but the declaration texts and the leg would be answering for a chimera — a mirror’s roster against the real interpreter’s suite. The in-process cases prove the arms; a counted table genuinely left unrun is a real-tree finding. - Every counted figure stays still. Both guards are failure-list functions
wired into the self-test beside the sets they protect, deliberately not case
tables — the coverage tally reads
…_fixtures(accumulation lines, and a guard driving no table must not register as one — so the ninety-nine citations cases, one hundred and fifty keys cases, twenty-three counted tables, eighteen fenced README claims and nineteen gate legs are all unchanged. - The wave then re-read its own prose, and found itself once. The self-test’s docstring, amended by both guard phases, still promised that its in-process block “need[s] no tree” — a predicate the duplicate-def sweep, enumerated inside that very clause, falsifies by reading the two real checker sources off the tree. It now says what is true: the block builds no fixture tree, and the sweep alone reads the real sources rather than a stand-in.
2026-08-15 — v0.73.5 (the static-hosting walkthrough becomes a pointer, and the counted-versus-run fence reaches the second checker)
No on-chain ABI, instruction, error-code, economic or behavioral change — a PATCH bump, v0.73.4’s class and v0.73.4’s date: the docs moved twice in one day, so they take a section each. Two book pages changed, one in what it says and one in how it renders.
- The
[[static]]walkthrough stops keeping its own copy of the rules. Static hosting for browser clients had become a second telling of what v0.73.4 wrote into[[static]]— same-origin static mounts: the same motivation, then the same five rules in the same order, maintained in two places. It is now the lead-in, the worked three-entry TOML, and the four refusal messages an operator meets at startup, with the rules named once and pointed at — 112 lines to 82. What stayed is what the reference does not carry: the API’s ownwwwroot/test/as the thing the harness fence exists for, the change-history link for the release that stopped serving a mount below a nest, and an example you can paste without uncommenting it. The heading, the TOML and all four quoted refusals are byte-identical, so every inbound deep link still lands where it did. One correction rode along: those refusals abort the process while the router is assembled, but a missingrootnever did — that mount is built and answers 404, which makes it not a refusal at all. - The autoconfiguration route table stops wrapping its Client labels. The
route table under client autoconfiguration (no
plugin) put
“Thunderbird” on a second line under its own term icon: the book-wide floor
under every prose table’s second column — there so the configuration
reference’s Default column is not crushed by a long Meaning — was spending
that width on a Method column holding nothing but
GETandPOST. The floor is right for the tables it was written for, so it is scoped off that one table through a marker span rather than relaxed book-wide, and Method now falls back to its content width. - The gate’s case counts now answer for both config checkers. v0.73.4
closed counted-versus-run for
check_config_citations.py’s own case tables and left thecheck_config_keys.pytables it imports joined to nothing: one of those could be summed into the totals and iterated by no fixture at all, with every leg green — v0.73.4’s own defect, one file over. The same tally is now pushed over that checker’s entry point as well (imported for its source; none of its cases run here), with two seams of its own — the alias hop, since every loop over there writesCASESwhere the counter here sumsKEYS_CASES, and the import block, since a fixture function that entry point calls and this file never imported would otherwise read as tables nothing runs. Exit 2 like its neighbours, and it runs between them and the count fence. The keys checker’s one remaining inline case loop moved into a fixture function of its own so the tally can see it run at all — behaviour-preserving, and what let the join be one more equality rather than a special case. - A drifted count word’s suggested fix now arrives in the letter case it has to be pasted in. That finding always printed the word to write, and always printed it lowercase — wrong for a claim whose sentence opens with its count. The wave’s totals, for the record it is worth keeping: the citations checker’s self-test runs ninety-nine cases against ninety-three (sixty-five fixture shapes, thirty-four in-process), twenty-three case tables are declared and counted across the two checkers, one hundred and fifty keys cases run, and the gate README’s fenced count claims go from seventeen to eighteen. The gate’s nineteen legs are unchanged.
- The wave then re-read its own prose, and again that is where the last edits came from. Passages this wave wrote were made untrue by later phases of the same wave: the count fence’s case list still introduced six cases with a list of five, the clean control unnamed — v0.73.4’s defect exactly, one table over; the sentence saying which findings name the suite being measured named the two that do not; and the keys-side set’s description undersold two of its own cases. On the doc side, the new pointer named four of the reference’s five rules, dropping the one about nesting and order. Two older sentences went with them, both the same family: a note that “nothing anywhere notices” a dropped fixture call, which outlived by a version the fence that notices it, and a description of the discovery guard’s import finding that named the wrong checker’s import block. Nothing in either gate reads prose for truth, which is why the pass that finds this is a phase of the wave rather than a courtesy at the end of one.
2026-08-15 — v0.73.4 (the case counts learn which cases actually run, and [[static]] gains its background)
No on-chain ABI, instruction, error-code, economic or behavioral change — a
PATCH bump, v0.73.3’s class. One thing differs from that entry: a book page
did change this time. [[static]] — same-origin static
mounts explained
every rule the mount list obeys and never said what static hosting is; it
now says so first. The tooling half finishes the join v0.73.3 opened.
- A counted case table and a run case table were still two different
things. v0.73.3’s discovery guard proves that every case table either
config checker declares is summed into
live_case_counts(). What no fence could see was the other direction:live_case_countsandself_testare two hand-kept rosters over one set of tables, and afailures += …_fixtures()line dropped from the entry point leaves every count word in the gate README true — of a suite that no longer runs those cases, with the whole fence family green about it. A roster proved complete is still only a roster; what a suite executes is a separate claim. - The tally reads the entry point rather than trusting it. It starts at
self_test’s own source asinspect.getsourcehands it over, names the fixture functions it accumulates from by that one line shape, and counts a table as run only where a fixture body drives it through afor … in NAME_CASES.items():loop — a name mentioned in prose is not a run, and several of those functions discuss their neighbour sets by name. Anything reached some other way tallies as zero rather than raising, and the asymmetry is deliberate: the total can then only come out short, so an idiom the tally cannot read reds the leg instead of blessing a suite it could not see. Three findings come out of it — a counted table no fixture iterates; a totals gap where every counted table is iterated, blamed onlive_case_counts()’s own arithmetic rather than on any table; and a counted total of zero, refused rather than compared, because a match over nothing proves nothing. - It is exit 2, and it runs between the discovery guard and the count
fence. The line is v0.73.3’s unchanged: a table counted but not run is not
drift in a tree this gate reads, it is section 5’s own state being
inconsistent, so it reds ahead of the words rather than beside them — the
roster of tables first, then the cases actually executed, then the README’s
claims about them. It leans on the guard in front of it (the tables this
file declares are, by then, exactly the tables counted here), and the
imported
check_config_keys.pytables stay outside the diff by design: they are counted underkeys-totaland driven by that checker’s own entry point. Like its neighbour it has no root hook — its subject isself_test, which no mirror tree can perturb — so it was red-proved on the real file instead: aPROBE_CASEStable planted at column zero and summed into the counter, exit 2 naming both figures and the table, the discovery guard beside it staying correctly green, revert sha256-identical. - The gate’s leg count is unchanged; its case count moves twice. Seven
cases drive the coverage fence, so
check_config_citations.py --self-testgrows from eighty-six cases to ninety-three — sixty-five fixture shapes and twenty-eight in-process. Both new in-process sets then got a count claim of their own,citations-discoveryandcitations-coverage, taking the README’s fenced claims from fifteen to seventeen. They sit beside the in-process aggregate rather than inside a split of it: their cases are already summed there, and a per-set claim and a total over the same cases are both true — what the per-set claim buys is a finding that names the table a stale word is about. Leaving a number this same wave wrote unfenced until a later one would have been precisely the hole this work exists to close. [[static]]now says what static hosting is before it says what the rules are. Four paragraphs ahead of the existing prose: the browser clients SithBit ships are static bundles whose only job is to call this API; the browser’s same-origin rule is why serving them from the API’s own listener beats a second web server and a CORS allowlist; these are directories the API finds rather than files it ships —sithbit-outlook.zipis four entries whose manifest points Outlook at{{BASE_URL}}/addin/taskpane.html, which is why an Office add-in needs a mount at all and whyroutedefaults to/addin, while the Thunderbird.xpiis 61 entries and needs no server; and it is deliberately not a general-purpose web server. Recorded there because no doc stated it: mounts carry no authentication — they sit alongside the API’s routes, not behind its JWT layer — so a mounted directory is public.- The wave then re-read its own prose, which is where the remaining edits came from. The “what each checker owns” entry for section 5 still told the reader that the discovery guard “is what keeps those counts from being true about the wrong suite” — a sentence this wave falsified by building the fence that closes the other half of that claim; the coverage fence’s own new paragraph overstated its reach (it diffs the tables this file declares, not everything the discovery guard proves counted); and its seven cases were introduced by a list of six, the clean control unnamed. On the doc side, the mount-list paragraph’s closing clause re-explained the CORS rationale the new background paragraphs now give in full, and lost it. A wave routinely falsifies sentences it wrote itself three phases earlier; the pass that catches them is a phase, not a courtesy.
2026-08-14 — v0.73.3 (the case-count fence finds the case tables nobody told it about)
Docs tooling only: no on-chain ABI, instruction, error-code, economic or
behavioral change, and no book page changed a byte — a PATCH bump, v0.73.2’s
class exactly. Section 5 of check_config_citations.py reads the gate
README’s self-test case counts back against the live case tables; what it
could not do was notice a table it had never been told existed.
- The counting seam is that the counter is hand-kept.
live_case_counts()sumslen()calls written out one table at a time, so a new*_CASEStable nobody adds to it is not miscounted — it is invisible, and every count word in the gate README stays true about a smaller suite than the one that runs. That is not a hypothetical: it happened twice in wave #96, once per checker, each time a table landing in one commit and its registration in the next, with nothing red in between. A fence whose answer is only as good as its own roster has to report on the roster first. - The guard reads both checkers’ source and diffs it against the counter’s
body. Every column-zero
NAME_CASES = {declaration incheck_config_citations.pyandcheck_config_keys.pyis collected and looked for inlive_case_counts(), whose text comes straight from the interpreter (inspect.getsource), so what is read is the function that will run rather than a second copy of it. Two subtleties carry it: thefrom check_config_keys importblock is parsed for the original → local mapping, because that file declaresCASESand this one counts it asKEYS_CASES; and every name is matched as a whole identifier, since a substring test would let the registeredSTEP_UP_CASESwave through aUP_CASESit has nothing to do with. A table declared incheck_config_keys.pythat the citations checker imports nowhere is a finding of its own, blamed on the import block — the shapeDEFAULT_VALUE_CASESarrived in, and the one no amount of counting can reach, since the name is not in scope to sum. Six in-process cases drive it, one per class plus the lookalike and a clean control. - Both classes are exit 2, and the guard runs ahead of the count fence itself. The line is the one the roster-pairing guards already draw: whose state is wrong. Section 5’s exit-1 findings are drift it can describe — a count word a grown table left behind — while an uncounted table is section 5’s own state being inconsistent, and while one sits outside the sum neither the green nor any exit-1 finding beside it is worth acting on. The fix is always an edit to the checker, never to the docs. Its anti-vacuity case is the membership guards’: a declaration pattern matching nothing at all in either checker is exit 2, never a clean report over no tables.
- Its one limitation is stated here rather than left to be discovered: the
guard has no root hook. It reads the counter through
inspect.getsource, so no fixture tree can be pointed at it and--self-teststays green under a planted unregistered table — only the real-tree run bites. That is why it was red-proved in the tree from both directions instead: an unregistered table added (exit 2), and an existing registration removed (exit 2, correctly pre-empting the count fence’s exit 1), each reverted sha256-identical. It also puts one constraint on the files it scans — fixture source text must stay indented, since a column-zero stand-in declaration would be discovered as a real table. - The gate’s leg count is unchanged; its case count is not. The guard
rides the
check_config_citations.pyinvocation the gate already runs, and its--self-testgrows from eighty cases to eighty-six — sixty-five fixture shapes and twenty-one in-process — with the gate README’s section 5 moving with it. That account, and the guard’s own comments, were then re-read against the wired code rather than merely appended to, which is the discipline wave #96 paid for: the checker’s import comment still told the reader there was no discovery pass to fall back on, and it now names the guard that closed it.
2026-08-14 — v0.73.2 (section 6’s fences finish their proof: a real-tree probe and branch coverage)
Docs tooling only: no on-chain ABI, instruction, error-code, economic or
behavioral change, and no book page changed a byte — a PATCH bump, v0.73.1’s
class exactly. That entry landed check_config_citations.py’s sixth section,
holding the account API’s [[static]] startup refusals — quoted on
[[static]] — same-origin static
mounts and
static hosting for browser
clients — to the
Rust source that prints them. It landed on fixture proof alone. This is that
proof finished.
- The value fence has now been probed in the real tree, the strong form
sections 2 and 3 were each held to: one of the five refusal literals
reworded in
account_api/src/lib.rs, left compiling, this leg red alone among the gate’s legs naming both sides — the source line and the roster row that has to follow it — and the source reverted sha256-identical. Recorded with it because it changes how the finding is read: the source-side line’s excerpt names which refusal drifted but can show none of the drift, when the reworded words sit past the excerpt’s cut, so the roster-side line is the half that pairs the two for a reader. - The render guard is parameterized, and every branch it reports is now covered. It read its three rosters out of the checker and took no arguments, so it could only ever run against the live tree — and no fixture tree can reach it either, because the quote fence’s fixture pages are built from the very rosters it compares, leaving its branches unreachable from that root hook by construction. It now takes those rosters as arguments, the two older pairing guards’ shape, and an in-process case set drives all three branches beside a live control: a rendered row naming no message, a rostered key no row renders, and a row left behind by a reworded format string. Each was mutation-checked — disabling one branch reds that branch’s case and no other.
- The self-test grows from seventy-six cases to eighty, and the count fence follows it. The new set is registered in the live figures section 5 reads this gate’s README back against, and it gets a claim of its own rather than joining the existing one: that claim asserts a single count against both older pairing sets, which works only while they are equal, and this third set is a different size. One claim per differing size keeps every finding able to name the table it is about.
2026-08-14 — v0.73.1 (the [[static]] refusals the book quotes are fenced to the code that prints them)
Docs tooling only: no on-chain ABI, instruction, error-code, economic or
behavioral change, and not a single book page changed a byte — a PATCH bump.
v0.72.6 and v0.73.0 below put the account API’s [[static]] startup refusals
into the two operator pages verbatim; nothing then tied those quotes to the
Rust source that prints them, so an edit in account_api/src/lib.rs would
have left the book quoting refusals the API never prints — the drift class
the step-up nonce prefix closed at v0.66.0, with a startup abort in place of
a nonce prefix. check_config_citations.py gains a sixth section closing it.
- The five messages themselves are the anchor. A hand-kept call-site →
verbatim-text roster is diffed against the live string literals in
account_api/src/lib.rsboth ways, byte for byte — a refusal edited, added, or removed at a call site reds the gate until the roster, and so the book, moves with it. The extraction reads each literal across the backslash-continuations rustfmt wraps long strings with, so a reflow of the Rust source changes nothing the fence compares. - The two pages’ quotes are held to that roster through the worked
example.
[[static]]— same-origin static mounts and static hosting for browser clients each quote four of the five refusals, rendered with the walkthrough’s/addinvalues; a render guard holds those rendered texts to the live format strings, and each is diffed against its page byte for byte — section 3’s pair of guards, one section over. - A whole-book scan fences the roster’s membership. A refusal-shaped quote on a page no row rosters, and a rostered page quoting none, are both drift — a quote pasted onto a third page is a red, not silent unrostered coverage. This page is the one excluded from that scan, by name and with its reason: dated prose-of-record is never retro-edited, which is also why this entry retells the refusals rather than quoting one.
- The root-mount refusal stays prose-only, and the fence knows that is a
decision. Both pages tell the
route = "/"refusal in prose without quoting it, deliberately: its worked example would hand the reader the origin root, the one line no reader should be given to copy. The asymmetry is encoded rather than merely omitted — the root-mount message has no quote row, and a rendered copy of it anywhere in the book reds with a diagnostic naming the decision (drop the quote, or take the decision back and roster it) instead of passing as extra coverage. - The gate’s leg count is unchanged; its case count is not. The new
section rides the
check_config_citations.pyinvocation the gate already runs, and its--self-testgrows from fifty-eight cases to seventy-six; the gate README’s retelling of the checker — the fixture shapes, the flag list, the counts its own count-fence reads back — moved with it.
2026-08-14 — v0.73.0 (no [[static]] mount may stand on or below another mount’s harness nest — BREAKING)
A breaking configuration change: a [[static]] list that puts one mount
below another mount’s harness nest — route = "/addin" beside route = "/addin/test/sub" — used to build and serve, and GET /addin/test/sub/x.html returned 200. The account API now refuses that pair
while the router is built, before the listener binds, so a deployment
carrying it stops starting on upgrade instead of starting and serving. The
rule in one sentence: a [[static]] mount may not stand on or below
another mount’s harness nest — test, tests, __tests__, spec — at any
depth. Per the versioning preamble MAJOR stays 0 pre-launch, so a break
rides the MINOR digit and the heading carries the word.
- Exactly which configurations stopped working. A pair of
[[static]]entries where one entry’sroutelies strictly below another entry’srouteplus one of the four harness names — at any depth beneath it, and in either configuration order, since the check does not care which entry was written first. Nothing else changed shape: every list that started before and is not that pair still starts. - Why it was refused rather than left alone. Every mount 404s
test,tests,__tests__andspecbeneath itself, unconditionally. A mount standing below one of those names wins the match — nested prefixes resolve most-specific-first — and so served content straight through a fence the rest of the docs describe as absolute. The choice was to keep the fence and refuse the config, or keep the config and let the fence have a hole nothing reported. - What is not affected. An entry standing exactly on a nest
(
/addin/testbeside/addin) already aborted, as an axum route conflict; it now aborts with a message of our own instead, which is not a behavior change. Ordinary nesting is untouched —/addin/help/submounts as it always did — and so is a prefix that merely starts with a harness name:/addin/testingis not inside/addin/testand still mounts. - The remedy is in the message the process aborts with. It names the
deeper route as the unmountable one, quotes the nest it breaches, and
gives the two ways out: drop one of the two entries, or move the deeper
mount to a route that is not inside a harness directory of the other. No
data migration, no rebuild — one edited
routeline, or one deleted entry. - A
routemissing its leading slash is refused too, and never normalized.route = "addin"previously died on a bareassertion failed: path.starts_with('/'); it now says what to write instead. It is refused rather than silently read as/addin, so the prefix that answers requests is always the prefix in the file — a correction the process makes for you is one the config file no longer describes. - Both pages describing the list now teach the nests before an operator
trips over them. Neither
[[static]]— same-origin static mounts nor static hosting for browser clients had ever said that four directory names 404 under every mount — an operator could only discover it by shipping a bundle with aspec/directory in it. Both now state the nests, print the two refusals verbatim, and mark which of the two is the break; the reference table’sstatic.routerow lists all four ways the process aborts at startup rather than the two it named before.
2026-08-14 — v0.72.6 (the [[static]] mount abort is documented in the operator’s own vocabulary)
Docs catching up to a message that changed under them: no on-chain ABI,
instruction, error-code, economic or deployment-default change, and —
one pathological spelling aside — no [[static]] list that was accepted
before is refused now. A pair of entries sharing a prefix already
aborted, because axum registers nest_service("/addin/") as
/addin/{*tail}, the same route as /addin, so what moved is which
message an operator gets rather than which lists are accepted: a PATCH
bump. (The aside is route = "//", refused now where axum may have
tolerated it.) Both pages describing the list, and the annotated
example file beside them, said the abort names an axum-internal synthetic
route and never mentions [[static]] — false since the validation
landed, and the reason this entry exists.
- The abort quotes the operator’s own
routeback at them.[[static]]— same-origin static mounts and the walkthrough in static hosting for browser clients now print the message itself — it names the section, quotes the duplicated value, and says what to do about it — and the next step they give is to grep the config file for that prefix. The old advice, that the docs were the only place a copy-pastedroutewas ever named as the cause, goes with the message it described. - A trailing slash buys no second mount, and both pages now say so.
/addinand/addin/are one prefix and collide; where two entries disagree about the spelling the message carries both, so whichever line an operator greps for, they find one of the two. - The grep has one hole, stated rather than glossed over. An entry
that omits
routeinherits the/addindefault, so the message can quote a prefix that appears nowhere in the operator’s file. The accusation stays correct — the entry it names is the one with norouteof its own — only the search fails, and both pages say that in place of promising a grep that always lands. - The root mount reads as our refusal, not axum’s.
route = "/"gets a message of its own: the API’s own routes live at the origin, so a directory is served under a prefix instead.account_api.toml’s commented[[static]]block, which explained the same failure as “(androute = "/", which axum will not nest)”, is rewritten to match, as is thestatic_routerdoc comment carrying that phrasing in the source. - The v0.53.0 entry below keeps its wording. It records what the pages said when that capability shipped, which was true then; this log is not rewritten after the fact.
2026-08-14 — v0.72.5 (the Default-column fence spells an enum variant and a listener constructor, and stops accepting a cell that lists alternatives instead of a default)
Tooling, plus the two doc rows that tooling caught: no on-chain ABI,
instruction, error-code, economic or behavioral change —
check_config_keys.py’s value fence learns the two remaining shapes the
tree writes a default in, and grows one refusal on the doc side, so this
is a PATCH bump. Two pages moved, both because a fence that could now
read the code side found the page saying something else: the mode row
of the two SMTP listeners,
and a sentence on
the account API’s search over sealed rows.
- An enum variant is rendered in its serde spelling. The bare
variant path an
impl Defaultwrites (StoreKind::Sqlite) and theEnum::default()a bare#[serde(default)]over an enum-typed field becomes both resolve to the string the row actually states ("sqlite"), by reading the enum’s own declaration — itsrename_allrule, a variant’s#[serde(rename)], and the#[default]marker (or animpl Default forthe enum) naming which variantDefaultpicks. That is reading a declaration, not evaluating anything, which is why it stays inside this boundary; and it is derived only from the six rulesRENAME_ALL_RULESholds. An enum deserialized through a conversion type —KeySource’stry_from— or carrying a rule that table does not name states no spelling the fence can reach and stays unsupported, and a struct-variant literal (KeySource::File { … }) states structure rather than a spelling, so it stays the section it always was. ServerConfig::plaintext(<addr>)reduces to its bind address. It is the one constructor the fence follows, because the argument is the whole of what those[server]rows state and every part of it is a literal in front of the reader — the same argument the multiplied-out product rides on. Both spellings the tree writes are read: the([127, 0, 0, 1], 2525)octet tuple under its.into(), rendered the way the page writes it, and the"127.0.0.1:1430"string under its parse wrapper. It is held exactly there — a named constant or a computed port in either position, a tuple of another arity, and any other constructor (anipv6one, one that also takes a certificate) each state something the address alone does not, and stay refused.- A Default cell that lists several literals is now refused. A cell
claiming
`"mx"` / `"submission"`over a field the fence resolved to one value enumerates what the setting takes instead of stating what it defaults to, so there is nothing to diff. Passing it on whichever token matched first is how a Default column stops meaning anything, so it is exit 2 with a sentence of its own — state the default alone and move the alternatives to the Meaning column, or allow-list the field by name. - That refusal caught the sithbitd
[smtp]moderow. It now states"mx"alone, the value the code defaults to, with the"submission"half — and what each role means for AUTH, sender policy and relaying — moved into the Meaning column, where it also says plainly that the[submission]table does not flip the mode by name: setmode = "submission"on it explicitly. DEFAULT_ALLOWLISTwent twelve entries to six, and the six became comparisons.StoreConfig.kind,SmtpConfig.modeandSmtpConfig.sender_authwere excused for naming an enum variant;SmtpConfig.server,ImapConfig.serverandPopConfig.serverfor the constructor. Compared default values rose 90 → 96 across the same 31 tables — one per retired entry, exactly. The six that remain excuse shapes this fence still does not read: avec![…]macro, aKeySource::File { … }literal, a helper the cell describes in prose, andHealthConfig::on_port(8193), whose port the health-listener arm fences separately.- One sentence on the account API page was factually wrong. Its
sealed-row search section said the per-session summary cache is keyed
on a digest of the session’s bearer token. It is keyed on a digest
of the session id — the token’s
jticlaim — andaccount_api::session_summary_cachenever sees the token at all. The distinction is what makes re-keying a live session evict what the replaced secret opened, so it is worth stating correctly. The same wording in the v0.51.0 entry below is left exactly as written: that entry records what the page said at the time, and this log is not rewritten after the fact. - The gate’s leg roster is unchanged. All of it rides the existing
check_config_keys.pyleg, whose self-test went 138 → 150 cases (its Default-column table 32 → 44): the two new green cases carry theplaintextcall three ways and a#[default]marker on a variant with a#[serde(rename)]of its own, so a fence assuming the first variant — or spelling the renamed one by the enum’srename_allrule — reds on a tree in perfect sync; the drift cases add a page naming another variant’s serde spelling and a port moved behind the listener constructor three times over, once per parse path and once per argument spelling; and the unreadable ones add the four near misses of that constructor, an enum behind a conversion type, and the enumerating cell itself.
2026-08-14 — v0.72.4 (the Default-column fence multiplies out a byte size, and the standalone servers’ own tables come under it)
Tooling-only: no on-chain ABI, instruction, error-code, economic or
behavioral change — check_config_keys.py‘s value fence learns the one
arithmetic the tree writes its byte sizes with, and the three standalone
protocol servers’ own doc regions come under it, so this is a PATCH
bump. Nothing documented needed correcting; every newly fenced value was
already in sync with the tree. No book page moved — neither
the configuration reference nor any other
page was edited for this.
- The value fence learned the one arithmetic it needed. A chain of
bare integer literals multiplied together —
25 * 1024 * 1024, this tree’s spelling for a byte size — reduces to its product now, through a newint_product()behind anINT_PRODUCTfullmatch called fromliteral_value(), so both parse paths reach it through the one shared boundary rather than one of them learning a shape the other would exit 2 on. It is held to*over bare integer literals alone: a+, a shift, a named constant, a call or a float among the operands, or a parenthesis, all fail the fullmatch and stay unsupported exactly as before — multiplying literals asserts nothing a reader has to check, because the operands are in front of them, wherePAGE * pages()would. That retired the threeDEFAULT_ALLOWLISTentries excusing nothing but a unit conversion (SmtpConfig.max_message_size,ImapConfig.max_message_sizeand sithbit-ipfsd’sIpfsdConfig.max_pin_bytes), the rationale being that an allow-list carrying arithmetic a reader can do at a glance is how an allow-list stops being read at all. A finding over a product states what the factors come to, the way a finding over a named helper already states what that helper resolved to: both are shapes that state something other than their own value, and both would otherwise leave the reader a second lookup — or a second sum — before they can tell which side moved. - The standalone binaries’ own H2 regions are fenced for the first
time.
SmtpConfig,ImapConfigandPopConfigeach gained a second rostered table under the empty prefix, because the standalone protocol servers’ regions tabulate those same structs at the top level rather than under[smtp]/[imap]/[pop]— the keys sit at the top level ofsmtp_server.tomland friends, so the doc path is the bare field name and the row label a finding prints carries noprefix.either. That is what tells the two rows apart:[imap] hostnamecame from sithbitd’s table, the barehostnamefrom imap-server’s. Tables went 28 → 31 and compared values 84 → 90, and the six break in half. Three are the fields the product rule above stopped excusing. The other three are the genuinely new comparisons, and the rise is only that becausetable_cellskeys by cell text, so a field two tables document identically merges into one comparison rather than two: pop-server’s and imap-server’s ownrequire_tls, and smtp-server’shostname— which sithbitd’s table writes*(discovered)*, a cell claiming no literal — were under no fence at all before this. - Three new allow-list entries were the price of those tables.
SmtpConfig.server,ImapConfig.serverandPopConfig.server: each region’s[server]row states a bind address, while the code side isdefault_server()’sServerConfig::plaintext(…)— a constructor call the one-hop resolver refuses by design, the same refusalHealthConfig::on_port(…)already carries an entry for. So the allow-list total has not moved from twelve while its composition turned over, and the total is the less interesting half of that sentence: three entries excusing a unit conversion left, three excusing a constructor arrived. - The remaining gap is written down rather than left to be inferred
from the fence’s silence. Each standalone binary wraps its server
config in a private
FileConfigin its ownmain.rs— the#[serde(flatten)]host of[[accounts]]and, for SMTP,[[mailboxes]]— and no roster entry holds it. That is deliberate and buys little to close: both array rows state*(no entries)*, an italic cell claiming no literal and so nothing to diff, and the[health]port the same struct owns is already read by the health-listener arm. It is still a gap rather than a covered edge, so it is named in the roster’s own comment beside the prose-documented structs, the way the allow-list is printed in the clean run’s summary. - One rule set, two authoritative copies — and the third was
retired. The module docstring’s value-fence paragraph used to
restate which expressions the boundary evaluates, how a refusal is
excused, and what an italic cell is counted as; it is now a pointer at
the
--- the Default-column value fence ---section comment standing overDEFAULT_SOURCES, where those rules live beside the code they govern. One rule set in two places is one that drifts, and the two copies worth keeping are that section comment andmail_docs/README.md’s checker row, which describes the same fence for a reader who is not in the file. - The gate’s leg roster is unchanged. All of the above rides the
existing
check_config_keys.pyleg, whose self-test went 132 → 138 cases (its Default-column table 26 → 32): the product read both ways through both parse paths, since a product taught to theimpl Defaultside alone would exit 2 on the serde one; the near miss that must go on being refused, a*chain with a call among its operands; and the empty-prefix table from both sides — the doc side, where only the top-level row moves and the finding has to name the bare label a prefixed read never produces, and the code side in the shape the real page wears, where the prefixed row states no literal at all and the top-level table is the only one asserting anything about the field. The clean-tree case carrying a25 * 1024 * 1024default is an assertion by itself: a resolver that had not learned the shape would exit 2 on that same tree, and one multiplying wrongly would exit 1.
2026-08-13 — v0.72.3 (the Default-column fence learns the two other ways the tree writes a default, and the health-listener ports get a fence of their own)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — check_config_keys.py’s value fence widens on both of its
sides, the checker grows a seventh arm, and check_screenshots.py’s
--update learns to report the one input gate mode already reports, so
this is a PATCH bump. Nothing documented needed correcting; every newly
fenced value was already in sync. No book page moved.
- A default written as a call to a named helper is compared by value
now. The serde-default-fn idiom (
default_report_window_hours()) used to sit outside the expression boundary, so every field spelled that way was excused by name instead of diffed. The resolver follows such a call exactly one hop, and only when the helper takes no arguments, is declared in the same source, and has a body that is a single bare literal — wrapped in the conversion calls the fence already normalizes, or not. Anything else (a further call, a struct or variant literal, arithmetic, a second statement) stops the hop and the field stays unsupported: the name is indirection rather than computation, so following it asserts nothing the literal rules did not already. Compared values went 60 → 65 andDEFAULT_ALLOWLIST13 → 8, and the three excuses that stayed now name the real refusal rather than the idiom. - The three protocol-server configs’ Default cells are fenced for the
first time.
SmtpConfig,PopConfigandImapConfigwrite noimpl Defaultat all — theirdefault()istoml::from_str("")— so the parser that reads aSelf { … }literal read nothing for them, which is why the[smtp],[imap],[pop]and[smtp.quota]tables of the Configuration reference went value-unfenced for as long as they did. A roster entry now picks its parse path with adefaultskey: absent reads theimpl Defaultblock,"serde"reads the#[serde(default …)]attributes of the struct declaration and turns each field back into an expression the one shared boundary already evaluates —default = "f"becomesf(), a bare#[serde(default)]becomes the field type’s ownDefault, and anOption<T>becomesNonewhether it carries the attribute or not. A field of such a struct with neither an attribute nor anOptiontype has no default at all and is exit 2 rather than a skip: the struct parses the empty document, so a field serde cannot fill would panic at startup. The two paths are fenced against each other in both directions, each naming the roster key to change. Compared values went 65 → 84 across 28 tables, the allow-list 8 → 12 (two enum variants documented by their serde spelling, two25 * 1024 * 1024products — honest entries rather than a wider resolver), and a finding over a named helper now states what that helper resolved to, so the reader can tell which side moved without a second lookup. - The nine health-listener ports were restated on two pages and
fenced by nothing. Each binary picks its default with one
HealthConfig::on_port(N)call; the Liveness table tabulates all nine and the configuration reference repeats four of them in per-binary rows. That is a constructor call, outside the value fence’s expression boundary by design, and three of the nine are set in amain.rshelper no roster entry could name — so the ports get a dedicated arm rather than a looser resolver. It scans every*.rsfor call sites, attributes each to a binary by its crate directory (never by port set, so two binaries with exchanged ports cannot pass), and diffs three surfaces: the Liveness table both ways, the reference’s four per-binary literals one way — which four restate a port is itself a roster fenced in both directions, so the asymmetry stays a decision rather than a hole — and that page’s8190–8198range claim against the tree’s own lowest and highest, since the range is everything it says about the five ports it does not restate. The documented127.0.0.1host is read out of the constructor rather than assumed. Port 0 is the ephemeral test spelling and is skipped and counted; a mention that is no call, a non-literal argument, or a site in a crate no service row names is exit 2. --updateno longer crashes on the one input gate mode reports. Since v0.71.4 asourcesentry naming a missing path has been a named finding in gate mode, but the re-pin path still walked into a rawFileNotFoundError. It now prints that same report — verbatim, through helpers both modes share, so their wording cannot drift — skips that client’s re-pin, re-pins every sibling anyway, and exits non-zero. Skipping is the only implementable answer rather than merely the tidy one: a hash over a partial source set would look pinned while covering less than it claims. Two rules ride along:--updaterewrites the manifest only when something was actually re-pinned, and the self-test redirects the manifest path as well as the repo root, so no section can reach the committed manifest.- The gate’s leg roster is unchanged. All of the above rides the
existing
check_config_keys.pyandcheck_screenshots.pylegs; the former’s self-test went 99 → 132 cases. Each widening was mutation-proved on the real tree in the strong form — for the new arm, moving one binary’s health port in the code reds that leg alone among the gate’s legs, naming the tabulated row, the source line and both addresses — andmail_docs/README.mdnow describes the seventh arm and the widened--updatecontract.
2026-08-13 — v0.72.2 (screenshot checker: the walked closure now fences the offline shell — and its first run caught real drift)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — check_screenshots.py gains three hardenings and the new
fence’s first catch fixes webmail’s service worker, so this is a PATCH
bump. No book page moved.
- A
sourcesentry naming a missing path is a report now, not a traceback. The hash walk used to escape with a rawFileNotFoundErroron a manifest entry no longer on disk, aborting the run and swallowing every closure finding queued behind it. Each missing entry is now a named finding carrying its two fixes (restore it, or drop it and re-pin), the closure findings from the same run still surface beside it, and only the hash comparison is skipped — a hash over a list that is not all on disk cannot be computed to disagree with anything. - The walker learned the PWA manifest’s one reference form.
.webmanifestis a walked extension now and its JSON"src"members are followed, so the icons the manifest names are part of the computed closure instead of structurally invisible. The form is scoped to.webmanifestfiles alone — a JSON-shaped"src":literal in JS is data, not a reference, and must never widen a closure — and the reached assets stay out of the closure diff and the hash (they are not source), so no client’s pinned hash moved for this. - The offline app shell is fenced against the closure — the guard
those assets were walked for. A client whose root ships a
service-worker.jskeeps a second hand-kept copy of its closure, the worker’sPRECACHE_URLSarray, and it drifted exactly the way thesourceslist did before v0.71.4.check_precachediffs the array against the walked closure both ways in URL space: a reached file the precache omits is red (a first offline open of the installed PWA would lack a file its pages reach), an entry nothing reaches is red,"."and the worker itself are allowed unreached, and the generated wasm pair is existence-exempt — the walker can never reach a build output — but required present. - The fence’s first real run disproved “harmless today”. Eight
reached
shared/modules were missing from webmail’s precache — four directapp.jsimports (chain.js,chain-send.js,recipient.js,trustless-inbox.js) plusbase58.js,keystore.js,store.jsandtag-vocabulary.jstransitively — so the installed PWA’s offline shell lacked files its pages reach. Fixed in the worker; webmail’ssource_hashre-pinned, hash only, no re-shoot — no rendered pane changed. - Mutation-proven live, and the hand-kept third copy retired. Both
drift directions were probed on the real tree — a planted dead entry
and a removed reached entry each red the checker naming the url — and
webmail’s
service-worker.test.jsdrops its hand-hardcoded shell list, a third copy of the same closure, for the assertions the Python fence is structurally blind to: a non-empty list, the"."scope root present, no duplicate entries. Three new self-test sections take the checker’s own suite from two to five, the gate’s leg roster is unchanged, andmail_docs/README.md’s checker table now describes all three guards. - v0.72.1’s queued follow-up landed alongside. The Rust unit test
that entry deferred — pinning
server_common’sidle_timeout_secsdefault at 1800 as an RFC 3501 conformance matter — now exists and is itself mutation-proven, so the docs fence over the reference’s Default column is no longer the only thing in either gate reading that figure.
2026-08-13 — v0.72.1 (the configuration reference’s Default column is now checked by value, not just by key)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — check_config_keys.py gains a fence over the reference page’s
Default columns, so this is a PATCH bump. No documented default needed
correcting; the tree was already in sync.
- Every leg of the docs gate could agree a row existed while none of
them read what it said. The checker diffed key sets — the nine
example TOMLs against
Configuration reference, the service
roster against the tree, appconfig-gen’s roster and its artifacts,
keys and all — and a value stated in a Default cell was fenced by
nothing. That is exactly how a
600sat in the[*.server]table’s Default column long after the in-code default had moved to 1800. - The fence reads the
impl Defaultblock behind each table.DEFAULT_SOURCESpairs each rostered config struct with the doc region its fields are tabulated in and the path prefix they are written under — thirteen sources, twenty-five tables and sixty compared values today — and each field’s default expression is diffed against the Default cell of the row that resolves to its path, through the same cell resolver the key diff already uses. Region-scoping is what gives it teeth:bind_addris a row in six regions carrying six different values, so a map keyed on the path alone would report drift on its first row. A struct two regions tabulate is diffed against both of its tables. - The expression boundary is deliberately narrow. A literal is the
value; a literal under a conversion wrapper (
.into(),.parse().expect(…),PathBuf::from(…)) is normalized to the literal it wraps, since those spellings change a value’s type and never its value; a nestedType::default()resolves one level, to the empty rendering for the std containers and otherwise to a struct, whose row states structure rather than a value; andNoneis fenced as saying nothing is set, so a row claiming a literal for an unset field is drift. Integers compare as numbers, so a code-side10_000and the page’s10,000agree. Anything else is an assertion the fence cannot make — exit 2, not a pass — because a fence that guesses what a Rust expression evaluates to is worse than no fence. DEFAULT_ALLOWLISTis the one seam, and it cannot go stale. Thirteen fields are excused by name with a written reason (serde default helpers, a constructor, arithmetic over literals, an enum variant’s serde spelling), each printed in the clean run’s summary so nothing is skipped invisibly, and an entry the run never needed is itself a finding — the same contractEXCLUDED_PREFIXEShas carried since v0.66.1. Cells that state no literal at all (the page’s italic*(unset)*/*(discovered)*spelling) claim no value to diff and are counted rather than skipped, so “the fence read nothing here” is a number in the summary rather than a silence.- Eighteen self-test cases, and the gate’s own count fence had to
learn about them in the same breath. The new cases — two green,
eight seeding drift, eight seeding an input the fence cannot read —
take the two config checkers’ suite from seventy-nine to ninety-seven.
check_config_citations.py’s section 5, which fences the case counts written inmail_docs/README.md, sums only the case tables it imports by name, so the new table was invisible to it: the README understated the suite by eighteen and nothing red. The import list is now part of the fence’s contract, stated as such in its own comment, and the README’s checker table gains a row describing the value fence. - Probed on the real tree. Flipping
server_common’sidle_timeout_secsfrom 1800 to 600 — the drift this fence was built over — redscheck_config_keys.pyalone among the gate’s legs, naming the row, the source’s block and both values in one line. The workspace cargo gate does not catch that mutation at all (no Rust test pins that field’s default), which is the argument for the fence; a unit test pinning the value as an RFC 3501 conformance matter is queued separately. No gate leg was added — the fence ridescheck_config_keys.py’s existing run.
2026-08-13 — v0.72.0 (pinning is defined in plain language before the docs use it)
Documentation-only; the version is unchanged. The terms pin / pinning / unpin were used throughout the mail chapters with only a storage-jargon glossary line (“retain its blocks against garbage collection”) defining them — nothing a non-technical reader could lean on before first meeting the words. Following the precedent of What is a CID?, the IPFS-benefits appendix gains a plain-language What does pinning mean? section (a pin is one keeper’s promise, anyone can add their own, unpinning is not deletion everywhere), and the definition now precedes the term on each reading path: the Email chapter opens with a plain-terms note before its first “pinning”, its Pinning to IPFS section links the definition at its first “pinned”, the Pinning mail command reference points new readers at it before the concept link, and the glossary’s Pin / unpin entry cross-links it the same way the CID entry cross-links its own plain explanation.
2026-08-13 — v0.72.0 (the shared idle deadline rises to 30 minutes: IMAP autologout stops breaking RFC 3501 §5.4)
A new enforcement default that changes how every deployment behaves, so this is the MINOR class widened at v0.10.0 — no on-chain ABI, instruction, error-code or economic change.
limits.idle_timeout_secsnow defaults to1800, not600. RFC 3501 §5.4 requires an IMAP server’s inactivity autologout timer to be at least 30 minutes, and the shared listener deadline every protocol’s reads run under was ten — so the reference server broke that MUST every time an IMAP session simply fell quiet, and had been breaking it since the knob existed. 1800 s satisfies it. POP3 (RFC 1939 §3) and SMTP (RFC 5321 §4.5.3.2) state only smaller minimums of their own, and a deadline clearing the largest minimum clears the rest, which is why one shared knob stays conformant on all three listeners rather than needing to be split per protocol.- What an operator will notice: a silent session holds its connection
slot three times longer. Ten minutes of quiet no longer closes a
connection on SMTP, IMAP or POP — thirty does. The exposure that
bounds is
max_connectionsandmax_per_peer, not this deadline, so a listener sized for the old churn should be re-checked against those two rather than tuned back down here: loweringlimits.idle_timeout_secsunder 1800 on a listener serving IMAP re-opens the §5.4 violation. Every commented example value in the shipped TOMLs and.envsamples now shows 1800. - The IDLE seam of v0.71.0 is inert at shipped defaults, and both
pages that framed it as a raise were rewritten.
imap.idle_command_timeout_secsalready defaulted to 1800, so with the shared deadline there too the substitution raises 1800 to 1800 and changes nothing out of the box — §5.4 conformance at defaults is now bought by the base default, not by the IDLE-specific one. The seam is still real, because the two knobs move independently: an operator who lowers the shared deadline for every other command does not thereby hang up on a conformant idler. The configuration reference’s twoidle_command_timeout_secsrows (sithbitd’s and the standalone server’s) and the conformance appendix’s RFC 2177 section all said the default “allows the full half hour instead of” a stingy 600 s shared one; that contrast is false now, and each says the two coincide instead.
2026-08-13 — v0.71.4 (screenshot manifest: the closure rule is now walked mechanically, not kept by hand)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — check_screenshots.py gains a guard over its own manifest, so
this is a PATCH bump.
- The rule v0.71.3 wrote down was still hand-kept. That entry stated
the curation rule correctly — a client’s
sourceslist names the shared files reached by the closure of every one of its HTML entry points — but nothing checked it, so the next shared import a page gained would sit unpinned exactly the way onboarding’sfund.js/dnd.jsimports did, silently narrowing what that client’s screenshot hash covers. check_screenshots.pynow walks the closure and diffs it against the list both ways. A shared file the pages reach that no entry covers is “reached but not in ‘sources’”; an entry no entry point reaches is “in ‘sources’ but unreachable”. Both are exit 1 with the path named, and both fixes end in--updateto re-pin the hash. The walk starts at every HTML page in the client’s tree and is transitive across the two hops that matter, HTML → JS → JS by import and HTML → HTML bymount.- Seven reference forms, four of them load-bearing today.
<script src>,<link href>,<img src>, ESimport … from, side-effectimport "…", a literal-pathfetch("…")andmount(id, "…")are all followed. On the three manifested clients the work is done by<script src>andimport … from, plusmount(the only path to the threeshared/*-panes.htmlfragments) and<link href>(the only path tobrand.css);<img src>reachesmark.svg, which<link rel="icon">reaches as well. The side-effect andfetchforms are covered by the self-test but unexercised — the literal fetches in the tree belong to thechromeandthunderbirdshells, which carry no screenshots. - The bundle-versus-source path skew has a remap. The shells spell
shared imports
./shared/api.js, which exists only afterbuild.shcopieswebclients/shared/into a client’s staging root, so a reference landing on a missing<client>/shared/…is retried against the siblingshared/directory it is staged from. - An unresolvable reference is dropped silently, deliberately. The
generated
wasm/mail_wasm.jsimport exists only afterwasm-pack, and a gate that reds on an unbuilt bundle would be unusable. The cost is that a shared file reached only through something the walker cannot see would read as an unreachable extra; the fix then is to widen the reference forms, not to trim the manifest. - No new gate leg, and no manifest correction needed. The guard is
folded into
check_screenshots.py’s existing verify pass, so the gate’s nineteen legs stand as documented inmail_docs/README.md, whose checker table now describes the closure guard; the threeclientsentries were already exactly their closures, so nosources,screenshotsorsource_hashvalue moved.
2026-08-13 — v0.71.3 (screenshot manifest: the curation rule is every HTML entry point’s closure, not index.html’s)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — a correction to the prose rule inside
check_screenshots.py’s manifest, so this is a PATCH bump.
mail_docs/screenshots.manifest.json’s_formatnote stated a rule that was only accidentally right. Since v0.8.10 it had said a client’ssourceslist names the shared files “that client’sapp.js+index.htmlactually reach” — but a client is not necessarily one page.onboardingalso servesfund.htmlanddnd.html, whosefund.js/dnd.jsclosures reach shared filesindex.html’sapp.jsnever touches, which is why those imports sat unpinned as long as they did.webmailandmarketplacehave a single HTML entry point each, so the old wording happened to be complete for them.- The rule is now stated as the closure of every HTML entry
point.
_formatsays to walk each page’s<script>imports transitively when curating a list, and names onboarding’s second and third pages as the worked example — so the next client to gain a page is covered by the rule rather than by luck. - Prose only. The three
clientsentries were verified correct when the true closures were re-traced, and nosources,screenshots, orsource_hashvalue moved here;_formatis documentation for humans, ignored by the checker, which reads only theclientsobject.
2026-08-13 — v0.71.2 (the conformance appendix gains the RFC 2177 section its IDLE deadline had been missing)
- The appendix now covers IDLE, where it had covered every other IMAP
extension the server advertises. The new RFC 2177: IDLE — the push
watch, and its own
deadline
section sits with CONDSTORE and APPENDLIMIT, and records the same
three things they do: what is implemented (the capability, offered
with the other post-credential extensions;
+ idling,DONE, and untaggedEXISTSpushes fed by both the in-process and cross-process change seams), what a custom server should copy, and where the numbers come from. v0.71.0 shipped the behaviour and the configuration rows; the conformance page had never mentioned the RFC at all. - The 1800 s default is now explained as a relationship, not a
number. RFC 2177 requires nothing of a server here: it permits one
to treat an idling client as inactive and log it off at its inactivity
timeout, and on that basis advises clients to re-issue
IDLEat least every 29 minutes. The default is that interval plus a minute, so a client taking the advice re-issues before the deadline it would otherwise meet — and the section says plainly that 29 minutes is client-side guidance rather than a server-side floor, so an operator lowering the knob under it knows what they are choosing. - The two expiries are contrasted where a server author will look for
them. The IDLE allowance ends with an untagged
* BYE IDLE timed outand a clean close; the shared read deadline’s expiry stays a silent EOF. The section notes both are pinned to the tick by paused-clock driver tests, liveness included, so the shapes are behaviour rather than intent. - Documentation-only, so PATCH: v0.71.2. Nothing moved in the server; the appendix caught up to v0.71.0’s work.
2026-08-13 — v0.71.1 (two logins in one second are two sessions: the logout section stops denying the jti it now has)
- The logout section’s “same-second logins are one session” exception is
gone, because the claim behind it is. Ending a
session told
readers that two logins by one wallet inside a single second minted a
byte-identical JWT —
iat/expbeing second-resolution, with nojtito tell them apart — so logging out of either ended both. Every issued token now carries a randomjtisession id, minted fresh per issuance, and that id (not the token text) is what the reading-secret and decrypted-summary stashes are keyed by: a same-second pair is two sessions with separate stashes, and ending one leaves the other whole. The “per token, not per wallet” bullet now says so without the exception. - What logout still does not do is revoke the token, and that stays in
bold. The JWT remains a stateless bearer credential valid to its
expon every route; the section’s closing paragraph keeps that warning and only corrects its reason — there is no denylist, and thejtiis the key a session’s server-side state is filed under rather than a revocation list a presented token is checked against. The narrower guarantee is unchanged: after logout the server holds no key material for that session, so the same still-valid token reads exactly what an unkeyed session reads. - Documentation-only, so PATCH: v0.71.1. Nothing about the API’s behaviour moved here — the docs caught up to behaviour that had already shipped.
2026-08-13 — v0.71.0 (a client inside IMAP IDLE outlives the shared read deadline, and is told when its own expires)
- IDLE now runs under a deadline of its own. While a client sits inside
an accepted
IDLE, the connection’s read deadline is the newimap.idle_command_timeout_secs— 1800 s by default — instead of the listener’s sharedlimits.idle_timeout_secs(600 s by default), and the shared deadline is restored the moment the exchange ends. RFC 2177 tells clients to re-issue IDLE at least every 29 minutes; the previous arrangement hard-closed exactly such a conformant idler at the ten-minute mark, so a mailbox left open in a standards-following client kept dying mid-watch. The default allows the full half hour. - The IDLE deadline expiring is a goodbye, not a vanishing. A client
that idles past the raised deadline without
DONEor a re-issue is sent an untagged* BYE IDLE timed outbefore a clean close — where the shared deadline’s expiry was, and for every other command remains, a silent EOF. A client that sees the BYE knows its session ended rather than wondering what the connection died of. - Where the knob lives, and where it deliberately does not. It scopes
one IMAP command, not the listener, so it sits beside
max_login_attemptsrather than under the shared limits: top level in the standalone server’simap_server.toml(envIMAP_SERVER_IDLE_COMMAND_TIMEOUT_SECS), and under[imap]insithbitd.toml(envSITHBITD_IMAP__IDLE_COMMAND_TIMEOUT_SECS). Both configuration-reference tables — sithbitd’s[imap],[pop]and the standalone server’s — carry the new row, and the shared listener section’sidle_timeout_secsrow now names its IMAP exemption. - The version rolls to v0.71.0. Observable IMAP wire behaviour changed — who survives the read deadline, and what an expiring IDLE is told — plus a new config default: the additive, deployment-affecting class that took MINOR at v0.68.0 (APPENDLIMIT advertised) and v0.69.0 (the oversize refusal explained); PATCH stays reserved for docs-only changes.
2026-08-12 — v0.70.0 (the v1 wallet-signature window closes: no epoch accepts an epoch-less mail password — BREAKING)
A breaking credential change: the epoch-less v1 wallet mail password is
refused everywhere, including at auth epoch 0, which was the one state that
still honoured it. A client holding a v1 password no longer logs in over SMTP,
IMAP or POP until it re-derives; every shipping client already derives
v2, so the remedy is one
re-derivation and there is no migration to run. Per the versioning preamble
MAJOR stays 0 pre-launch, so a break rides the MINOR digit and the heading
carries the word.
- The window is gone rather than deprecated. The v1 prefix, the function
that built its bytes, and
verify_at_epoch’s epoch-0 fallback arm are all deleted; verification is now one message build for the account’s current epoch and one signature check, with nothing to fall back to. The wallet mail password and auth epoch glossary entries stop describing two live constructions, and the account API’sauth_epochfield stops calling0the state in which the older signature is still accepted —0is now just “never rotated”, refusing a wrong-epoch signature as firmly as any other value does. - Abrupt was safe because the two shapes were never confusable. v1 and v2 were domain-separated from the start, at the version digit in a prefix of the same length and by total message length (53 bytes against 61), so no v1 signature could ever be reinterpreted as a v2 one and no account could be mid-way between them. That is what allowed a clean removal rather than a deprecation period: there was no ambiguous byte string to keep supporting.
- Closure is proven by refusal, not by absence. Rather than resting on
“the code is gone”, the server credential layer, the account API and the
wasm signer each build v1-shaped bytes in-test and assert they are rejected
— the server layer across a spread of epochs with
0leading, since0is the epoch that used to accept them. A future re-introduction therefore has to delete a test that says so. - Docs-side scope. Two pages asserted the live window and both were rewritten; the versioning preamble gained the pre-launch rule that decides which digit a break like this moves. No configuration key, gate leg or checker behaviour changed — the docs gate stays at nineteen legs, and its self-test case counts stay at 79 / 58 / 44 / 7.
2026-08-12 — v0.69.1 (three open questions get answers: the upgrade authority’s end-state, the money unit each surface speaks, and the line numbers the gate leaves unfenced on purpose)
-
SithBit’s canonical network launches behind a multisig, and freezes on a stated trigger. How this qualifies “nothing to rug” used to close on an open question — freeze, multisig or DAO — and told the reader that a governance decision was not a protocol fact this repository could settle. For this project’s own network it is settled now: the authority sits in an
N-of-Mmultisig at launch (option (a)), and freeze (option (c)) is a later step gated on a written trigger — that the rules stop moving, meaning no planned protocol work still needs an instruction-set change and RFC coverage is no longer growing the programs’ surface. Freezing before then forecloses the on-chain bug-fix path while that surface is still being extended, and a program that cannot be fixed is a weaker promise than one whose fixers you can count and name. A DAO (option (b)) is ruled out: governance that is more than a multisig wants tokenomics to weigh votes, and the “minimal rake, no token” stance excludes the token that would take. What did not change is the page’s framing. Every network still sets its own upgrade authority at deploy time and can transfer it, so this settles one deployment rather than imposing a protocol rule, and the page still sends you tosolana program show— including for this deployment, after launch — rather than to its own statement of intent. -
The CLI takes lamports, the human-facing clients show SOL, and that split is now written down as a rule instead of read as an inconsistency. Which unit each surface speaks is a new section on the economics page, at the seam between the lamport-denominated narrative above it and the constants table below. Every money argument the
sithbitbinary reads —alias sell --price,alias bid --amount,mail send --bounty,mailbox create --default-postage, the delegate’s fee setters — is an integer count of lamports, because the CLI is a scripting surface and an exact integer in the chain’s own unit composes: it survives shell substitution and generated command lines with no float rounding, no locale decimal separator and no ambiguity about which unit a bare number is in, and it is the unit an RPC response or a program error quotes back. Webmail, the marketplace and the Outlook / Thunderbird / Chrome panes quote SOL, because SOL is what a person holds in a wallet and compares against a price, and they convert at their own edge. The auction flags are framed as an instance of the rule rather than an exception to it:--reserveand--amountare lamports because their siblings--priceand--feeare, and a marketplace that read a reserve in SOL and a fixed price in lamports would be a foot-gun for anyone scripting both. Moving the whole project to SOL was the alternative, and it was weighed and rejected. The rule is scoped to what the CLI accepts: its reports may still annotate a figure with a SOL and best-effort USD tail (sithbit earnings,frombox get,mailbox get --usd), which is readability rather than a second input unit. -
The gate’s page-less line numbers are recorded as deliberately unfenced, with the reason, and the one volatile instance is gone.
check_config_citations.pyrefuses the bare:Npointer shape outright andcheck_timelock.pybinds every pinned<page>.md:<line>to a pin or a written allowance, but a line number written as ordinary prose or as aScope’slines=argument — naming no page and carrying no colon — sits outside both.check_timelock.py’s module docstring now says why that shape gets no fence of its own, and the argument is what its instances are rather than what a fence would cost to build. There are three classes and a fence would help with none: one is guarded by the live code beside it (a comment restating the bounds of aScopea few lines below is re-checked every run, because moving the prose those ranges bracket makes two scopes overlap andrequire_disjoint_scopesrefuses the table at exit 2), one deliberately describes a range that no longer exists (the close row’s retired range is quoted to record why that scope became asection=, and a fence demanding it match today’s file would force the record to be falsified to stay green), and one is illustrative prose pointing at nothing (a measurement of magnitudes, taken when the choice was made, that no reader is meant to follow to a line). The standing call is unchanged either way: prefer asection=, a heading name or a quotation to a number, and where a number is the only thing that says it, write it where live code checks it. The one instance that quoted live line numbers “today” — the glossary split’s parenthetical — was deleted rather than kept and pinned, the same call the checker’s own duplicated citation got. -
The version rolls to v0.69.1. Everything here is prose: a decision written down, a rule stated, a reason recorded. No instruction, wire format, default or configuration key moved, no gate leg was added (the docs gate stays at nineteen), and no exit code changed for anything already green — the checker’s self-test stays at 44 cases and every timelock mention count is unchanged. That is the documentation-only class, so PATCH.
2026-08-11 — v0.69.0 (An oversize IMAP APPEND is now told why it was refused)
- A refused
APPENDliteral now names the reason and the ceiling. An oversize synchronizing literal draws exactly one line — a taggedBADreadingTOOBIG: message exceeds the APPENDLIMIT of <n> bytes, sent in place of the continuation request, so nothing of the payload is buffered first.<n>isimap.max_message_size, the same setting advertised asAPPENDLIMITa version earlier, so the promise and the refusal cannot drift. Previously the refusal carried the flow layer’s default text — the three characters...— and told the client nothing at all. - The
TOOBIGname is unbracketed, and that is upstream’s ceiling rather than a shortcut. RFC 7889 §4 wants the machine-readable[TOOBIG]resp-text-code; the adoptedimap-nextpre-builds this rejection with its response code hardcoded toNone, and its one seam — the reject text — is validated as continuation-request text, whose constructor refuses a leading[. A trueNO [TOOBIG]is unbuildable on the pinned version without forking the dependency, which was weighed and declined for the same reason the CONDSTORE deviation is left alone. So a client matching on the code still reads a genericBAD; only a human reads the reason. - The
LITERAL-gap is unchanged and now stated in full. A non-synchronizing oversize literal still gets one untagged* BAD could not parse command— noTOOBIG, no ceiling — and, the part worth knowing before you rely on it, theAPPEND’s own tag is never completed, which a client waiting on its tagged reply experiences as a hang. The connection survives and the next command is answered normally. Both gaps are recorded as upstream’s in the conformance appendix’s RFC 7889 section, where the previous entry had claimed the single gap was ours to fix, and narrowed in the standards page’s IMAP table. imap.max_message_sizeis documented as three things, not one. Both configuration reference rows for it —sithbitd’s and the standalone IMAP server’s — now say it is enforced, advertised asAPPENDLIMIT, and named in the refusal text.- The version rolls to v0.69.0. Observable IMAP wire behaviour changed — what a refused upload is told — so this is not the doc-only case that keeps the previous version, and MINOR rather than PATCH because PATCH is reserved for docs-only changes.
2026-08-11 — v0.68.0 (IMAP publishes its APPEND ceiling: RFC 7889 APPENDLIMIT)
- The IMAP server now advertises
APPENDLIMIT=<n>(RFC 7889), so a client can size an upload instead of discovering the ceiling by having a finishedAPPENDrefused. The server has always enforced a maximumAPPENDsize and simply never said what it was. The advertised value isimap.max_message_size— 25 MiB by default — which is the same setting the driver caps incoming literals with, so the advertised limit and the enforced one cannot drift apart, and the extension needs no config knob of its own. It rides §2’s form (a) (one ceiling for every mailbox, carried in the capability name), and is advertised both before and after authentication, which §2 permits and which is what lets a client size its very first upload. See the new row in the standards page’s IMAP table and the new conformance appendix section. - Two parts of RFC 7889 are deliberately absent, and the appendix says so
rather than claiming the RFC whole. Limits do not vary per mailbox here,
so the extension’s bare-atom form and the
STATUS (APPENDLIMIT)item it depends on are not implemented. And §4’s[TOOBIG]response code is a known gap: an oversizeAPPENDis refused, but the refusal is generated by the adoptedimap-nextflow layer below the session, as a taggedBADwith no response code (a non-synchronizing literal is discarded with no tagged response at all). That one is ours to fix rather than upstream’s — unlike the CONDSTORE wire deviation recorded a section earlier — and is queued as a follow-up in the server’s driver. - The version rolls to v0.68.0. A newly advertised capability is the significant additive, deployment-affecting class that took MINOR for SASL-IR (v0.33.0), SPECIAL-USE (v0.34.0) and CONDSTORE (v0.45.0) — even though no enforcement changed here, only what the server says about it.
2026-08-11 — v0.67.1 (Getting started opens the Using SithBit chapter)
- Getting started is now the first page of Using
SithBit, ahead of GUI clients, rather than a
sibling sitting after the marketplace. It is the wizard every GUI client
embeds, so a reader opening the chapter to set themselves up now meets the
setup page first instead of finding it below the clients that depend on it.
The page’s own path is unchanged, so every deep link into it — the several
#web-onboarding-the-browser-wizardand#self-service-pages-for-refused-senderscitations across the book — still resolves.
2026-08-10 — v0.67.1 (the example TOMLs show what production actually sets, the beacon rows link the campaign commands they name, and the recon page’s audit cites carry both halves again)
-
The example TOMLs now show every setting the production AppConfig artifacts set, and a new cross-check holds them there. The six
iac/appconfig/aws/documents are the config a real deployment runs on, and 33 of their settings appeared in no annotated example — most of two entire storage flavors: the S3 blob store, which no example TOML in the workspace showed at all, and the Azure store and blob flavor, which onlysithbitd’s example showed. Nothing red, because nothing compared the two: the artifact tree was fenced for existence only. It is now fenced for content, on both clouds — each AWS document’s key set, and the merged Azure key-value set, diffed against the example TOML its service row names, so a setting cannot reach production while the example that is supposed to teach it stays silent. The gap itself is closed insithbitd, account-api, sithbit-ipfsd and sithbit-gateway: the S3 and Azure flavors now sit beside the local one in each[blobs]/[store.blobs]table, doubly commented because each replaces that table rather than adding to it, withsithbitd‘s example — which already showed the Azure flavor — as the template the others follow by name. Two settings had never been documented anywhere: sithbitd’simap.hostnameandpop.hostname, which unlike the SMTP listeners’hostnameare never discovered from the chain, and they now have reference rows. Among the new keys onlyregion(S3) and the Azureaccount/table/queue_prefixhave defaults; the rest are required, and the examples say which is which. -
The RFC-updates recon page’s audit cites carry both halves again, and the gate now holds them there. The page’s own method note promises
file:linecites, but 18 of them had dropped the file half — a line number alone against a file named a phrase or a paragraph earlier, exactly the form whose line moves while nothing reds. Every cite now names its file, and the page joins the page-less-pointer fence’s roster as its first book page, so the 19th cannot land. The assessed-files record that had deferred it (“a rewording job first, a roster edit second”) now points at the roster entry instead of naming a candidate. See RFC-updates recon — 2026-08. -
The three participant-beacon rows stop naming CLI commands they don’t link. In the
MailInstructionvariants table, theCreateParticipantBeacon/UpdateParticipantBeacon/CloseParticipantBeaconrows citedcampaign create,campaign update, andcampaign closeas bare code spans, even though every sibling row whose command has a reference page links it — and Campaigns has carried all six subcommands as anchored sections since it landed. Each row now links its own section in the same linked-command form thefrombox reclaimanddomain attest-senderrows use. Thecreaterow keeps its item 43 concept link alongside the new one: the concept appendix and the CLI reference answer different questions. Documentation-only, so PATCH: v0.67.1.
2026-08-10 — v0.67.0 (the compose 429 becomes alertable on its own, the gate fences the artifacts its roster implies, and the CLI’s own help stops teaching short flags)
-
A refused compose now has a series an operator can alert on directly. Until now the only way to see the account API’s outbound-quota refusals was to filter
sithbit.api.refusalsbyroute="/v1/mail/send"— and that filtered series is not even quota-only, since any other 429 answered on that route lands in it too. There is now a dedicatedsithbit.api.compose_quota.refusals, incremented at the quota gate itself, carrying no labels at all: a counter you must filter to read has not solved the problem it exists for. The blanket counter is untouched — same name, same labels — so one refused compose increments both instruments and the two must never be summed. See Outbound quotas and suspension. A flat zero on the dedicated series means enforcement is off ([quota] enabled = falseshort-circuits before the counter), not that nobody is over quota. -
The docs gate fences the
iac/appconfig/artifacts against the roster that generates them. Theappconfig-genservice roster was already diffed against the configuration reference’s, but nothing checked that a row’s artifacts exist. Both are now required for every row — the AWS base and the Azure override — becauseappconfig-genitself requires both at generate time, so a row carrying one is a build that cannot succeed; an artifact no row names is drift in the other direction. A missing artifact directory is a distinct, louder failure than a missing file, because absent it the fence would report “no orphans” while blaming every roster row for a defect none of them has. -
The gate now records the widening decisions it has already refused. Its page-less-pointer fence reads a hand-kept roster of files rather than sweeping the tree, because one look-alike — a port written with no host in front of it — cannot be told from a real finding by shape. That roster now carries an assessed-files record: which files were read, which were refused and why, and one refusal that is permanent rather than pending a rewording (a page that quotes the banned shape in order to document it cannot be fenced against writing it). The roster is also held to its own stand-ins, so a half-added entry names itself instead of failing nine fixtures with a message about a missing file.
-
The CLI’s
--helpoutput stops teaching short flags. Eleven examples insithbit --helpspelled a flag short — ten-kand one-x. The standing rule that documentation uses long-form flags now covers any example that surfaces in help output, not just the prose in this book. Two of the eleven sit behind non-default features, so a default build never compiled them. -
Two participant-beacon rows name the CLI commands that ship.
UpdateParticipantBeaconandCloseParticipantBeacondescribed what they do but named no command, thoughcampaign updateandcampaign closeare both in a default build. See the instruction reference.
2026-08-09 — v0.66.2 (the gate stops writing the pointer shape it forbids, a second service roster joins the fence, and three pages stop promising work that shipped)
-
The docs gate no longer writes the citation shape it refuses.
check_config_citations.pyhas long refused a page-less line pointer — a bare:412with no page in front of it — because the line moves and nothing reds.check_timelock.pywas the largest single pocket of that shape in the tree and was not one of the files the fence scanned: fifty-two of them, and five had already rotted, naming lines that no longer carry the figure they claimed. They now name sections, the file has joined the roster, and the two checkers hold each other’s prose to one standard. Rewording forced a re-measurement in four separate phases — a pointer spelled as a number lets you describe a place without ever looking at it. -
The pointer fence also stops half-reporting. Writers chain these pointers (
:52/:54/:56), and the fence’s head member always matched, so a chained run did red — but the message names one token per finding, so a fixer who edited the token it named left the tail members live and the next run went green over them. The anchor class now admits/and-, measured at zero new findings across all four rostered sources. What keeps the precision is the character kept out of the class: a slice, a port, a clock time and a ratio are every one of them digit-preceded, and a digit never precedes a real pointer. -
The gate’s own count words are fenced. The docs README states how many citations it writes, and that number had gone stale twice — once inside a single session, when a correction was invalidated by its own rewording.
check_timelock.pynow holds it, and holds the unit with it: occurrences, distinct page-and-line pairs and allowance-table rows are nine, six and five here, three figures for one idea, which is exactly how the word rotted. A number word with no stated unit can no longer be written in that slot. -
appconfig-gen’s service roster is fenced against the reference’s. Two independent lists of the config-taking binaries have coexisted — the configuration reference’s nine, and the sixappconfig-genwrites the Azure kvset artifact from — with nothing checking they agree. They are now diffed by service name, which is the only correlation that survives their disagreements: the two schemas use the same field names for different things, and they list their rows in different orders.sithbit-gatewayneeded a recorded exception on both halves, its env prefix and its file stem, and the exception table is itself fenced against going stale. The exclusion list beside it gained the same treatment: an entry excusing a binary the tree no longer has is now reported rather than left reading as a standing decision. -
Three pages stopped promising work that has shipped. The program reference’s
CreateParticipantBeaconrow said CLI authoring was still to come; it namescampaign createnow. The account API page’s two refusal sections named no metric and now cross-referencesithbit.api.refusals— precisely, because the compose-quota429lands in that counter under its own route series rather than joining the three gated ones. And the IMAP server crate’s docs, alone among the three protocol crates, still described its production storage as forthcoming. -
The SMTP and IMAP crate READMEs name the daemon that runs them. Only
pop_servermentionedsithbitd; a reader arriving at either of the others learned about the standalone binary and never learned the combined daemon is the usual way to run it. The SMTP note deliberately departs from the POP model’s “same handler, different backend” shape, because it is not true there — the daemon spawns both SMTP roles from one config and wires DMARC reporting seams the standalone binary leaves unset. -
One last short flag. The compute-unit appendix’s
mail send -frow reads--from. The v0.66.1 entry below claims everysithbitexample in the book uses long flags; that cell was believed to be pinned short by a checker reading it, which turned out not to be true of that checker. The claim is now literally correct rather than nearly so.
2026-08-09 — v0.66.1 (the CLI examples all speak long-form, two more gated commands say they are gated, and the gate’s three hand-kept rosters are fenced against the tree)
-
Every
sithbitexample in the book uses long flags. The convention has always been long-form in examples with the short spelling named in the argument list, but eleven pages had drifted from it across thirty sites —-k,-y,-x,-oand-s. Every long form was verified against the clap definition inmail_clientrather than assumed. The house style is now uniform and worth stating once: examples and synopsis blocks are long-form only, argument lists name the short form parenthetically, and no page carries a short-first-x, --longentry any more.solana address -kin the devnet vanity-ID appendix is the Solana CLI’s own flag and deliberately stays short.mailbox create-certalso had its synopsis and argument list normalized, and itsclient_cert_authsentence harmonized with the phrasing the two client pages already used. -
The two
rand-gated command pages say so.mailbox reading-secretandmailbox key create/key setridemail_client’srandfeature and never mentioned it. Both now carry a build note, written in the direction that helps:randis on by default, so a stock build already has these commands and there is nothing to enable — only a--no-default-featuresbuild that leaves it out drops them. The neighbours that are not gated (credentials,sign-text,key get,key close) are named too, so the note is not read as covering a whole page. -
The rate-limit section points at the counter that observes it. The per-wallet mutation budget explains the 428/429 charge order but named no metric, so a reader deciding how to size the budget had nothing to measure with. It now cross-references
sithbit.api.refusalsand the monitoring page, worded “per route template — not per method” so it cannot be read as claiming the counter separates a refusedPUTfrom a refusedDELETEon one route. It does not, by the deliberate design v0.66.0 recorded. -
The docs gate’s three hand-kept rosters are fenced against the tree.
check_config_keys.py’sSERVICESandcheck_config_citations.py’sSITESandSTEP_UP_PAGESwere each a list nothing checked for membership: a new config-carrying binary, a fifth listener readingclient_cert_auth, or a fourth page quoting the step-up nonce prefix could all appear and no leg would notice. Each roster is now diffed against the tree both ways. The per-page copy counts stay hand-kept on purpose — deriving them from the very scan they fence would dissolve the anti-vacuity guard they exist to be. A fourth fence refuses page-less:Ndoc pointers in the gate’s own sources, the rotcheck_timelock.py‘s pinned-citation guard structurally cannot see. All four ride invocations the gate already runs, so it stays at nineteen legs; the two checkers’ self-tests grew to twenty-nine and forty-five cases.mail_docs/README.mddescribes the widened ownership, and its claim that renaming a real source reds one leg alone now covers the step-up fence too, having finally been run that way. Docs-tooling and prose only, no behavioral change — so PATCH: v0.66.1.
2026-08-09 — v0.66.0 (POP3 stops freezing its SASL list at connect, the account API’s refusals become countable, and the step-up nonce prefix is fenced past the Rust boundary)
-
POP3 offers SASL EXTERNAL after an STLS upgrade. The mechanism was frozen at connection start, so a plaintext POP listener with
[pop.tls]andclient_cert_auth = truenever advertised or accepted EXTERNAL no matter what happened on the socket afterwards — it reached POP3S (995) only. POP now gates the offer the way IMAP and submission always have, on the listener’s client-auth mode and the live TLS channel, re-read at everyCAPAandAUTHrather than computed once. The same connection that was refusedAUTH EXTERNALin the clear is offered it, and logs in with it, the moment STLS completes. Client-certificate auth (SASL EXTERNAL) loses its POP caveat and gains the reason the offer can appear mid-connection; thepop.client_cert_authrow now differs fromimap.client_cert_authonly in protocol spelling. POP3S behaviour is unchanged. An additive listener capability that changes what an existing deployment offers on the wire, so MINOR. -
The account API’s refusals are countable now. The monitoring page told operators to count 428s and 429s off the fronting reverse proxy’s or ingress’ access log, because no
sithbit.metric covered the account API’s HTTP surface at all — v0.65.1 below says so out loud. One counter joins the metrics table:sithbit.api.refusals, labeledstatus(the integer code, per the OpenTelemetryhttp.response.status_codeconvention) androute. It is a single layer over the fully merged router rather than a branch inside the error type, which buys two things — the route label, which needs the request that the response conversion never sees, and coverage of the refusals no handler produces: an extractor rejection on malformed JSON, the 404 an unrouted path earns, the 405 a wrong method earns. All of them land in the one counter deliberately — each under its ownstatus/routeseries, so a panel can keep them apart or sum them — because an operator alerting on refusals wants the client sending malformed JSON beside the one sending none.routeis the matched route template, never the request path, so a wallet or message id in a segment cannot explode cardinality, and anything matching no route collapses into the single<unmatched>series — a[[static]]mount’s 404 included, since a nested service carries a private matched-path type the public extractor cannot see. A new operational surface deployments will want to scrape and alert on, so MINOR. -
The refusal guidance names the metric, and warns off two wrong dashboards. Outbound quotas and suspension now points at the counter instead of an access log, and states the two traps a panel built on it falls into. First, the
routelabel carries no HTTP method, so the five step-up-gated method/path pairs collapse onto three series per status —/v1/account/password,/v1/account/auth-epoch,/v1/account/pin-provider— and a refusedPUTof the password is indistinguishable from a refusedDELETEof it. Second, the budget is charged in front of the handler holding the step-up gate, so a spent window answers 429 before a 428 can be reached: under sustained abuse of a gated route the two statuses replace each other rather than rising together, which makes the sum of both the honest “sensitive-mutation refusals” signal and the split the diagnostic one. The alerting split itself is unchanged from v0.65.1 — a 429 spike is the control working, a 428 spike means clients cannot sign. -
The prefix the book tells you to sign is checked against the one the API mints. Step-up challenges hand you a nonce beginning with a fixed prefix, and you sign that string verbatim — so every place the book writes the prefix out is a string a reader pastes into a signer, not a description of one. Seven such copies were spread over three pages (the account API reference, the recipient PIN provider guide, and the CLI’s mailbox-credentials page) with nothing tying them to the constant in
account_api. Renaming it would have left all seven telling people to sign bytes the API never issues, silently, with every checker green.check_config_citations.pygained a third section comparing each copy against the constant byte for byte — the trailing space included, since the nonce is a bare concatenation — and holding each page to the number of copies its roster claims, so a copy quietly reworded away fails as loudly as a copy quoting a prefix that was never minted. Nothing on the three pages needed an edit: they already agreed, so the fence locks in a truth rather than correcting a lie, and renaming the constant now reds the docs gate until the copies move with it. Two design points, because the obvious implementation misses both: it matches step-up-shaped prefixes and then compares, since a search for today’s exact string would go quiet on a rename at precisely the moment it should report the copies that rename stranded; and each page is flattened to one line before matching, so a copy an editor’s reflow left straddling a line break still reads as one literal — a fence that reddened on rewrapping is a fence people delete. A constant renamed away or declared twice, or a value the fence’s shape no longer recognises, is exit 2: a fence that cannot read its own anchor must not blame the docs. This page’s own quotations of the prefix are deliberately not fenced, because dated prose-of-record is never retro-edited — which is why the roster is an explicit page list rather than a tree-wide search. It rides thecheck_config_citations.pyinvocation the gate already runs, so the docs gate stays at nineteen legs while the checker’s--self-testgrows from fifteen cases to twenty-four. Docs-tooling only — no page prose changed and no leg was added — so this part is PATCH; the section’s version comes from the two entries above it.
2026-08-08 — v0.65.1 (the step-up 428 reaches every page that meets it: the two extensions, the glossary, the operator’s refusal table and the budget’s own section)
-
The extension pages say a rotation can be refused, and say it GUI-first. Thunderbird and Outlook walked through rotating the wallet mail password without mentioning that the epoch bump is one of the account API’s step-up gated calls, shipped at v0.63.0 — so a user who met
428 Precondition Requiredhad nowhere in the book to read what it meant. Both pages now say it from the side a user actually stands on: the pane fetches the challenge and has the wallet sign it, which is the Phantom or Ledger approval prompt that appears at that moment, so the case that surfaces in the extension is only the one where no signature could be produced — a locked or disconnected wallet, a declined prompt, a challenge gone stale — and the pages quote the pane’s one sentence verbatim, so searching it lands here. The remedy is written as three acts rather than a wait: unlock or reconnect, arm the control again, approve. Waiting is explicitly not the remedy, because each attempt spends its own challenge and a retry needs a fresh signature, never the previous one. And a refused rotation changes nothing — the epoch stays where it was and the password the client already holds keeps working. -
The glossary carries the same fact, definition-shaped. Auth epoch is where the term glyphs land a reader, and it described what a bump does without saying what a bump takes; it now closes on the 428 and the nothing-changes guarantee in three sentences. That insert pushed every heading below it down the page, which moved
check_timelock.py’s two hardcoded glossary scopes and the pinned citationmail_docs/README.mdcarries — docs tooling, not page prose, and both halves had to move in the same commit, since either one alone exits 2. -
The operator’s refusal table can tell the two refusals apart. The wire-refusal table carried only the 429 +
Retry-Aftershape for the five gated mutations. A second row names the step-up refusal — HTTP 428 plus{"error":"step_up_required"}, and deliberately noRetry-After, because the remedy is a signature rather than a wait — with two paragraphs of alerting guidance beside it, since a 428 spike and a 429 spike demand opposite responses and the table has no “what to do” column. Recon result stated plainly on the page because it surprises: neither refusal is metered. The account API installs no request-tracing layer and exports no metric over its HTTP surface, so both are counted off the fronting proxy’s or ingress’ access log, by status against the five gated paths. The page also rules out the wrong cause by name — a 428 is not client clock skew. A challenge’s expiry is stamped at issue time from the issuing replica’s clock, so what eats into its 300-second life is skew between API replicas; the client’s clock never enters the judgement. -
The per-wallet budget bounds attempts, not changes.
[rate_limit]read as though only a success or a 429 charged the budget. The budget is applied as a tower layer while step-up is a per-handler extractor, so the charge lands in front of the gate: the 428 that asks for a signature costs exactly what a success costs (why). A fifth property spells out what that buys the person choosing the number —max_per_window = 30is 30 real changes for a client that fetches a challenge and signs its very first send, but 15 for one that tries the mutation bare and steps up on the cue, since that client spends two slots per change. Only the client re-sending the bare request in a loop is punished as intended: it never succeeds and finishes on a 429. -
And the configuration reference’s nine example-TOML bullets stop being hand-maintained. The page’s intro names each canonical annotated TOML by path, a roster nothing checked — the sibling of the count word fenced at v0.63.1.
check_config_keys.pynow diffs those bullets againstSERVICES[*]["file"]both ways: a service whose file the page never names, and a bullet naming a file no service carries, are each drift. It is narrowed twice, like the count — the page preamble only, and only the first bullet run after the “canonical per-key documentation” sentence — because the page carries a dozen further bullet lists, several of them backticked paths, that a looser matcher would sweep in; and an intro the checker cannot read (that sentence missing or doubled, no bullets after it, a bullet that is not exactly one backticked path, the same path bulleted twice) is exit 2 rather than a quiet pass, the convention the roster cases already follow. The page itself needed no edit: its list already matched, so the fence locks in a truth instead of correcting a lie, and adding a TOML-taking binary now reds two intro assertions until theSERVICESentry, the bullet and the count word all move together. The checker’s--self-testgrew from 13 cases to 22, the fence rides the invocation the gate already runs, and the docs gate stays at nineteen legs. Every change in this entry is documentation or docs-tooling describing behavior that already shipped — no instruction, route, wire contract or default moved — so PATCH. The protocol state is still the one v0.65.0 left, and by the preamble’s rule that a run of documentation-only edits keeps one version across several dated sections, this section repeats the stamp the section below it already carries rather than inventing a second: v0.65.1.
2026-08-08 — v0.65.1 (the client-certificate section’s read-site claim is fenced against the four sources it names)
-
A sentence about code stops being maintained by hand. Client-certificate auth (SASL EXTERNAL) states, as of v0.65.0, that each binary reads
client_cert_authin exactly one function —listener_tlsinsithbitd,load_tlsin the standalonepop-server/imap-server/smtp-server— and each of those four functions carries a doc comment asserting it is that one place. Nothing checked either side, so a rename, a moved marker, or a second branch on the flag falsified the page in silence while every checker stayed green.check_config_citations.pygained a second section that diffs the sentence against those four Rust sources both ways: a function the page names that no longer exists, a marked function the page never names, a marker sitting on a function that does not branch on the flag, and a second decision site all fail the docs gate. Passing the flag along —sithbitd’s three call sites — is deliberately not a read; a secondiformatchon it is. -
It is a second section in an existing leg, not a new gate step. The fence rides the
check_config_citations.pyinvocation the gate already runs, so the docs gate stays at nineteen legs; the checker’s--self-testgrew from five cases to fifteen, and the read-site half is proved against generated stand-ins in the two real shapes rather than copies, so reformattingsithbitd.rscannot red it. A claim sentence that is missing, doubled, or names no readable function in binary pair is exit 2 — an anchor the checker cannot read is a defect in the fence rather than a clean bill of health — the same conventioncheck_config_keys.py’s intro-roster cases follow.mail_docs/README.md’s owns-table row, which still described this checker as “RFC numbers only, nothing else … one way”, now describes both sections. Docs-tooling only: no page prose changed, no exit code moved for anything already green, and no leg was added — so PATCH: v0.65.1.
2026-08-08 — v0.65.0 (SASL EXTERNAL is advertised if and only if the listener really requests a client certificate)
MINOR — an enforcement-default change that affects deployments (the class widened at v0.10.0). No instruction, route, or account layout moves; what changes is which mechanism a listener puts on the wire.
- A listener can no longer offer an EXTERNAL that no client could complete.
Each of the four binaries reads
client_cert_authin exactly one function —listener_tlsinsithbitd,load_tlsinpop-server/imap-server/smtp-server— and that function returns the TLS acceptor paired with the client-auth mode it was built with. Every handler gates the offer on that recorded mode, never on the config boolean. The pairing is necessary rather than stylistic: rustls keeps a finishedServerConfig’s client-certificate verifier private, so a built acceptor cannot be asked after the fact whether it requests certificates — the fact has to be recorded where the acceptor is chosen. Previously the flag and the acceptor could disagree, and clients would see the offer, attempt it, fail, and on IMAP burn a login attempt each time. That state is no longer reachable by configuration. - Client-certificate auth (SASL
EXTERNAL)
states the invariant instead of the advice it replaces — the page used to tell
operators to read an offer as “the operator turned this on”, not as proof the
handshake asked for a certificate, which is now exactly backwards. It also
says plainly what the toggle does not do: it defaults to
false, and turning it off leaves the password mechanisms (PLAIN, LOGIN, CRAM-MD5, APOP) untouched — only the one mechanism the transport cannot back is withheld. - The guarantee is bounded, and the page says so three ways. It is per
listener —
sithbitd’s copies of the flag are read independently and nothing cross-checks them, so on-for-IMAP-off-for-POP is valid and silently accepted. It prevents a false offer, not a silently absent one:client_cert_auth = truewith no[*.tls]section is still inert and warns nothing. And it governs advertisement and completability, not authorization — whether the wallet a certificate proves is one this daemon will serve is the verify step’s business, unchanged here.
2026-08-08 — v0.64.0 (the CLI can answer a step-up challenge: sithbit mailbox sign-text)
MINOR — an additive CLI surface over the existing step-up contract (precedent v0.9.0). No new instruction, route, or wire change: the v0.63.0 step-up gate is unmoved. What is new is that a shell can now mint the proof it asks for.
sithbit mailbox sign-textjoinsmailbox credentialsandmailbox reading-secreton the Mailbox credentials page as their third neighbour: the raw signer, base58 of the wallet’s ed25519 signature over the exact UTF-8 bytes it is handed, with no prefix, hash, or trim of its own. It ships in the default feature set and is fully offline — no RPC endpoint, no chain read, no HTTP call — so it runs on an air-gapped machine holding the wallet. The reference documents the stdout/stderr split that makes it scriptable (the signature alone on stdout, theSigned as <address>:line on stderr, so a shell capture is exactly the header value), and states the rule the raw-signer design implies: sign only text a server just handed you, verbatim. Domain separation is the challenge’s job, which is why a signature collected at login can never be spent as a step-up proof, or the reverse.- The per-recipient pin-provider walkthrough is scriptable end to end.
Configuring a
provider said of
its step 2 that any ed25519 signer would do and that no
sithbitsubcommand minted one — true when it was written, and now false. Step 2 is the real command, between the twocurlcalls it always had, with the verbatim-nonce trap called out (one stray trailing space is a different message) and the reminder that a failed attempt still spends the challenge, so a retry re-runs step 2 against a fresh nonce. - Not a CLI-only capability, and the docs say so. The browser clients sign
the identical bytes in the page with the wasm wallet — the two signers are
pinned to the same test vectors on both sides, so neither can drift from the
other unnoticed — which is how the webmail settings pane’s buttons already
answer their own challenges.
sign-textis for the surfaces that have no button: a shell, a script, a provisioning job.
2026-08-08 — v0.63.1 (the configuration reference’s “Nine binaries” is fenced against the checker’s own service roster)
- The count word in the configuration reference stops being
hand-maintained. How a setting
resolves opens by counting
the binaries that take an annotated TOML file of their own, and that English
number had already been wrong once — v0.62.1 corrected it from four to nine
after v0.61.1 added the three standalone protocol servers, a drift that went
unnoticed for eight sessions.
check_config_keys.pynow fences it: the count word must equal the service roster the checker already derives every other count from, so adding or dropping a binary fails the docs gate instead of leaving the sentence behind. The match is narrowed to that one section and that one phrasing, because the page also says “Nine behaviours decide whether the defaults suit your deployment” and a looser fence would read it as a second roster. A roster the checker cannot read at all — unsupported number word, sentence missing or doubled, heading renamed — exits 2 rather than passing quietly, on the principle that an assertion the checker cannot make is a defect in the fence, not a clean bill of health. The checker’s self-test grew six cases to cover that contract, andmail_docs/README.mddescribes the widened ownership. Docs-tooling only — no doc prose changed and no exit code moved for anything already green — so PATCH: v0.63.1.
2026-08-08 — v0.63.0 (a valid session stops being enough to change a mail credential: the five sensitive account mutations demand a fresh wallet signature)
-
Step-up authentication, documented as the wire contract it is. The account API’s five sensitive mutations — both
/v1/account/passwordwrites, both/v1/account/pin-providerwrites, and the auth-epoch bump — now want proof of present control of the wallet key on top of the JWT, and Step-up: proving present control of the wallet is the new reference section for it.POST /v1/auth/step-up(bearer only, no body) mints a challenge —"SithBit step-up nonce: <uuid>", living 300 seconds, a constant in the code with no configuration knob — the wallet signs that string’s raw UTF-8 bytes, and the base58 signature rides anx-sithbit-step-upheader on the mutation itself. A gated route reached without a usable proof answers428 Precondition Requiredwith the byte-exact{"error":"step_up_required"}and noRetry-After. The four statuses a client branches on are set out as a table:401the session is over, log in again;428the session is fine, re-sign with the token you hold;400the header is not a base58 ed25519 signature, and the challenge survives an honest retry;429the budget is gone, honourRetry-After. -
Three properties a client has to be built around, each stated where a client author will meet it. The challenge is consumed whatever the outcome and a proof spent on one route cannot be replayed onto another, so it is one round trip per mutation — a UI applying three gated changes needs three challenges. Login and step-up challenges share the account’s single nonce slot and are told apart by a kind prefix checked where each is consumed, so neither can ever be spent as the other, in either clobber order. And a 428 still spends the per-wallet budget, because the limiter is charged by a layer in front of the gate — which makes retrying a bare 428 the one thing a client must not do.
-
The refusal table counts three. What was “Two 429s, and only one carries
Retry-After” is now the table under Which refusals carryRetry-After: the 428 joins the mutation budget’s 429 and the outbound quota’s, and it omits the header for a different reason than the quota does. The quota cannot state an honest delay; the step-up gate has no delay to state at all, because no amount of waiting turns a 428 into a success. BothDELETE /v1/account/passwordand the epoch bump gained the new status in their own response lists, and the bump’s guard bullet — which used to say the JWT was “the whole gate — no separate re-challenge, and no rate limit of its own” — is corrected on both counts. -
In the browser clients nobody sees any of this. The webmail settings pane fetches the challenge and has the wallet sign it as part of the action, so rotating the wallet mail password and removing the stored password now say what the pane says — your wallet signs a one-time confirmation as you continue — including that the signature is asked for at the confirming press rather than at arming, that declining it leaves the account untouched, and that an in-app wallet signs silently while a Phantom or Ledger one shows its approval prompt. The settings overview says the same for Set password, and the rotate screenshot’s alt text carries the pane’s new closing line. A reader never meets the wire’s
step_up_required. -
Except for pin providers, which have no GUI to hide it. Configuring a provider is the one gated surface a recipient drives from their own tooling, so that page now shows the three-step dance in full — challenge, signature, then the write carrying the header — and every
curlexample on it grew thex-sithbit-step-upline it would otherwise be refused without. TheGETis deliberately not gated: it reveals nothing a token holder cannot already learn, and gating it would cost a wallet signature per poll.A new enforcement default on an existing public surface, which every client of the account API has to be built against, so MINOR by the v0.10.0 widening rather than PATCH: v0.63.0.
2026-08-08 — v0.62.1 (the configuration reference counts to nine binaries; the citation checker’s prose stops saying six)
-
The configuration reference names every configurable binary now. The page’s opening file list and its How a setting resolves section both named four binaries —
sithbitd,account-api,domain-sithbit,mail-grpc— while the layering they describe has applied to nine since v0.61.1 added the three standalone protocol servers. Both carry the full nine, and the prose that used to enumerate file names,*_CONFIGvariables and env prefixes inline — three parallel lists a reader had to zip together — is replaced by one Binary / config file / config-path variable / env prefix table. Beside it, a note for the two binaries that layer their configuration identically but ship no annotated example file because their whole surface is a short table:sithbit-consoleandsithbit-migrate. -
And the
.envsection stops under-counting. Which services get a.envlisted five crates shipping a sample;pop_server,imap_serverandsmtp_servership one too, and now say so. The exceptions bullet beneath it gainssithbit-console,sithbit-migrateand the repo-side generatorappconfig-gen, which is what makes its closing line — every remaining workspace crate is a library with nothing to configure at runtime — true rather than an overclaim that quietly swept up three binaries. -
The docs gate’s own prose caught up.
check_config_citations.pydescribed itself, in four docstrings, as fencing “the six canonical example TOMLs”, and its RFC 2595 allowlist comment namedsithbitd.example.tomlas the sole citer. Both had been wrong since v0.61.1: every count in the checker derives fromSERVICES, so it was already reading nine files and reporting nine while its prose said six, andpop_server.tomlcites 2595 for the same POP3require_tlsdefault. Comment-only — no logic, no allowlist data, no exit code moved — andmail_docs/README.md’s two matching “six“s went with it. Documentation and docs-tooling prose only, correcting how already-shipped behavior is described, so PATCH: v0.62.1.
2026-08-08 — v0.62.0 (the cross-connection login budget reports itself; the account-mutation 429 reaches the refusal reference)
-
The cross-connection login budget is observable now. The
[auth_rate_limit]table introduced at v0.60.0 enforced in silence — nothing told an operator how many (client address, account) pairs it was holding, or how often it was refusing. Two instruments join the metrics table:sithbit.auth.rate_limiter.tracked_pairs, an unlabeled gauge of the pairs currently tracked — registered once per limiter as it is built, and exactly one live limiter reports per process (sithbitdbuilds each protocol handler’s default limiter and then replaces it with the single shared table; a replaced instance goes silent rather than reporting beside its successor) — andsithbit.auth.rate_limiter.refusals, a counter labeledprotocol(pop3/imap/smtp), counted at the drivers’ refuse sites. The two SMTP roles deliberately share the onesmtpseries: MX and submission answer a refusal byte-for-byte the same (v0.60.0 below), so separate series would draw a distinction the wire itself refuses to make. What to watch: a rising refusal count is the control working, and the gauge approachingmax_tracked(default ten thousand) warns that the fail-open path — new pairs going untracked — is near. A new operational surface deployments will want to scrape and alert on, so MINOR. -
The account-mutation refusal is on the monitoring page now. The wire-refusal table — the debugging reference for what a refused account sees, per surface — gains the credential-mutation row: over the per-wallet
[rate_limit]budget is HTTP 429 plusRetry-Afterin delta-seconds, floored at1, sitting one row under the compose 429 that deliberately carries no such header — the confusable pair v0.60.0 explains, now told apart at a glance in one table. -
Internal: the account API reads the auth epoch off the account row it already fetched instead of making a second point-read — one read answers for the whole response, so there is no between-reads window in which a vanished row could 404 a live account — and the outbound suite now pins that a suspended account’s IMAP wallet-signature login (the self-proving path that skips the stored-secret lookup) is refused with the same distinguishable
[CONTACTADMIN] account disabledshape as the password path. Neither changes a wire contract.
2026-08-08 — v0.61.1 (the standalone servers’ config files reach the reference, and the gate holds them there)
-
The three standalone servers’ TOML files are documented now.
pop_server.toml,imap_server.tomlandsmtp_server.toml— the files the dev/pilot protocol servers read — were the last configuration surface with no page in the book: every key lived only in the shipped files’ comments. They get one combined Standalone protocol servers section in the configuration reference — a shared intro for what the three have in common (the same layering as the other binaries, the keyssithbitdnests under[pop]/[imap]/[smtp]sitting at top level here, each binary reading its own[auth_rate_limit]), then a key table per binary, with the shipped files’ deliberate dev exceptions (require_tls = false, SMTP’ssender_auth = "none") called out where they diverge from the documented defaults. -
The config-key docs gate covers them.
check_config_keys.pynow reconciles nine services and 356 keys — the six canonical example files it has fenced since v0.16.1 plus the three standalone TOMLs. Because the three share one H2, the checker learned H3-aware sub-regions: an H3 naming a service starts that binary’s own region, while every other H3 stays part of its enclosing section. The new fence was adversarially probed in both directions before being trusted — a key deleted from a TOML, a bogus doc row, and an undocumented top-level key each turned the leg red naming the offender, and each probe was reverted byte-identical. Documentation and docs-tooling only, no protocol surface moved, so PATCH: v0.61.1.
2026-08-08 — v0.61.0 (a config typo now stops the dev servers; four term glyphs that were never links)
-
A misspelled key in a dev server’s config now stops it starting, instead of being ignored. The three dev/pilot protocol servers —
pop-server,imap-server,smtp-server— read their TOML through a wrapper that quietly discarded any top-level key it did not recognise, sohostnmae = "mx.example.com"parsed clean and the server ran onlocalhostwhile the operator read their own file and believed otherwise. All three wrappers now reject unknown top-level keys — the samedeny_unknown_fieldscontractsithbitd’s[*.server]tables have carried all along: startup fails with an “unknown field” error naming the key and listing the ones it accepts. This is deployment-observable in the direction that matters — a file that started a server yesterday can refuse to start it today, and it refuses on exactly the files whose typo was doing nothing the operator wanted. Read the error, fix the key. One gap is deliberately left: the[[accounts]]/[[mailboxes]]dev-fixture tables do not deny unknown fields, so a typo inside one of those is still swallowed. A new enforcement default that refuses a startup which succeeded before, so MINOR. -
The dev servers’ example files now spell out the cross-connection login budget.
pop_server.toml,imap_server.tomlandsmtp_server.tomleach carry[auth_rate_limit]commented out with its real defaults — ten failures per fifteen minutes, ten thousand tracked pairs — plus a note on what changes when the block is carried intosithbitd.toml, where one table serves every listener. Nothing about the limiter itself changed: it was already in force on those binaries, with no sign of it in the file you edit. The configuration reference’s account of that same bullet is corrected on two points in the same pass — those files are no longer silent about the table, andsithbitd’s warning about a per-protocol section it is ignoring fires only when that section was tuned away from the defaults, so a block carried across unchanged is dropped without a word. A quiet boot means no tuning was lost, not that no per-protocol section was there. -
Four client term glyphs are links now, and the gate can see the next one that isn’t. The webmail, Chrome, Outlook and Thunderbird marks in the prose — the four GUI-client icons — rendered and did nothing, because the term→chapter map carried no entry for them; each now opens its own chapter under Mail clients. The docs gate had only ever checked that the entries it found resolved, so a term with no entry at all was invisible to it: deleting one of the four left the leg green. It reads the prose too now and fails on a term the map does not carry, which is what stops the next inert glyph lasting months.
-
The glossary defines the credential a mail app actually stores. Wallet mail password — base58 of a signature over a fixed challenge, presented with the wallet address as the username — was the one term in the login story with no entry of its own, so a reader looking it up found only the auth epoch that retires it. The new entry says what it is derived from, why the servers keep no secret for it, how it differs from the operator-held stored mail password, and that a login may append the session reading secret after a
.separator.
2026-08-08 — v0.60.0 (the sensitive account mutations get a per-wallet budget; the mail protocols get a cross-connection one)
- Changing an account’s mail credentials now costs allowance. The three
account-API routes that can change or revoke the credentials a mail app logs
in with — setting or removing the stored mail password, the pin-provider
writes, and auth-epoch rotation — sit behind a per-wallet budget, on by
default at 30 attempts per 300 seconds, and answer
429 Too Many Requestsonce it is spent. It is one count per wallet shared by all five method/path pairs, not one per route, 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. Nothing else is limited: the reads, the wallet-challenge login, the timezone and do-not-disturb routes and compose are untouched. The refusal carries aRetry-Afterheader in RFC 9110 delta-seconds form, floored at1(aRetry-After: 0would invite the immediate retry the budget exists to prevent), and its body stays the API’s ordinary{"error": …}prose with no machine-readable field — the status code is the branch, and the delay already has a standard channel in the header. Two consequences an operator should plan for rather than discover: the counters are in-process and per-replica, so two replicas behind a load balancer grant a wallet two budgets, and an unauthenticated request is never charged — it names no wallet, gets the usual401, and is therefore not slowed by this control, which leaves anonymous hammering of those paths to the network layer in front of the API. The full contract is under Rate limits on the sensitive mutations, the knobs under[rate_limit]. The same page now also explains why this API’s other429— the compose route’s outbound quota — deliberately carries noRetry-After: that one counts a rolling hour/day total over hour-bucketed counters, so when allowance returns depends on which past bucket ages out, and any delay it printed would be a guess a conforming client would honour at exactly the wrong moment. A new enforcement default that refuses requests which succeeded before, and which deployments must size per replica, so MINOR. - Guessing a mail password now costs allowance that outlives the
connection. POP and IMAP have carried a per-connection login budget
(
max_login_attempts, spent and forgotten when the socket closes) — so a guesser who reconnected after every third try kept a steady rate forever, and on SMTP there was no budget of any kind. A new[auth_rate_limit]table closes that: the (client address, account) pair is remembered, and once it has spent its failures every further attempt is refused unchecked — no store lookup, no password comparison. On by default at ten failures per fifteen minutes, and the recovery story needs no operator at all: the ban lapses by itselfwindow_secsafter the pair’s last counted failure (refusals in between do not push it out), and a successful login forgets the pair immediately. It keys on the pair rather than the address alone, so one hostile login cannot lock out a co-located neighbour, and on the effective client address, so a listener behind an L4 balancer must haveproxy_protocolon or every user of that balancer shares one bucket.sithbitdruns one table for the whole daemon — a per-protocol section still parses but is ignored, with a startup warning naming it, because private budgets would let a guesser rotate POP → IMAP → MX → submission for four times the allowance; the standalonepop-server/imap-server/smtp-serverbinaries each read their own. Three consequences worth planning for rather than discovering: SMTP has no per-connection sibling at all, so on the submission listener this is the only authentication budget in the stack andwindow_secs = 0costs more there than elsewhere; the two SMTP roles answer out of one budget with one indistinguishable refusal —454 4.7.0then421 4.7.0, byte for byte the same on MX and submission, and sharing the454 4.7.0code with the store-outage reply on purpose so a locked-out client cannot tell a rate limit from an outage (an operator reading logs must go by the message text, since the code alone cannot tell them apart); and the counters are in-process and per-replica, like the account API’s, so two replicas over one store grant two budgets. A new enforcement default that refuses logins which succeeded before, so MINOR — the same reasoning as the entry above.
2026-08-07 — v0.59.0 (a stored mail password can be removed; the idle POP session keeps its deletions)
-
A stored mail password can be removed. Until now a password you had set for your mail client could be replaced but never taken away — the docs said so in four places. The webmail Settings pane now carries an armed, two-step Remove the stored mail password control, shown only while a password is actually stored, and the wire contract behind it is
DELETE /v1/account/password. The control warns before it commits, because the server keeps only a hash: the removed value cannot be shown to you again, and every mail app set up with it stops connecting until you give it a new one. The new prose is explicit about the three things removal does not do — it does not rotate the wallet-derived password, does not sign you out, and does not revoke a client-certificate login. Afterwards the account simply sits on wallet-signature mail login, and a new stored password can be set at any time. -
A POP session that idled too long no longer loses its deletions on a quiet mailbox. The maildrop lease has a 15-minute lifetime, and a session that spent longer than that reading before deleting used to fail every deletion at sign-off — even on a single-instance server where nobody else wanted the mailbox. It now takes the lapsed lease back, provided the key was genuinely free and the mailbox still lists exactly what the session opened on; Scaling out describes both gates. A stolen maildrop, one whose contents moved — new mail arriving counts — and any store that cannot answer are all still refused, so the guarantee that matters is unchanged: no client is ever told the deletion succeeded when it did not.
-
Clicking a term glyph no longer 404s. The small icons that mark defined terms in the prose — mailbox, frombox, alias, domain, pin, POP, IMAP and the rest — are links, and eleven of the seventeen had been pointing at page paths that stopped existing when the reference was reorganised into basic concepts and technical reference. A
pinglyph went to a page that was never rebuilt under that name; amailboxglyph went to a CLI page’s old location. All seventeen now resolve, and the policy behind them is written down rather than left to taste: a term glyph points at the chapter that explains the term, not at a CLI how-to that uses it. Two glyphs that had shared one destination now separate — POP3 and IMAP each land on their own section — andpingoes to Pinning leases. A gate check now fences this, so the next time a chapter moves the build fails instead of the link quietly rotting. -
The glossary defines auth epoch. The counter behind last release’s rotatable wallet mail password now has its own entry, sitting with the other keys and the levers that retire them. It states what a bump actually does — retires every outstanding wallet-derived password at once, on every listener — and, just as importantly, the three things it does not do: it does not clear a stored mail password, does not sign the session out, and does not revoke a SASL EXTERNAL client-certificate login, which proves identity from the certificate rather than from a signature over the epoch.
2026-08-07 — v0.58.0 (the wallet mail password can be rotated)
-
The wallet mail password can be rotated. Until now the credential a mail app derives from a wallet signature was permanent: the signature is deterministic, so a copy of it worked forever and its owner had no way to take it back. Accounts now carry an auth epoch — a counter mixed into the challenge the wallet signs — and bumping it changes the bytes every valid password must sign over, so one bump retires every outstanding wallet-derived password at once, across every SMTP, IMAP and POP listener. The user-facing lever is a deliberate two-step control in the shared Settings pane: an arming button, then a warning naming the cost — every mail app already set up with the wallet-derived password will stop connecting until you paste the new value into it — then Rotate it now or Keep my current password. It is written up, with a screenshot of the armed control, under Rotating the wallet mail password, and the Thunderbird and Outlook extensions and the operator’s enrollment page carry the same pane. The wire contract is
POST /v1/account/auth-epoch— JWT, no body and no path parameter (it always acts on the token’s own wallet), answering200 {"auth_epoch": <new>}— and the current value now rides everyGET /v1/account. Offline, the CLI takes the epoch as an argument:sithbit mailbox credentials --epoch <N>, a page whose whole premise used to be that this credential could not be revoked. The scope is documented as carefully as the capability, because three plausible readings of “rotate” are all wrong: a bump retires the wallet-derived password only, so a stored mail password survives it untouched (and, as of this release, had no delete path of its own — see v0.59.0 above, which gave it one); the caller’s session survives it, the JWT being as unrevoked as it is after logout; and a SASL EXTERNAL client-certificate login is not epoch-revocable at all, since it proves identity from the presented certificate rather than from a signature over the epoch — only the operator’sclient_cert_authcloses that door. A password derived before epochs existed still authenticates an account that has never rotated, which is why--epochdefaults to0; the first rotation ends that grace for that account permanently. New user-facing capability plus an additive API, so MINOR. (The screenshot rig grew the frame that shows the control — and lost a long-standing double-render on the way: re-initialising the settings panes cloned Alpine’s already-rendered output and then rendered it again, so every templated block came out twice, the seven-day do-not-disturb list included.) -
A long POP session no longer loses its maildrop to a peer daemon — and is told plainly when it does. The store’s keyed leases gained renewal: a live lease may be extended, with its expiry set absolutely to a full TTL from now, by one atomic conditional write on all six backends. The POP maildrop session uses it to re-prove ownership before every mutation, so two daemons over one shared store can serve a wallet’s POP without either expunging the other’s messages, and a session that outlives its original 15-minute TTL keeps its maildrop instead of silently losing it. A renew that comes back lost — stolen after expiry, lapsed unstolen, or never held — fails that deletion rather than expunging mail the instance no longer owns, and because POP3 has no untagged channel to warn on, the QUIT reply is the whole signal:
-ERR [SYS/TEMP] some deleted messages were not removedinstead of+OK POP3 server signing off, so a client that would have dropped its local copies on+OKkeeps them. Written up under POP maildrop exclusivity, with the renewal contract in the glossary’s Keyed lease entry. Stated as a known seam rather than glossed: renewal extends only a live lease, so a session that idles past the TTL without mutating anything gets that same-ERRat QUIT even on a quiet single-instance server where nothing else ever touched the maildrop — the safe direction to fail, never a false+OK, but user-visible. Observable new behaviour in the POP surface, so MINOR. -
A misspelled listener setting now fails startup instead of being ignored. The
[*.server]table and itslimitssub-table reject key names they do not recognise, naming the offender and listing the accepted keys, soidle_timeout_seccan no longer leave the 600-second default quietly in force while the operator believes they changed it. The same section also spells out a long-standing surprise:bind_addrhas no default of its own, so uncommenting a single limit and nothing else fails withmissing field bind_addr— the listen addresses documented there are defaults for the whole absent section. Both under[*.server]— the shared listener section. A config that booted before can now refuse to boot, which deployments observe, so MINOR. -
IPFS has a glyph, and the autoconfig table now says which client each route serves. The term-icon set gains an IPFS mark — the hexagon of an isometric cube, traced in the same stroked house style as the rest of the set rather than lifted from the real logo, whose several tones would flatten to one solid shape under the CSS mask these icons are painted with (the logo’s inner ring of cubes is left out for the same reason: it closes into a smudge at inline size). It enters on the ordinary frequency rule and not as an exception — IPFS is named on 48 of the book’s 106 pages — and is swept into 34 of them at each page’s first substantive mention. Both the glyph and the measurement that admitted it are in the Icon legend. Separately, the Client column of the three autoconfig/autodiscover routes now carries the Thunderbird and Outlook glyphs, so the table answers “which client is this route for?” at a glance. Documentation only, so it rides the version this section already carries.
2026-08-07 — v0.57.0 (deadlines on stalled connections, a POP login budget, client icons)
- A mail login opens the account it authenticated. POP and IMAP resolve a login name to a wallet exactly once per session, on the way to the stored password, and open the mailbox that one resolution named — so an alias re-pointed in between (a transfer, a sale, or an auction settlement, which anyone may crank) cannot redirect a session to another wallet’s mail. Stated as a guarantee in the threat model under A re-pointed alias cannot redirect a mail login, and where a reader meets the login pair, in Mailbox credentials. A name that resolves to nobody is now refused exactly as a wrong password is, with no distinguishing reply, so the login prompt is not an alias-existence oracle. Wallet-signature and client-certificate logins are unaffected: their username is the wallet, so nothing is resolved. A change in authentication behaviour that deployments can observe, so MINOR.
- Two new listener deadlines bound connections that stop making progress —
the
[*.server]limits table, and the replica-sizing note in Scaling out.limits.handshake_timeout_secs(default 30) bounds the TLS handshake on both paths — the implicit-TLS accept and the STARTTLS/STLS upgrade — so a peer that connects and then never sends a ClientHello no longer holds its connection slot until the kernel gives up on the socket.limits.write_timeout_secs(default 60) bounds one write’s progress: reads were already bounded by the idle timeout, writes were not, so a peer that stopped draining its socket could wedge the session task behind TCP backpressure. The write clock restarts on any byte the peer accepts, so it expires only on a reader taking none at all — a dead or hostile peer, not a slow one.0disables either. Both are additive settings with new default enforcement behaviour, so MINOR. - POP3 now has a login-attempt budget and tarpit, matching IMAP —
pop.max_login_attempts. A POP connection previously allowed unlimited password guesses at full speed; it now tolerates 3 by default and tarpits each failure (2 s, then 4 s, doubling) before hanging up. Bothmax_login_attemptsrows also now state what neither said before:0is not unlimited — the session counts the failure before comparing, so0behaves exactly like1. The budget is per-connection; a reconnect resets it. client_cert_authnow works in the combinedsithbitddaemon — Client-certificate auth (SASL EXTERNAL). The standalonesmtp-server/imap-server/pop-serverbinaries always honoured the toggle, butsithbitdbuilt a plain TLS acceptor at all three listeners, so it never asked the client for a certificate and SASL EXTERNAL could not complete there — while all three listeners advertised it anyway. Client-certificate login therefore only ever worked on the standalone binaries; it now works in the daemon too, with each listener’s acceptor fenced by a test that boots the real daemon and walks EXTERNAL to completion. The same section’s claim that EXTERNAL is advertised only under client-auth TLS was wrong and is corrected: advertisement follows the toggle, acceptance additionally requires a presented certificate whose key matches the wallet — which is the gap that hid this. A fix restoring documented behaviour rather than a new capability, so it does not move the version on its own.- The icon set gains the four GUI clients — Icon
legend, applied across the client
pages and the GUI clients overview.
webmail,Chrome,OutlookandThunderbirdjoin the inline term icons so a reader scanning a page can see which clients a passage applies to. They are the one deliberate exception to the legend’s frequency rule and enter as a closed set of four: the icons answer “which clients does this apply to?”, so an unmarked client would read as excluded rather than merely rarer. Drawn as monochrome line glyphs rather than vendor logos because the icons render as CSS masks in the surrounding text colour — a gradient-filled brand asset collapses to a solid blob. Documentation-only, so it does not move the version on its own.
2026-08-07 — v0.56.0 (the reclaim tool leaves launch builds for real)
AdminCloseAccountis now compiled out of launch program builds — Why the launch build doesn’t have this command and the program reference instruction rows and error table. That page has always described this lifecycle, but only the CLI half was ever enforced: all three programs shipped the handler unconditionally, and the on-chain value guard (error 106) cannot tell leftover state from a live account — a live mailbox, alias, or domain holds exactly its rent-exempt minimum, so the standing delegate could destroy live user accounts and take their rent. Each program now carries its ownreclaimCargo feature (off by default, implied bydevnet— see the devnet build appendix) gating the handler; a default-feature build refuses the instruction outright with new custom error 108 (AdminCloseDisabled), proven by launch-build refusal tests run against the shipped bytecode in all three program suites. A new error code is an additive public-ABI change, so MINOR.
2026-08-07 — v0.55.0 (signing out of a mail server, and mid-session key changes)
- Locking a wallet now ends its server session — Locking and signing
out on the webmail page, and
Ending a session
for the wire contract. The new
POST /v1/auth/logoutdrops the session’s reading secret and its decrypted-summary cache; every client’s Lock button calls it, so signing out is one click and needs no CLI. Documented with its limits rather than its happy path: the token is a stateless bearer credential and is not revoked — it stays valid until it expires — and a sign-out that could not be delivered passes without a word to the user. A new endpoint plus a new default client behaviour, so MINOR per the versioning rules above. - Publishing or closing a delegated encryption key now takes effect immediately — same webmail section. Previously the change reached the server only at the next unlock, so mail sealed to the new key read as locked until you locked and unlocked again. The flip side is now stated too: because a session reads with exactly one key, mail delivered before a publish stays listed as locked for the rest of that session.
- libsodium is defined, and the record corrected — a glossary
entry (which gives every mention a hover
definition) and Where libsodium
fits. SithBit links no
libsodium: it implements libsodium’s
crypto_box_sealwire format via the pure-Rustcrypto_boxcrate, which is why the same sealing code compiles to WebAssembly for the browser clients, and why a libsodium.js or tweetnacl client can open SithBit mail. - Seven more configuration keys documented —
greeting,max_message_size,max_messages,max_recipientsandmax_recipient_errorsfor the two SMTP listeners, plusmax_message_size/max_login_attemptsfor[imap]andenable_apopfor[pop]. All were live settings that the reference had never listed.
2026-08-06 — v0.54.0 (expunge releases an offloaded attachment’s pin)
- Offloaded attachment pins are reclaimed on expunge, by refcount —
When an offloaded pin is
released replaces the
old “offloaded pins are never released” section on the
sithbitdpage. One attachment is pinned once per submission and referenced by every copy that carries its link — each local recipient, the sender’s Sent copy, every IMAPCOPY— so each expunge drops one reference and only the last reference standing unpins, exactly once. Deleting one copy therefore does not make the attachment unfetchable for the other readers, which is the sentence the page now says out loud. A message that offloaded nothing pays one empty lookup at expunge and enqueues nothing, so a deployment that never arms the threshold cannot tell the machinery exists. A new default behavior for deployments that run offload, with no ABI or wire change — MINOR per the v0.10.0 widening above. - No queue to provision, and the recorded coordinates do the unpinning —
the same section records the two operational facts an operator needs before
turning offload on: the release job rides the existing
chain_deletequeue (same worker, same provider client, same “already gone is fine” posture as the message-body teardown), so enabling offload needs no queue provisioning changes on any store backend, cloud ones included; and the pin is released by the coordinates the spooler recorded at delivery — theoffload/<uuid>object name and the provider’s cid, both riding the job — never by a derived name, because Filebase unpins by name and Pinata by cid. - Relayed submissions are exempt, permanently — and the threat model says so
plainly — Offloaded attachments: the link is the
credential
loses its “nothing ever unpins an offloaded attachment” gap and gains the
bounded version: expunge reclaims a local-only attachment, and never a
relayed one. If any recipient was remote the pins are flagged at delivery and
no local expunge ever releases them, not even the last local copy’s, because
the link is already on servers this deployment cannot see. For those there is
no automatic cleanup: retention is the pinning provider’s policy or an
operator sweep’s job, against the
offload/object-name prefix. The section also notes what no release path can do — unpinning removes this deployment’s copy of the ciphertext, it revokes nothing, and a link already forwarded, logged, or pasted is beyond it. - An armed threshold with no pipeline now warns at boot — the prerequisites
bullets record that a non-zero
threshold_byteswithout a configured[grpc]+[ipfs]pipeline makes the daemon warn that attachment offload is inert, naming the threshold that was set. It warns rather than refusing the boot: the sink is never armed, every over-threshold attachment delivers inline, and nothing is pinned — so read the startup log after arming the threshold instead of assuming the setting took.
2026-08-06 — v0.53.0 (one account API hosts several browser bundles at once)
- The account API’s static hosting is a list of mounts, not a single
directory — Static hosting for browser
clients is
rewritten around that shape (and renamed: it stopped being about the Outlook
add-in alone). Each directory you want served is one
[[static]]entry, a route prefix plus a root, so one instance can carry the add-in bundle, the onboarding and self-service pages and a webmail shell on the API’s own origin — which is the point of serving them here at all: those pages call/v1/…with no CORS configuration anywhere. No entries, the default, still means no static routes and a pure JSON API. A deployment change that adds capability without touching any ABI — MINOR per the v0.10.0 widening above. - What the config file cannot say for itself — the configuration reference
gains
[[static]]— same-origin static mounts beside the account-api key table, and both pages now state the three behaviors an operator otherwise meets the hard way. Route prefixes must be unique: a repeated prefix (orroute = "/") aborts the process while the router is built — before the listener binds, so it fails fast rather than half-serving — and the message names an axum-internal synthetic route rather than the offending[[static]]entry, so the docs are where you learn to suspect a duplicatedroute. Nested prefixes (/addinand/addin/help) are legal and resolve most-specific-first in either config order, so entry order is cosmetic. And arootdirectory that does not exist is not a startup error: that mount answers 404 per request, which is what an unbuilt bundle or a relative path read against the wrong working directory looks like. - Migration: the old single
[static]table is now refused at load — it is a loud startup error, not an ignored section, so no deployment can quietly stop serving its bundle. The fix is the second pair of brackets; the two keys are unchanged. The reference carries the before/after block, and — because__-nested environment names cannot address array entries — the one environment variable that sets the whole list,ACCOUNT_API_STATIC, taking the array as a TOML value (inline tables,key = value; JSON is rejected).account_api.toml’s commented example now shows two mounts with distinct prefixes. - Every remaining copy of the old spelling is gone from the client pages and
the repo’s web-client docs — the migration is only half-done if a page an
operator copy-pastes from still hands them a single
[static]table, and four of them did, uncommented: the building-and-serving blocks on webmail, Outlook and marketplace, plus the scratch-server recipe inwebclients/README.md— which is a config that is actually executed by the end-to-end recipe, so it would have taken that run down rather than merely misinforming a reader. Each doc fence now also says why the second bracket pair is there and points at the canonical[[static]]list. The prose mentions on onboarding and in the enrollment-page hosting note follow; the latter now names which mount servesenroll.htmlinstead of implying there is only one.
2026-08-06 — v0.52.0 (a browser-made wallet reads its own sealed mail)
- The default webmail account no longer lists its mail as locked —
Sealed rows and your reading key
is rewritten around what changed. A wallet the in-page wizard generated,
with no encryption key published, has its stored mail wrapped to the
wallet’s own X25519 twin; the page now derives that twin secret when you
unlock and sends it on the login exchange, so every sealed row opens —
including mail delivered long before, since the twin is the key it was
always wrapped to. Nothing to publish, nothing to save, no button to press.
The sign-in is also key-aware: it reads what your mailbox has published
on-chain and offers the matching secret, so the section’s cases are
re-stated as four. The browser-generated default (no key published) and the
connect-wallet (Phantom/Ledger) account read everything, and so now does an
account that published the recoverable wallet-derived key
(
sithbit mailbox set-key --derive) — that key is reproduced from the wallet at unlock and used instead of the twin once the mailbox is seen to carry it, so those accounts, which read nothing sealed here at all before, open every row on any device holding the wallet. Only a fresh random key published from the Encryption key pane still costs the old trade: nothing about your wallet reproduces it, so the app holds it while the tab lives and a reload signs in with the wallet twin again, leaving rows sealed to the published key locked. Handing that exported secret back to the app at sign-in remains filed follow-up work; the pane is documented as being about key separation, not about unlocking reading. The page also now says plainly what the reading key is: not a password that proves who you are but a key that opens mail, so anyone holding it reads every message ever sealed to that wallet. No ABI, wire or on-chain change, but a significant additive capability that changes what end users can read — MINOR per the v0.10.0 widening above. - The same secret, for a mail app you configure by hand — the CLI gains
sithbit mailbox reading-secret, documented besidemailbox credentialson a page now framed as the two offline derivations from your keypair: who you are, and what opens your mail. The reference states the secret-versus-authenticator distinction as a warning, the address-to-stderr/secret-to-stdout split, and — documented for the first time — how the value is used: appended to the wallet-signature password after a single.(never part of base58, so the split is unambiguous), on the incoming IMAP/POP server only, because the submission server refuses a password carrying a reading secret rather than accept a key sending mail never needs. - Screenshots re-pinned, not re-shot — the four webmail, five onboarding
and two marketplace captures were re-pinned in
screenshots.manifest.jsonafter the wave’s web-client edits. Those edits are session-logic only (wallet-session.js‘s key-aware sign-in, the keys pane’s publish path, and four shells’ pane contexts); no.html,.cssor.svgunder any hashed source changed, so no rendered pane can differ and the committed captures still show the current UI.
2026-08-06 — v0.51.0 (a keyed session reads sealed mail everywhere, not only on open)
- A session that logged in with its reading key now gets decrypted
summaries and search hits, not just decrypted message opens — the
account-api reference gains
Sealed bodies and keyed sessions.
It documents, for the first time, the listing’s
sealedboolean and its narrow meaning — “this response did not open the body”, not “encrypted at rest” — with the four cases written out exhaustively: plaintext or a sealed body a keyed session unwrapped both list assealed: falsebeside a real parsed summary, while a sealed body in an unkeyed session, or one whose key does not match, has no wrapped key for this reader, or hits a store error, lists assealed: truebeside an all-default summary. The row’s own metadata (uid, size, flags,internaldate) comes off the store row and is always real; a client that predates the field sees no field, which reads asfalse. 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 and matches headers, and the body whenbody=true, at parity with a plaintext row, while a row it cannot open is skipped rather than matched as ciphertext, so an unkeyed search returns precisely what it always did. Every hit therefore carriessealed: false. Cost is bounded by that same window (at worst one blob fetch, one wrapped-key lookup and one decrypt per candidate), unkeyed sessions pay nothing extra because the sealed path short-circuits before any store round-trip, and decrypted summaries are cached per session — keyed on a digest of the bearer token, never in the shared plaintext cache, dying on token expiry, logout, or eviction of that session’s reading secret. Nothing to configure. No ABI, wire or on-chain change and no default-behavior change, but a significant additive capability that changes how end users read their mail — MINOR per the v0.10.0 widening above. - What a locked message looks like in webmail, and which accounts can
unlock one — the webmail page gains
Sealed rows and your reading key:
a row the app 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, no snippet, a real date (dates come off the mailbox row, not the body), and a readable sign in again with your wallet’s reading key message on open instead of a broken reader; search skips such rows rather than matching their ciphertext. Signed in with the reading key, the same rows list, search and open with nothing marking them out. The section is honest about which accounts hold that key today: a connect-wallet (Phantom/Ledger) account onboarded through the web wizard does, because the wizard published a delegated reading key whose secret the browser holds; a wallet whose published key is the recoverable, wallet-derived one does, reproduced on any device holding the wallet (sithbit mailbox set-key --derive— there is no settings-pane button for it yet); and a browser-generated account that has published no key does not yet, because its mail is wrapped to the wallet’s own encryption twin and the in-page wasm module does not hand that secret to the app. Publishing the derived key fixes it for mail delivered from then on — mail already delivered stays wrapped to the earlier reader — and exposing the twin secret to the page is a filed follow-up. The Encryption key pane’s Generate & publish a delegated key button is explicitly not the workaround: it publishes a fresh random key while the browser session derives the wallet’s own reading key, so the two never meet. - The privacy page says so too — What your operator holds previously described only the sealed-body refusal a keyless session gets on open. It now also states that a keyed session reads normally throughout (list summaries and search hits decrypted, not only the message you open), while an unkeyed one still gets its list and its search with unopenable bodies shown as locked rather than blank or silently missing.
2026-08-06 — v0.50.0 (large attachments can ride an encrypted IPFS link)
sithbitdcan now offload large attachments to IPFS — the new[spooler.offload]section documents it. An attachment larger thanthreshold_bytesdecoded bytes is sealed under its own freshly generated key, pinned through the same[ipfs]provider the chain workers use, and replaced in the delivered message by a placeholder part linking to<gateway_url>/ipfs/<cid>with the key in the URL’s#fragment— so the gateway serves ciphertext it cannot read, and no access log on the fetch path can carry a key. The feature is off by default and opt-in: with the section absent, or present but leavingthreshold_bytesat0, no message is ever rewritten and delivered bytes are byte-identical to a daemon without the feature. Naming agateway_urlalone does not arm it, and the chain pipeline ([grpc]+[ipfs]) is required, because without a pinning provider there is nothing to build a fetchable link from. A seal or pin failure tempfails the whole submission (451) rather than quietly delivering the attachment inline. 5 MiB is the suggested production threshold. No ABI, wire, or on-chain change and no default-behavior change, but a significant new deployment-affecting capability — MINOR per the v0.10.0 widening above.- The operator’s picture of that offload, and its threat model — the
sithbitdpage gains Large-attachment IPFS offload: when to turn it on, the two hard prerequisites (a configured chain pipeline, and agateway_urlrecipients can actually reach — it is baked into delivered mail and cannot be corrected afterwards), the decoded-versus-wire size trap (threshold_bytesis a decoded size, so a 5 MiB threshold is roughly 6.8 MiB on the wire and an operator setting it from SMTP log figures sets the bar a third too high), what is never offloaded at any size (body parts,multipart/message/rfc822containers, and any part carrying aContent-ID— which is how inlinecid:images survive), the placeholder’sX-SithBit-Offload-*headers, the451tempfail on a pin failure, and the fact that offloaded pins are never released. The threat model gains Offloaded attachments: the link is the credential, which states plainly that the link is a bearer credential — no wallet binding, no expiry, no revocation, and explicitly weaker than the sealed-box path the message body takes — enumerates where it leaks (forwarding, theX-SithBit-Offload-Urlheader riding every relay and archive, browser history, link-previewing and URL-rewriting middleboxes, paste), records the compensating facts (the#fragmentnever reaches the gateway or any access log, the gateway cannot decrypt, a fresh key per attachment, ciphertext is what is pinned), and records the known gap that nothing ever unpins an offloaded attachment — deleting the message does not remove the pin. The sithbit-gateway page notes the one deployment where that read-only gateway becomes load-bearing for mail delivery. Documentation only; no behavior change, so the version stays at v0.50.0.
2026-08-06 — v0.49.0 (the marketplace Participants tab authors your beacon)
- The Participants tab
now authors the connected wallet’s own beacon — the new
Managing your own beacon
section documents the flow. Below the pool list, a
Manage my beacon button connects the external wallet (the same
Phantom/Ledger path buys and listings sign with) and opens a
three-state surface: publish when not opted in (grouped tag pickers
by name over the CLI-identical 24-tag vocabulary, an optional
paste-only detail CID, at least one tag required),
update/disable/close when opted in, and re-enable when disabled.
Update is a wholesale replacement mirroring
sithbit campaign update(an empty CID input clears a published one); disable is a pane-side convention — a zero-bitmap update that leaves the account and its rent on chain while dropping the beacon out of every tag search, with the prior tags remembered per wallet in browser storage so re-enable can restore them; close refunds the rent. The design-note appendix retires its “browse-and-discover surface, not an authoring one” limitation accordingly; the group-offer (quote/send) flow of decision 4 stays CLI-only. No ABI, wire, or on-chain change — the pane drives the existing beacon instructions — but a significant new client capability, so this is a MINOR bump to v0.49.0 (the new-client-capability precedent, per the versioning note above).
2026-08-06 — v0.48.2 (the marketplace Participants tab speaks tag names)
- The Participants tab
now shows tag names, not raw bit positions. The browser clients grew
a shared copy of the participant-tag vocabulary
(
webclients/shared/tag-vocabulary.js) — a hand-maintained mirror of themail_modelTAG_*constants, fenced by drift-guard tests, whose names are identical to thesithbit campaign --tagnames by construction. Rows render names (a bit newer than the page’s vocabulary falls back to its numeric position and stays visible), and the tag filter accepts names and bit positions mixed in one comma-separated list, case-insensitively, rejecting an unknown name in the pane before any request is sent. The shipped web surface note’s known-limitation paragraph (tags render as raw bit positions) is retired accordingly, and that page now records the hand-maintained mirror + drift guards as the accepted mechanism. The participants API wire contract is unchanged — the route still takes bit positions and names resolve client-side — so no ABI, wire, or economic behavior moved: a PATCH bump to v0.48.2.
2026-08-06 — v0.48.1 (the MODSEQ-parentheses wait now names its upstream issue)
- The conformance appendix’s CONDSTORE
section
now cites the upstream tracking issue the previous entry said was
being filed:
imap-codec#722,
opened 2026-08-06. That issue is the unblock signal for the deferred
QRESYNC work — when it is fixed and released, the pins move and the
wave starts.
imap_session’s scope doc carries the same link. Documentation-only and the protocol state has not moved, so this keeps v0.48.1 rather than bumping it, per the versioning note above.
2026-08-05 — v0.48.1 (the MODSEQ-parentheses deviation is recorded as a wait on upstream)
-
The conformance appendix’s CONDSTORE section now carries the dated probe behind the known
MODSEQ 4vsMODSEQ (4)wire deviation. Probed 2026-08-05: the threeimappins are already at the newest published releases (imap-codec2.0.0-alpha.9,imap-types2.0.0-alpha.7,imap-next0.3.4, checked against the crates.io sparse index,cargo searchand the upstream default branch), the encoder arm that drops the parentheses is byte-identical on that branch, and no upstream issue or pull request mentions the defect — so one is being filed. The record states the consequence explicitly: the QRESYNC wave stays queued behind the upstream fix rather than being worked around with a local patch or fork, which would put the servers on a private wire encoder for one FETCH item. The section also now notes that the codec’s own parser requires the parentheses it does not write. Same record inimap_session’s scope docs, whose deferred-QRESYNC bullet had the probe still pending. Documentation-only — no code, pins or behavior changed, so this is a PATCH bump to v0.48.1. -
Running as an OS service now covers all nine server binaries: the three standalone dev/pilot protocol servers (
pop-server,smtp-server,imap-server) gained the sameservice install|uninstallsubcommand the fleet six got at v0.47.0, each declaring itsServiceDefinitionover the sharedos-servicecrate. The generated units’ commented unprivileged-run hints scope to each server’s own ports (110/995, 25/465/587, 143/993) rather than the daemon’s full mail-port list. MINOR: an additive deployment capability, per the v0.47.0 precedent. Also behavioral, same change: each binary’s config env var now follows the fleet-wide{PREFIX}_CONFIGconvention —POP_SERVER_CONFIG,SMTP_SERVER_CONFIG,IMAP_SERVER_CONFIGreplace the oldPOP_CONFIG/SMTP_CONFIG/IMAP_CONFIGnames (the rendered unit’sEnvironment=line and the loader must agree; anyone exporting the old names must switch).
2026-08-05 — v0.47.0 (every server binary installs as an OS service)
- Running as an OS service
now covers every server binary, not just
sithbitd:mail-grpc,domain-sithbit,account-api,sithbit-ipfsd, andsithbit-gatewaygained the sameservice install|uninstallsubcommand (systemd unit on unix, SCM registration plus the internalservice runverb on Windows), each registering under its binary name with its own config env var. The machinery moved from the daemon into the sharedos-servicecrate; each binary declares only its service definition. The generated units’ commented unprivileged-run hints are per-binary: low mail ports forsithbitd, low web ports (80/443) for the internet-facing three, a bareUser=line for the fleet-internal two. MINOR: an additive deployment capability across the fleet. Also behavioral, same change:mail-grpcnow shuts down gracefully on ctrl-c (draining telemetry) instead of dying on the default signal handler. The five operate pages cross-link the shared section.
2026-08-05 — v0.46.2 (module files migrate from mod.rs to the Rust 2018 layout)
- Every
foo/mod.rsin the workspace’ssrc/trees renamed to the Rust 2018foo.rs+foo/layout — 14 files across nine crates, pure renames with no code changes. The two integration-test helper modules (mail_program/tests/support/mod.rs,domain_program/tests/support/mod.rs) deliberately keepmod.rs: under Cargo, a top-leveltests/*.rsfile compiles as its own test binary, andmod.rsis the prescribed pattern for shared test helpers. Doc impact is one path reference: the Scaling out chapter’s add-a-backend checklist now namestests.rs(formerlytests/mod.rs) as the conformance-registration step. Documentation-only — no code or behavior changed, so the version stays v0.46.2.
2026-08-05 — v0.46.2 (a beginner’s tour of the components joins the book after the Introduction)
- New page: The components at a glance — a
non-technical orientation tour of the four groups that make up SithBit
(the on-chain programs, the operator-run servers, the graphical
clients, and the terminal tools), each introduced in plain terms with
its own diagram, closing with a full three-tier map of how the groups
interrelate (clients → servers → chain + IPFS, including the CLI’s
direct-to-chain path and the trustless-reading path). Five new
diagrams under
images/components-*.svg. Wired into the book’s front matter between the Introduction and Standards and RFC coverage; links throughout point into the existing concept, client, operator, and reference chapters for depth. Documentation-only — no code or behavior changed, so the version stays v0.46.2.
2026-08-05 — v0.46.2 (a non-PEEK FETCH’s implicit \Seen is echoed back as an untagged FETCH)
- The IMAP server now sends RFC 7162 §3.1.4.1’s follow-up untagged
FETCHafter the implicit-\Seenpersist (wave-set #52 lane B), closing the question that had stood open since the CONDSTORE landing (v0.45.0): when a non-PEEK body fetch implicitly sets\Seenand the persist succeeds, one untaggedFETCHper changed message precedes the taggedOK, sharing theSTOREflag echo’s exact shape —FLAGSalways,MODSEQwhen the session is CONDSTORE-enabled,UIDforUID FETCH. On a failed persist there is no echo and the taggedOKstill follows. Described in the conformance appendix’s CONDSTORE section, including the client-visible consequence (an unseen message’s flags are reported twice on a body fetch) and the echo’s exposure to the knownMODSEQ-parentheses codec deviation. The version rolls to v0.46.2: a wire-visible behavior addition with no new capability is the small-server-behavior PATCH class of v0.44.2’s source-port entry and v0.46.1’s reply-code change. - The standards table’s
RFC 7162 row now credits the echo too: alongside its existing
conformance-appendix link (which previously it cited only for the one
known wire deviation), the row states that the §3.1.4.1 follow-up
untagged
FETCHis sent, pointing at the same appendix section for the echo’s shape. Same tidy-up inmail_store’s rustdoc:MailRepo’s counter-allocation paragraph no longer conflates “bumps the IDLE change sequence” with “allocates UIDs” —set_flags/expungebump without allocating andmove_messagesbumps both mailboxes, matching the adjacent mod-sequence paragraph that already listed all five bumpers. Documentation-only — no code or behavior changed, so the version stays v0.46.2.
2026-08-05 — v0.46.1 (the DMARC bounce takes RFC 7372’s precise code)
- The DMARC rejection is re-coded from
550 5.7.1to554 5.7.26(wave-set #51 lane A), taking the deferred candidate v0.46.0 recorded:5.7.26is the “multiple authentication checks failed” code RFC 7372 §3.3 registers for a DMARC failure, following the in-repo precise-code idiom the5.7.23SPF-hardfail reply set. The reply text still names the offending From domain, both thedmarc-liteand fulldmarcpolicies share the one helper, and quarantine/none handling is untouched. Described in the conformance appendix and the configuration reference; the recon page’s cluster 5 records the take. The version rolls to v0.46.1: a wire-visible reply-code change with no new capability is the small-server-behavior PATCH class of v0.44.2’s source-port entry.
2026-08-05 — v0.46.0 (the EAI question is decided: deferred with criteria)
- The recon page’s EAI family moves from recommendation to record (wave-set #51 lane C — decision-recording only, zero code): the internationalized-email program (RFC 6530–6533 and its per-cluster satellites) is deferred with criteria — parked, unopened, until a concrete demand signal arrives (an operator or user needing non-ASCII addresses, or interop with an EAI sender); no EAI implementation now. Per the recon page’s family rule the one verdict covers all five defer (EAI) rows — RFC 6533 (DSN, cluster 4), RFC 8616 (SPF cluster 5 and DKIM cluster 6), RFC 5738 and 6858 (IMAP, cluster 8), and RFC 6532 (MIME, cluster 10) — each cluster now carrying the recorded verdict, with decision-inventory entry 7 as the governing record. Documentation only: PATCH, and the protocol state is unchanged since the entry below, so the version holds.
2026-08-05 — v0.46.0 (outbound DKIM can dual-sign with Ed25519, and the SPF/DKIM update RFCs land)
- Outbound DKIM gains opt-in RFC 8463 Ed25519 dual-signing. Each
[spooler.dkim]entry accepts a newed25519_selector/ed25519_key_filepair (see the configuration reference): configured, every signed message carries a second, ed25519-sha256DKIM-Signatureheader alongside the rsa-sha256 one, each signing the same headers and body, so verifiers honor whichever algorithm they support. The pair is validated both-or-neither at load; absent (the default) signing is rsa-only, unchanged. The key is a PKCS#8 PEM (openssl genpkey -algorithm ed25519) from the same file-or-secret-manager key source askey_file, and the second selector’s DNS record isv=DKIM1; k=ed25519; p=<raw-32-byte-key-base64>— not a DER SubjectPublicKeyInfo. Inbound,ed25519-sha256signatures verify to pass, fenced with the RFC’s own appendix-A test key. The version rolls to v0.46.0: a new operator-facing signing capability with its own config and DNS surface is the significant additive deployment-affecting class that took MINOR for SASL-IR (v0.33.0) and SPECIAL-USE (v0.34.0), not the small-behavior PATCH class of v0.44.1/v0.44.2. - The RFC 8301 algorithm floor now holds on the verify side too. The
signer was already rsa-sha256-only by construction (now cited as
such); new is the repo-side post-filter that downgrades a verified
rsa-sha1signature to failure before it reachesAuthentication-Results, reporting, or DMARC alignment input — fenced in both directions, with a deliberate assert that the adoptedmail-authstill verifiesrsa-sha1so a future dependency version closing the gap upstream signals the filter can retire. - RFC 7372 is credited and fenced. The published-SPF-hardfail
rejection already answered
554with enhanced status5.7.23; it is now cited as the code §3.2 registers for exactly that outcome and fenced end-to-end over the offline resolver, softfail pass-through included. - DMARCbis is RFC 9989, and the From-extraction disposition is
explicit. The number is settled (RFC 9989 obsoletes 7489/9091;
RFC 9990 is aggregate reporting, RFC 9991 failure reporting — the
recon page’s one stray “9990” is
corrected). Per §5.3.1, an evaluation that extracts zero author
domains (absent or group
From:) or several differing ones now terminates without a verdict as an explicit, fenced disposition — no reject or quarantine even under a publishedp=reject, the §5.3.1 MAY for the multi-domain case deliberately not taken — rather than an incidental bypass. - The coverage assertions move together: the standards page’s RFC 7208, 6376, and 9989 rows gain the update cites, a new conformance appendix section spells out all four postures, and the recon page’s clusters 5 and 6 move from recommendation to record (wave-set #50 lane B), answering decision-inventory entries 3 and 4.
2026-08-05 — v0.45.0 (the DSN/MDN update verdicts are recorded: every row closes as declined, deferred, or done)
- The recon page’s cluster 4 moves from recommendation to record (wave-set #50 lane C — decision-recording only, zero code, so no adopting wave will ever own this cluster). RFC 8098 (MDN) is declined at the server layer — an MDN is generated by the recipient’s mail client on display, never by the MTA — reinforced by the workspace’s standing “inbound DSN/MDN processing — dropped” record in HANDOFF.md; RFC 3885 (MTRK) is declined with its 3886 companion; RFC 6533 (internationalized DSN) is deferred behind the EAI program, to be decided with the whole family and never alone; RFC 4865’s DSN-side wording falls with cluster 2’s FUTURERELEASE decline; and RFC 6522 stands done. Per the recording’s convention call, declined RFCs get no standards page rows — the recon page is the record. Documentation only: PATCH, and the protocol state is unchanged since the entry below, so the version holds.
2026-08-05 — v0.45.0 (the IMAP server speaks CONDSTORE: mod-sequences land on every store backend)
- The IMAP server now advertises
CONDSTORE(RFC 7162) once a session authenticates, and implements the extension in full:ENABLE CONDSTORE,SELECT/EXAMINE (CONDSTORE)with the unconditionalHIGHESTMODSEQ/NOMODSEQresponse code,STATUS (HIGHESTMODSEQ),SEARCH MODSEQ, theMODSEQfetch item andCHANGEDSINCEmodifier, andSTORE UNCHANGEDSINCEansweringOK [MODIFIED …]with the messages it left untouched. Returning clients can now resynchronize flags by asking only for what changed. The QRESYNC half of RFC 7162 is queued as its own follow-up wave; its parameters are refused. See the new row in the standards page’s IMAP table and the new conformance appendix section, which also records the one known wire deviation (the adopted codec omits the parentheses around the FETCHMODSEQvalue; an upstream fix is queued). - Every store backend now tracks mod-sequences: a per-mailbox highest
mod-sequence and a per-message mod-sequence, bumped once per
flag-changing operation, across all six
mail_storebackends (sqlite migration 0013, postgres 0011). Production mailboxes are always tracked — the counter is born at 1 — soNOMODSEQnever appears in production. The version rolls to v0.45.0: a newly advertised capability with a storage-schema addition is the significant additive deployment-affecting class that took MINOR for SASL-IR (v0.33.0) and SPECIAL-USE (v0.34.0), not the small-behavior PATCH class of v0.44.1/v0.44.2. - The recon page’s cluster 8 records the adoption: the RFC 4551 row moves from answered-piecemeal to adopted at its RFC 7162 target, and decision-inventory entry 1 notes the landing while QRESYNC stays queued.
2026-08-05 — v0.44.2 (forensic reports name the peer’s source port, and the ARF applicability statement is credited)
-
The standards page’s SMTP table gains RFC 6692 (source ports in ARF reports), now implemented. Every DMARC failure/forensic report attributes the failing connection with the SMTP peer’s TCP source port alongside its
Source-IP: the port is captured at accept, threaded through the policy seam into the forensic datum, and emitted as theSource-Portfield — omitted entirely when the port was never learned, so a fakeSource-Port: 0is never sent. There is deliberately no config switch:Source-IPis already unconditionally disclosed in the same report, so the port adds no new disclosure class. The version rolls to v0.44.2 for this small additive report field, following the v0.44.1 precedent of a patch roll for a small server-behavior change. -
RFC 6650, the ARF applicability statement, is credited on the same table’s RFC 6591 row and argued in the conformance appendix: the stack’s only report-generation path is solicited-by-publication —
ruf=targets the policy domain itself published, external targets gated by the RFC 9991 §5 authorization check — which satisfies 6650’s consent requirement by construction. The forensic pipeline’s module doc now names 6650 as the governing applicability statement; no behavior moved for this credit. -
The recon page’s cluster 7 records both adoptions, and decision-inventory entry 5 is answered: implement, always emit when known, no config switch. The cluster-10 residual also closes to “fence pinned”: the group-syntax
From:DMARC bypass recorded by the RFC 6854 audit now has its explicit test, pinning degrade-not-fail as current behavior while hardening stays a deferred candidate for a future policy wave.
2026-08-05 — v0.44.1 (the IMAP cluster’s decisions are recorded: an RFC 7162 wave is queued, four declines go final, and RFC 6186 is answered for both halves)
-
The recon page’s cluster 8 records its decisions — nothing is implemented by any of them. CONDSTORE/QRESYNC moves from decision-required to answered as piecemeal: a dedicated RFC 7162 implementation wave (the
imap-typesfeature flip,imap_sessionsemantics, andimap_server/backend MODSEQ state, independent of any IMAP4rev2 adoption) has joined the queue, and the row records that decision, not an adoption. The four fork-blocked extensions — RFC 4469 (CATENATE), 5032 (WITHIN), 8437 (UNAUTHENTICATE), and 8474 (OBJECTID) — move to declined-final now that the strategy answer makes the earlier decision not to forkimap-typespermanent. And the IMAP half of RFC 6186 client-side SRV discovery is declined as superseded — the signed DHT service records are wallet-anchored, which plain SRV can never be — mirroring the POP3 half cluster 9 recorded in this same wave-set, while the operator-side SRV documentation stands. -
Both shared entries in the user decision inventory close. Entry 1 (the IMAP 3501-vs-9051 strategy) is answered as option (a), piecemeal — narrowed first, since the 9051/fork path was already declined in session #10 — and entry 2 (RFC 6186 discovery) is answered as option (b), declined as superseded, with both protocol halves now recorded. The options stay on the page as history, per the inventory’s marking convention.
-
The in-crate records landed in this wave’s earlier phases:
imap_server’s crate docs gained the driver-layer “RFCs implemented” block its two sibling drivers already had (closing the gap the recon page recorded), andimap_session’s scope docs now name the queued RFC 7162 wave in the deferred list and carry the new “Declined (final)” section for the four fork-blocked extensions. -
No coverage table moves. Because these are recorded decisions rather than implementations, the standards page’s IMAP table and the conformance appendix are deliberately untouched — and the transport-hardening table’s RFC 7817/8996/8997 rows already speak for the shared acceptors IMAP rides, so the per-protocol table gains no duplicate rows.
2026-08-05 — v0.44.1 (the TLS 1.2 floor is asserted in code, and RFC 7817/8996/8997 join the standards tables)
-
The standards page’s transport-hardening table gains the email TLS trio. RFC 8996 (TLS 1.0/1.1 deprecated), RFC 8997 (the TLS ≥ 1.2 floor for email, updating RFC 8314), and RFC 7817 (the updated TLS server-identity check — under strict verification the outbound relay verifies the connected MX host or smarthost, never the recipient domain). All three behaviors were already present, since every acceptor and connector rides rustls; what’s new are the credits and the fences — tests now assert the version floor at the shared
server_commonacceptors and at the outbound relay connector rather than inheriting it from library defaults. The version rolls to v0.44.1 for the one (tiny) behavioral hardening in the set: the spooler’s two HTTPS report fetchers (MTA-STS policy fetch, TLS-RPT submission) now pin the rustls backend in code instead of riding reqwest’s feature defaults. -
The conformance appendix’s RFC 8314 section states the version floor those credential gates sit on, so a custom server matching the conformance claim knows to refuse the deprecated versions too.
-
Two audited one-line credits fold in from the recon work: RFC 5248 on the enhanced-status-codes row (every emitted enhanced code is listed in its IANA registry) and RFC 6854 on the Internet Message Format row (group syntax in
From:/Sender:parses; generated messages always carry a singleton mailbox). -
The recon page’s cluster 3 records the adoption, its cluster 2 and cluster 10 rows record the two folded credits, and decision-inventory entry 6 is answered as option (a) — the reqwest backend pin. The page’s top note no longer claims it is unwired from the book nav (it has been under Appendix: Reference since wave-set #47).
2026-08-05 — v0.44.0 (RFC 1957 credited in the POP3 standards table, and the recon page’s cluster 9 records the wave)
-
The standards page’s POP3 table gains RFC 1957 (observations on POP3 implementations). Its one server-side recommendation — real clients depend on the optional
UIDLcommand, so provide it — was long implemented but uncredited; the credit now rides the table,pop3_proto’s own RFC block, and a new test fencing the per-messageUIDLreply against RFC 1939’s own example. No behavior moved. -
The RFC-updates recon page’s cluster 9 now records the adoption. RFC 1957’s verdict moves from recommendation to adopted, and the POP3 half of the shared RFC 6186 discovery decision is answered: client-side SRV lookup is declined as superseded — SithBit tooling trusts only the signed DHT service records — while the operator-side SRV documentation stands.
2026-08-05 — v0.44.0 (the RFC-updates recon page opens the adoption program; the dependency audit joins the book nav)
-
The RFC-updates recon page opens the RFC-updates adoption program. A new appendix-track reference page maps every published update RFC onto the repo surfaces it touches — ten clusters (SMTP core, enhanced status codes, email TLS, DSN/MDN, SPF, DKIM, ARF, IMAP, POP3, MIME), each update carrying an audited status (method + cites) and an adopt/decline recommendation — closing with a cross-cluster overlap map and the inventory of decisions each future adopting wave must surface. Recommendations only — no behavior changes ride this page.
-
The manifest-majors dependency audit is now reachable from the book nav. The page existed but was never wired into the summary; both it and the recon page now sit under Appendix: Reference.
2026-08-05 — v0.44.0 (RFC 7504 and RFC 7505 credited in the SMTP standards table)
- The standards page’s SMTP table gains RFC 7504 (521/556 “server does not accept mail” reply codes) and backfills RFC 7505 (null MX). Both behaviors were long implemented but uncredited: the relay detects a null MX and refuses such recipients outright, bouncing with reply code 556 and enhanced status 5.1.10 exactly as RFC 7504 §2.2 prescribes, and the client send machine classifies both codes as permanent. This change is credit and test fences only — no behavior moved.
2026-08-05 — v0.44.0 (the lifecycle figure matches its list, client pages are reordered, and the account API page is sectioned)
-
The account API page is now linkable by fragment. It previously had only its title heading, so nothing on it could be cited by anchor — the structural cause of a wave-45 dead-anchor miscite. It gains ten
##sections at the existing seams of its prose — for example the DMARC aggregate-report reader — with the content itself unchanged. -
GUI clients’ subtopics are reordered around what they are. The mail clients now lead, webmail first and the extensions following by reach — webmail, trustless webmail, Chrome, Outlook, Thunderbird — with the parent page’s prose reordered to match. Lockbox is no longer the section opener: it is a capability of the Outlook and Thunderbird plugins, not a client, so it now closes the section after the extensions that carry it. And the name marketplace — a browse/buy/sell page for aliases and domains, not a mail client — moves out of GUI clients to its own top-level entry under Using SithBit. Page URLs are unchanged; only sidebar order and the parent page’s prose moved.
-
The lifecycle of a message’s figure and the operation list below it now number the same four operations. The figure previously counted five mechanism steps (1–5) while the list counted the four CLI operations (Send/Get/Pin/Delete), so the two numberings never lined up. The figure’s compose-seal / store / envelope boxes are now sub-steps 1a–1c of Send, Get & open is step 2, Delete is step 4, and the figure gains the previously missing optional step 3 — Pin, re-pinning the verified body to a provider the recipient controls. Each list item cites its figure step. No semantics changed — the flow itself is as before.
2026-08-04 — v0.44.0 (DMARC is RFC 9989/9990/9991, and pct= is inert)
- DMARC is now specified by RFC 9989, which
obsoletes RFC 7489. Standards and RFC coverage
retires the 7489 row for three: 9989 for evaluation and disposition, 9990 for
aggregate (
rua) reporting, 9991 for failure (ruf) reporting. The conformance appendix and the glossary follow, and the appendix’s ingestion section is retitled RFC 9990: ingesting DMARC aggregate reports. Relaxed alignment now folds to the organizational domain through 9989’s DNS tree walk instead of a public suffix list, which is why subdomain-signed mail aligns without one. - A published
pct=no longer does anything, and the change is strictly stricter. Whypct=no longer does anything is new: 9989 retired the tag, so a domain publishingp=reject; pct=0— which used to have its unsampled mail downgraded to quarantine — now has it rejected. The page names the migration 9989 intends,t=ytest mode, which drops enforcement one level and needs nothing from an operator’s configuration. Every page that claimed the fulldmarcmode “honorspct” — the standards table, the conformance appendix, the glossary, the threat model andsmtp_server.toml’s shipped example — now says enforcement is all-or-nothing instead. - The subdomain policies
sp=/np=are documented, with the resolver trap that can disablenp=outright. Subdomain policies and thenp=existence probe explains which ofp=/sp=/np=applies and states that “does not exist” is decided by a live single-A-record probe. Behind a resolver that answers NODATA instead of NXDOMAIN — systemd-resolved among them — that probe always says “exists”, every subdomain takessp=, and a domain publishingp=none; sp=none; np=rejectgets no enforcement at all. - The reporting sections are re-cited, and one stale claim about emitted
reports is corrected.
[spooler.dmarc_report]and[spooler.dmarc_ruf]now cite RFC 9990 §4 and RFC 9991 §5 for the external-destination checks and RFC 9991 §7.1 for the headers-only default. Two facts an operator can see on the wire are stated for the first time: emitted aggregate reports carry the RFC 9990dmarc-2.0XML namespace, and theirpolicy_publishedblock does carryp/sp/adkim/aspf(the page still said it did not) while deliberately omittingdiscovery_method, because this MX folds a report’s grouping domain with the suffix list and its alignment verdicts with the tree walk — naming either method would be false rather than merely absent. - The dependency audit records that its
mail-authrecommendation was declined. That page advised pinning=0.11.0and taking 0.11.1 “only when the RFC 9989/9990/9991 features are actually wanted”; they were wanted, so the manifest kept a caret0.11and the adaptation was run as its own wave. The residual exposure the caret leaves — a futurecargo updatetaking another SemVer-illegal patch from an upstream that has already shipped one — is written down as accepted, not solved. Its prioritized list is annotated with what has since been done. - The RFC 7489-era compatibility anchor is gone. When the ingestion section
was retitled RFC 9990: ingesting DMARC aggregate
reports,
a raw
<a id>was left behind so that DNS setup and the account API kept resolving under the old §7.2 fragment. Both links now point at the real heading and the shim is deleted —mail_docs/srccarries no hand-written HTML anchors at all again. - The
dmarc-2.0XML namespace on emitted aggregate reports is now justified rather than merely noted — and pinned by a test.[spooler.dmarc_report]no longer presents the namespace as an unhandled consequence of a library bump. RFC 9990 is Standards Track and obsoletes RFC 7489, so emitting the current schema is precisely what running a DMARCbis receiver means, andnpexists only indmarc-2.0, so downgrading the namespace would foreclose ever reporting it. The tradeoff is stated rather than hidden: a consumer that still validates strictly against the RFC 7489dmarc-1.0schema will refuse our reports, and there is no switch to emit the older form. Report ingestion stays lenient and accepts both schemas. A new test gunzips the aggregate report that actually crossed the wire and asserts its namespace, so a future silent library bump that moves it again fails loudly instead. - Three surfaces outside the earlier sweep’s reach now name the right RFC
too. The account API’s stored aggregate reports are the parsed RFC 9990
form, not 7489 — see the account API; footnote 6 of
Trusting a “from”
address cites RFC 9989
for the specification a receiver evaluates against, and names RFC 9990 and RFC
9991 as the reporting halves DMARCbis split out; and
sithbitd.example.toml’s commented[spooler.dmarc_report]and[spooler.dmarc_ruf]blocks cite RFC 9990 for aggregate emission and RFC 9991 §7.1 — not 7489 §7.3 — for the headers-only default. Comment text only in the example file: no setting, default or value changed. The remaining mentions of 7489 across the book are all historical (“obsoletes RFC 7489”, “RFC 7489-era report bodies”), and are correct as written. - The dependency audit’s summary table now
shows the manifest as it is, not as it was. Seventeen of its twenty-six rows
named a version requirement the root
Cargo.tomlno longer carries: the eleven bare*entries the audit asked to pin have since taken caret floors, the five OpenTelemetry entries moved to the 0.32/0.33 train, and GAazure_corewent to1.1. A new note under the table says which column is live (Manifest) and which stay frozen as the July 2026 evidence (Resolved, Latest (index), Verdict), so a row whose requirement now runs ahead of its resolved version reads as intended rather than as an error. One correction beyond the sweep:num-deriveis on0.5, a major above the0.4the audit’s own pin-floor block proposed. No manifest, code or documented behavior changed. - Emitted aggregate reports now carry
np, and only when the domain published it.[spooler.dmarc_report]previously said the element was not emitted because the store did not retain it; it does now, end to end from the DMARC evaluation to the gzipped XML on the wire. The subtlety is stated rather than hidden: the DMARC library materializes an absentnp=as a copy ofsp=while parsing — the same fill-in that givessp=its value for a record publishing onlyp=— so echoing it unconditionally would report a policy the domain never stated. A report therefore carries<np>only where the tag was really published and differs fromsp=, and omits it otherwise, which RFC 9091 makes the identical statement. - Documentation only in this repository: the behavior described here landed with the DMARCbis adaptation itself. It changes what an operator’s MX does to inbound mail and what its reports look like on the wire, so it is tagged MINOR under the v0.10.0 widening for default-behavior changes that affect deployments.
2026-08-04 — v0.43.0 (the auction’s default duration gets a heading of its own)
- The default
duration
is now a subsection of its own. What an auction runs for when neither
--ends-innor--ends-atis given was the third bullet in the list of the two flags that do name an end instant, where it read as a third way of setting one. It now sits under its own heading alongside Anti-snipe extension, and states what the bullet left implicit: the default and the ceiling those two flags clamp to are separate on-chain settings that hold the same length today, so an auction naming no end instant gets the default, not the maximum. No documented figure and no behavior changed. - The timelock gate now follows that heading instead of a pinned line. The gate fences the two auction constants — the hard cap on an auction’s length, and the length one runs when no end instant is given — against separate slices of that one page. The default’s slice was the single line its bullet sat on, and inserting a line anywhere above it slid the bullet into the cap’s slice: the cap claimed the figure, the cap’s own total did not move, and the only complaint was the default row’s deletion guard reporting a mention as removed when it had merely moved. The slice is now the new subsection, tracked by its heading, with the cap’s two ranges stopping one line either side of it — so that same edit is refused outright, naming both rows and the exact window, the way the glossary split has behaved since v0.42.1. Every fenced-mention count is unchanged. Documentation and gate tooling only: PATCH, and the protocol state is unchanged since the entry below, so the version holds.
2026-08-04 — v0.43.0 (what an authenticated submission session pins at AUTH, written down)
- Outbound quotas and
suspension now
explains the session-pinning window. The refusal table already carried the
symptom — a suspension landing after AUTH is refused at MAIL with
550 5.7.1— without saying what an authenticated submission session keeps from AUTH time and what it re-asks. It keeps the identity and only the identity: the wallet the login resolved to, which for an alias login is whichever wallet the alias pointed at in that moment. Both verdicts are asked afresh (suspension at AUTH and at every MAIL, rolling usage at every external RCPT), so neither the suspend flag nor the allowance is frozen for the session. The operator-visible consequence is the flip side of the pin, and is the reason the paragraph exists: re-point an alias mid-session and the open session keeps being charged to — and keeps being stopped by the suspend flag of — the old wallet, so suspending the new one leaves that session sending. The window closes at the session’s next authentication, which is the next connection in practice, since a secondAUTHon an authenticated session is refused (503, RFC 4954 §4). Compose is contrasted as having no window: it checks both on every request. No code and no behavior changed — the prose was catching up to what the servers already do. Documentation only: PATCH, and the protocol state is unchanged since the entry below, so the version holds.
2026-07-30 — v0.43.0 (the keyserver stops answering <wallet>@domain with a stranger’s key)
MINOR — a corrected enforcement default on a public endpoint; no protocol change. Recipient resolution is one decision with two arms, and the two had drifted apart at one site.
- The
certkeyserver returned the wrong recipient for a documented address form. Discovering a recipient’s encryption key promises you may give it “a wallet address or an alias, with or without a domain suffix”. The bare form was handled correctly; the<wallet>@domainform was not. Its wallet-literal guard was conditioned on the query having no@, so a suffixed address skipped it, was lowercased, and went to the chain gateway — which echoes back anything decoding to 32 bytes. The endpoint then answered with a different, equally real wallet and that wallet’s published key, which a sender would seal to.<wallet>@domainis not an exotic spelling: it is what the SMTP wallet-literal recipient fallback accepts. The guard is now on the local part alone, so both spellings resolve to themselves, case-exact. The prose was already right — only the code was wrong — so no page changes; this entry records the behavior fix. - Why case matters here at all. Base58 text is the encoding of a key, so a
case variant of a valid 32-byte address is a different wallet, not another
spelling of the same one. This is measured rather than argued: the fixture
address
6EhtMhq…su2Rlowercases into another valid address. Only addresses containingLescape, becauselfalls outside the base58 alphabet and those merely 404. Aliases are the opposite — stored lowercased and globally unique, so they must fold. Every caller now makes both decisions in one step, and a source-sweep test fails the build if a new site builds the resolution request by hand and skips it. Same class as the wallet-envelope fix in v0.25.0 below. - The gateway’s alias cache no longer shadows a freshly registered alias.
mail-grpc cached resolutions under the raw local
part, so each case spelling of one alias held its own entry — including its
own independently-aging negative entry, letting a
NOT_FOUNDcached under one spelling hide a just-registered alias for a fullalias_cache_seconds. Alias keys now fold. Wallet-literal keys deliberately do not: folding those would file one wallet’s answer under another’s key, which is the same defect one layer down. No configuration changes.
2026-07-29 — v0.42.1 (the Balances pane gets its screenshot; the timelock gate closes its known gap, then the figures still outside it)
- Balances: quotes before you buy
is now illustrated. The pane that carries the outbound price quote, the
ceiling that quote pins a purchase to, and the Reclaim unspent button was
the last webmail surface documented in prose alone. The new
webmail-balances.pngrides the same capture driver — and the same dark-theme/timezone pin — as the rest of the committed set, but is shot against a populated fixture, so the frame shows a returned quote and a frombox still holding stamps rather than an empty form. The capture tooling andscreenshots.manifest.jsonmoved with it; no web-client source changed, so every client’s pinnedsource_hashis unmoved. Documentation only: PATCH, and the protocol state is unchanged since the entry below, so the version holds. - The timelock gate now fences the pages it used to skip. Its rows claimed whole files, so any page mixing two constants’ figures had to be left out altogether — the known gap recorded on 2026-07-27 below, covering closing accounts, the economics page, the threat model and Proving behavior. A row can now take a named section or an explicit line range instead of a file, so two constants may share a page as long as their line windows do not overlap, and the fenced-mention count goes from 19 to 30 (14 mailbox-close, 9 domain-deactivation, 4 domain-reclaim, 3 reply-bounty). Two figures stay deliberately unfenced, argued in the checker’s own docstring: one diagram alt-text line that states two different constants’ figures at once, and the glossary’s Mailbox close timelock entry, whose figure is fenced on the close pages instead. One line of prose moved with the tooling — the finalize error under Deactivate a domain now says “the 7-day deactivation timelock”, naming the timelock it means so the fence can see it. No constant and no documented figure changed: tooling, gate and wording only, PATCH, and the protocol state is unchanged since the entry below, so the version holds.
- The auction page joins the fence, and the last figures sitting outside it are now inside. The gate held one auction row for two deliberately separate constants — the hard cap on an auction’s length and the length one runs when no end instant is given — so Auction an alias could not be fenced at all: it states the cap on six lines and the default on one, and a single row over the page would have read correctly today only to fail at the wrong line naming the wrong constant the day the two diverge. There are now two rows at disjoint scopes, one per constant, and the page is fenced including the alt-text of its auction diagram — the last diagram figure in the doc set that no row could see. Two sentences moved with the tooling, each so the gate can reach a figure it had been scanning past: the mailbox close timelock now says operators can watch a mailbox “announce its own close” where it said “its own exit”, and the threat model’s close section now says a timelocked key revocation would leave MX servers sealing to a key the attacker holds “for another seven days” where it said “for seven more days”. Fenced mentions go from 40 across five rows to 50 across six (25 mailbox-close, 10 domain-deactivation, 4 domain-reclaim, 3 reply-bounty, 7 alias-auction cap, 1 alias-auction default). No constant and no documented figure changed: wording, tooling and gate only, PATCH, and the protocol state is unchanged since the entry below, so the version holds.
2026-07-27 — v0.42.1 (GUI parity for the money paths: sponsored close, quote-pinned purchases, sender reclaim)
Client-side only — no Rust, no ABI, no protocol change. Three on-chain capabilities that already shipped were unreachable, or reachable only wrongly, from the browser clients. Tagged PATCH on the same reasoning as v0.27.0 (“External wallets can now buy stamps and claim a mailbox… client and docs only”): the protocol state is unmoved and each item is a client adopting an option the program already offered.
- A sponsored mailbox can now finish closing. The webmail close pane built its finalize transaction without the funder account the program requires whenever someone else paid the mailbox’s rent, so the close failed on-chain. Mailboxes users created themselves were never affected. The pane now reads the recorded funder and also says where each rent went, which it previously got wrong for the sponsored case. See closing a mailbox and the webmail dashboard.
- GUI stamp purchases are pinned to the price they quote. The slippage ceiling the CLI has applied by default now also covers the webmail Balances pane, the compose card’s inline prepay, and the self-service funding page. A recipient repricing between the quote and the purchase makes the program refuse rather than charge the new rate. The ceiling is read fresh at buy time and covers the per-stamp postage only. See the price you are quoted is the price you pay.
- Senders can reclaim unspent postage from the GUI.
ReclaimFromboxStampswas CLI-only. The Balances pane now offers Reclaim unspent once a quote shows a frombox of yours holding stamps; it is hidden rather than disabled when the sender is an alias or email string, which the instruction cannot address. See getting unspent postage back and reclaiming unspent stamps. - Balances also gained an outbound price quote, which it never had — the pane previously showed only the inbound (someone → you) price. See Balances: quotes before you buy.
2026-07-27 — v0.42.0 (domain-scoped aliases removed: one holder-controlled alias namespace)
BREAKING on-chain ABI — AliasInstruction discriminants 13–15
(RegisterDomainAlias, RemoveDomainAlias, UpdateDomainAlias) are deleted,
and the gRPC AliasRequest.domain field is removed with its tag reserved.
Clients that emit those instructions or set that field must stop. They were
the tail of the enum, so nothing renumbered; per the versioning preamble the
protocol is pre-launch, so MAJOR stays 0 and this lands as a MINOR bump with
the break stated plainly.
- Domain-scoped aliases are gone. A verified domain’s authority could map any local part under its own suffix to a wallet it chose, and that mapping took precedence over the global namespace — so an authority could silently capture mail for a global alias holder resident on its domain, with no consent and no write to any account of theirs. Resolution is now global and domain-blind everywhere (gateway, GUI clients, inbound SMTP): the suffix is parsed off and discarded, and only a name’s holder can repoint it. See Aliases.
- Lockbox’s end-to-end claim is now unqualified. Sealing follows resolution in the sender’s browser, so the domain-scoped namespace was the one operator power that reached inside a sealed body — redefining which wallet an address named chose which key a sender sealed to. Removing it closes that, and Lockbox no longer carries the caveat.
- The verified-sender mark has two independent inputs again. The badge
resolves the From through alias resolution, then checks a
SenderAttestationthat is DNSSEC-gated on the From domain’s own DNS. While one party could both define a local part and vouch for it, that independence was nominal; it is now structural. - Organizations still issue addresses to staff — by reserving global aliases in bulk and transferring each to its holder. That hand-off needs two-party consent and leaves the employee holding the name outright, which the removed namespace did not.
- Error codes 81–83 stay (
DomainAliasAccountInfo,DomainAuthorityMismatch,NoDomainAlias). Unlike the instruction variants they sit mid-enum, where the numeric position is the on-chain code, so deleting them would renumber every error below. A trap note for whoever appends the next alias instruction — it inherits discriminant 13 — is recorded with the removed discriminants. - Docs. The two domain-alias pages are deleted; the
certkeyserver section they hosted moved intact to Get an alias. The old concept page’s claim that a domain-scoped alias “can never shadow or hijack a global alias” was false as written — it described account separation while denying the resolution shadowing that actually occurred; the threat model now states the guarantee correctly. No rendered client surface changed, so the existing client screenshots remain accurate.
2026-07-27 — v0.41.0 (money-path hardening: sender stamp reclaim, purchase slippage guard, admin-close value guard)
-
Senders can now withdraw unspent prepaid postage. A new
ReclaimFromboxStampsinstruction (discriminant 52) returns the balance above rent to a sender who prepaid against their own wallet address, zeroing the stamp count and leaving the frombox alive on its rent so the recipient keeps the price it set. The frombox derives from the hash of the signer’s address bytes, so reproducing that derivation is the authorization — no stranger can reach someone else’s frombox, and a frombox keyed on an email string stays recipient-managed by design. Documented at Reclaiming unspent stamps, with the concept-level story under Prepaying with stamps. This corrects the threat model, which previously stated that prepaid stamps had no refund path at all. New instruction: MINOR. -
Stamp purchases carry a slippage ceiling.
CreateFromboxandAddStampsgained an additivemax_price_lamportsfield; the recipient controls the per-stamp price and can raise it between the moment a buyer is quoted and the moment their transaction lands, so a purchase above the ceiling now reverts with custom error 107 rather than silently overpaying.frombox stamppins the ceiling to the price it just quoted by default, with--max-priceto pre-authorize a rise and--no-max-priceto opt out. Additive ABI field affecting how end users buy postage: MINOR. -
The admin reclaim tool can no longer reach accounts holding value.
AdminCloseAccount, in all three programs, now refuses any target whose balance sits above its rent-exempt minimum (custom error 106), sopostmaster reclaimreaps only rent-empty leftover state — never a sender’s escrowed postage, a reply bounty, or a live auction bid. New error code: MINOR. -
A permissionless crank can no longer capture the requester’s deposit.
PendingReclaimrecords the wallet that funded the request, anddomain reclaim --finalizepins the pending account’s rent refund to it. Finalize stays permissionless to crank; only the refund target changed. The new field is noted in the privacy reference. Additive account field: MINOR. -
Error codes 105
PinLeaseAccountInfo, 106AdminCloseEscrowPresentand 107PriceExceedsMaxare now listed in the program reference; 105 predates this entry and had simply never been written down.
2026-07-27 — v0.40.7 (timelock docs gate widened to all four constants)
- The “7 days” figure is now fenced for every timelock, not just
mailbox close.
check_timelock.pyguarded exactly one ofmail_model’s four same-valued timelock constants, so the prose describing domain deactivation, reclaim-by-proof and reply bounties could drift from its constant unnoticed — the three constants are deliberately independent literals, so retuning any one of them would have silently falsified those pages. The checker is now table-driven with one row per constant (19 fenced mentions in total, up from 9) and each row is drift-proved independently. Rows own file-exclusive allowlists, enforced at startup, because the context filters really do overlap inside a shared page. Scope stays.md-only: the three diagrams that also say “7 days” are excluded, since the screenshot gate already hashes them. The checker also gained a--self-testproving its exit-code contract against fixture trees, joining the link and anchor checkers. Known gap: a handful of close-family figures remain unfenced —closing-accounts.md,economics.md,threat-model.mdandproving-behavior.mdeach mix figures from two or more constants in one file, which the file-exclusive model cannot express; fencing them needs per-section scoping. Tooling and gate only, no documented behavior changed: PATCH.
2026-07-23 — v0.40.6 (first-run screenshot re-baseline)
- Webmail first-run screenshot re-baselined. The
committed
webmail-first-run.pngwas the last frame still shot on the old capture rig: its standalone capture path had no CDP session, so the frame rendered in the capture host’s own color scheme and could not be reproduced on a box whose desktop theme differed. The shot now rides the same capture driver — and the same dark-theme/timezone pin — as every other committed screenshot, so the full set is byte-reproducible on any box (run-to-run AE=0 across all four driver-captured frames). Same subject, same dark theme; only capture tooling and pixels moved. Documentation only: PATCH.
2026-07-23 — v0.40.5 (API-mode on-chain reply reveal + mailbox-credentials reference)
- Webmail: “Reply on-chain” now works in API mode. The on-chain compose card was CSS-hidden for the whole page lifetime whenever an account API was configured, so a trustless-viewer Reply click seeded the draft into an invisible card — a silent no-op. The card now reveals itself when the pane opens (it stays hidden until then, so nothing changes in the always-rendered UI), floating in the corner like the server compose pane. This narrows the v0.40.4 entry’s “it was never silent” note: that held for trustless mode only. The webmail screenshot set was proven pixel-neutral (same-rig A/B, AE=0 on all three shots) and re-pinned hash-only. Client-shell UX only, no protocol change: PATCH.
- CLI reference: new
Mailbox credentials
page documents
sithbit mailbox credentials— the offline, no-RPC derivation of the deterministic mail login (username = the wallet public key, password = the base58 wallet signature over the fixed auth challenge) — and wires it into the Mailboxes command tree beside the client-certificate alternative; the client walk-through pages already showed the invocation and are now cross-linked from the reference. Documentation only: PATCH.
2026-07-23 — v0.40.4 (lease-open acknowledgement + reference and screenshot upkeep)
- Lease-open acknowledgement — the trustless viewer’s “Lease this
message” button now switches the two view-routed shells to the settings
view with the lease form prefilled: webmail routes
via the
#/settingshash (so the browser’s Back button returns to the mail view), while the Outlook taskpane switches its plain view state (no history entry). No reply listener was added — the on-chain Reply compose card already opens in place in the mail view, so it was never silent. The webmail screenshot set was proven pixel-neutral (same-rig A/B, AE=0 on all three shots) and re-pinned hash-only. Client-shell UX only, no protocol change: PATCH. - Docs screenshots:
webmail-inbox.pngandmarketplace-listings.pngre-baselined on the current capture rig. The webmail capture driver now pins the topbar wallet line to a fixed display base58 (the public key of the checked-inmail-key1test keypair — the same wallet the marketplace capture signs in with) before the inbox shot; previously a fresh keypair minted per run made that line the frame’s one nondeterministic element. Both shots are re-shot on the chrome-headless-shell 151 rig and proven run-to-run byte-identical (AE=0 across consecutive full captures), so committed-vs-fresh comparisons are directly meaningful again. Capture tooling and images only: PATCH. - CLI reference: new
Create a client certificate
page documents
sithbit mailbox create-cert— the offline SASL EXTERNAL certificate mint (<prefix>.crt/.keyPEMs plus the deterministic password-less.p12, including--out’s extension-replacement behavior) — and wires it into the Mailboxes command tree; installation stays on the client walk-through pages, now cross-linked. Documentation only: PATCH.
2026-07-23 — v0.40.3 (the gRPC RPC rosters now gate-fenced against the proto)
-
Docs-tooling: the two hand-maintained
SolanaMailRPC rosters are now gate-fenced. A new docs-gate leg,mail_docs/check_rpc_rosters.py, diffsmail_api/README.md‘s flat RPC list and the gateway topology appendix’s three role buckets against the service definition inmail_api/protos/sithbit.proto: name-set equality both ways, the buckets’ union covering the proto set with no RPC claimed by two roles, and — where a bucket is introduced by an English number word (“Five RPCs”, “Thirteen RPCs”) — that word matching the bucket’s own list length. Both rosters had silently gone stale twice before (most recently the chain-read bucket omitting v0.40.0’sGetPinLease, caught only by hand at v0.40.1) — that drift class now fails the gate instead of waiting for a manual sweep. Docs tooling only, no protocol or server change: PATCH. -
Docs-tooling: the committed capture tooling now reproduces the webmail settings screenshot on its own.
capture-populated.mjs’s webmail pass now scrolls the settings page to the Pinning-leases pane — the shot’s subject, which sits below the fold — before shootingwebmail-settings.png; previously the committed image was reproducible only with an uncommitted modification to the capture driver. The upstreamed step’s output was verified byte-identical to the committed PNG, so no screenshot changed. Docs tooling only, no protocol or server change: PATCH. -
The trustless viewer now hands the open message to the Pinning-leases pane in all four GUI shells (webmail, Thunderbird, Outlook, Chrome): a Lease this message button beside Reply — shown only once a body has rendered, since a local-only message has no CID to lease — dispatches a
sithbit-lease-open {cid, messageId}event that prefills the pane’s create fields with the message’s on-chain CID and id. In webmail and Outlook the prefill waits in the settings view where the pane lives; in Chrome and Thunderbird the pane sits above the viewer on the same page. Prefill only — the user still reviews the deposit and submits — and manual CID entry is unchanged. No committed screenshot changes appearance (the new button is unreachable in every committed capture). Client-side UI only, no protocol or on-chain ABI change: PATCH.
2026-07-23 — v0.40.2 (pinning leases reach the web clients)
- A Pinning leases pane in all four GUI shells (webmail, Thunderbird, Outlook, Chrome): the v0.40.0 pinning-lease surface — until now CLI-only — is now a shared dashboard pane. Create a lease by a message’s CID and id (the recipient defaults to your own mailbox, the deposit prefills to the protocol minimum straight from the on-chain constant, and the one-time creation fee splits to the recipient’s operator exactly as the CLI resolves it), check whether your wallet holds a lease on a CID, and close a lease anytime to reclaim the deposit — including after the message itself has settled, since the lease is addressed by the CID. The transactions are built and signed in the shared wasm module, byte-parity-fenced against the CLI’s own builders, and the pane is documented on each client page. Client-side UI only, no protocol or server change: PATCH.
2026-07-22 — v0.40.1 (the sender-reputation figures reach the gRPC gateway)
- New
GetSenderReputationRPC on theSolanaMailservice: wallet in, the recorded cumulative postage spend and the effective first-contact rate in bps out — the same two figures as the CLI’ssithbit postoffice reputation, computed through the same fenced on-chain rule (tuned floor included), so MX operators and other servers can weigh a sender’s on-chain track record without shelling out to the CLI. Absent reputation account = the ordinary zero-spend/full-price answer; a failed chain read surfaces asUNAVAILABLErather than masquerading as zero spend. The gateway topology appendix’s chain-read role now counts all thirteen read RPCs (it had also omitted v0.40.0’sGetPinLease). Additive gRPC surface, no on-chain ABI change: PATCH. - The settings pane’s wallet-derived mail password is now real markup in all four shells (webmail, Thunderbird, Outlook, Chrome — the pane logic existed but no shell rendered it): a Derive mail password button, gated on the wallet being unlocked, with the same one-time reveal pattern as the encryption-key pane — username and derived password computed entirely client-side (see The mail password). A reactivity fix rides along: unlocking the wallet now re-renders the derive gate immediately (it previously stayed on the “unlock your wallet” hint until a reload). The webmail settings screenshot was re-shot to show the new section. Client-side UI only, no protocol or server change: PATCH.
sithbit mailbox create-certand the extensions’ Certificate sign-in now emit a combined.p12: the CLI writes<name>.p12— a password-less PKCS#12 bundle (certificate + unencrypted key, deterministic per wallet) — beside the PEM pair whenever--outis given, the wasm module derives the byte-identical bundle client-side, and both the Thunderbird and Outlook extensions’ Download client certificate button now saves<pubkey>.p12first, ahead of the PEM pair. The client pages’ certificate-login walkthroughs drop the manualopenssl pkcs12 -exportconversion step — the bundle imports in one step (leave the password prompt blank) — and note that the.p12, like the.key, embeds the wallet secret. Client surface only (CLI output + extension download), no protocol or server change: PATCH.
2026-07-22 — v0.40.0 (pinning leases: paid extended retention for mail bodies)
- New: pinning leases —
a per-(CID, holder) mail-program account
(
sithbit mail lease create/show/close) escrowing a reclaimable deposit (minimum 0.01 SOL, returned in full at close) that asks operators to keep a message body pinned past the default retention. Deliberately no expiry and no renewal fee; the only spend is a one-time creation fee (default 0.001 SOL, cap 10×, tunable viaSetPinLeaseFee/ read-onlypostoffice fee pin-lease) split with the recipient’s domain authority at the operator share. New instructionsCreatePinLease(49) /ClosePinLease(50) /SetPinLeaseFee(51), errors 103–105, Postoffice 192→200; see the economics rationale and the program reference. - The auto-settle sweeper
enforces leases with no new configuration: before releasing a pin it
asks the gateway’s new
GetPinLeaseRPC whether the CID is leased — a leased copy still settles (the stamp reclaim is unaffected) but keeps its pin; an unanswerable lookup fails closed and the copy retries next sweep. - The privacy reference on-chain account table gains the PinLease row (a lease publicly binds its holder wallet to a message CID), and the compute-units table the three new fences.
2026-07-22 — v0.39.2 (small-item cleanup: cheaper first-contact purchases, fee visibility, reference completeness)
- CreateFrombox on the default reputation tail costs ~27% less compute: the pricing pass’s postoffice and reputation reads now thread through to the fee-collection leg instead of being re-derived (the second postoffice PDA grind was the bulk of the cost). Measured CU dropped 48,625 → 35,256 and the fenced ceiling 72,000 → 58,000 — see Compute-unit budgets. No account-list, fee, or pricing change: PATCH.
sithbit postoffice fee attestationjoins the public read-only fee getters: it prints the effective one-time verified-sender attestation fee and its cap without a signature, in every CLI build. The read-surface list also now names thefee settlementgetter it had omitted.- Reference de-staling: the
mail-grpc topology appendix’s
chain-read role now counts all eleven read RPCs (it omitted
ListParticipantsandGetSenderAttestation), and the privacy field reference’s on-chain account table grew from nine rows to the full eighteen account types — adding the marketplace escrow/bid accounts, the participant beacon, the sender attestation/reputation records, and the three pending-timelock markers.
2026-07-22 — v0.39.1 (the verified-sender trust mark reaches the web clients)
- The readers now show a “✓ Verified” trust mark beside the From line when the sender holds an on-chain verified-sender attestation from its domain — the deferred client half of the v0.38.0 trust-mark decision, across all four shells (webmail, Thunderbird, Outlook, Chrome). The api-backed reader resolves the From address to a wallet on-chain and checks that wallet’s attestation; the trustless viewer binds the program-verified envelope signer instead. Absence renders nothing — no negative indicator. See Using it (the inbox screenshot now shows the mark).
- The web compose/prepay paths now pass the payer’s attestation to the first-contact frombox purchase when it exists on-chain (one existence check, the CLI’s default since v0.39.0) — so an attested org’s webmail first contact prices at the floor without any CLI step. Top-ups are unchanged (attestation affects first-contact pricing only). No ABI or fee-rule change: PATCH.
2026-07-22 — v0.39.0 (reputation-scaled sender friction: proven senders pay less at first contact)
- The default price a stranger pays at first contact now scales with the
sender wallet’s on-chain track record. New
Reputation-scaled first-contact pricing
section: a per-wallet
SenderReputationaccount (new mail-sidesender_reputationPDA seed — see the PDA seeds table) records the wallet’s cumulative distinct-recipient postage spend atCreateFromboxtime, and that spend steps the default first-contact rate: 10,000 bps (full default postage) below 0.1 SOL of spend, 7,500 from 0.1 SOL, 5,000 from 1 SOL, 2,500 from 10 SOL. A verified-sender attestation prices first contact at the floor immediately. Recipient-set prices are never touched — only the default a stranger inherits — and a nonzero price never rounds to zero: first contact is never free. Owner (self) purchases stay on the legacy path, unaffected. - The discount floor is delegate-tunable: new mail-side
SetReputationFloorinstruction (discriminant 48,sithbit postmaster fee reputation-floor <BPS>) tunesreputation_floor_bps(defaultDEFAULT_REPUTATION_FLOOR_BPS= 1,000 bps = 10% of the recipient’s default postage, capped atMAX_REPUTATION_FLOOR_BPS= 10,000; over-cap refuses with new custom error 102ReputationFloorAboveCap, and a zero rate stores the “unset” sentinel and resolves to the default). The postoffice account grew 184→192 bytes (versioned reads default older accounts; the setter upgrades in place). See the tunable-constants table and the error-codes tail. - Third-party stamp purchases now carry a reputation tail by default:
CreateFromboxaccepts 6/8/9/10-account forms — the CLI and wasm builders emit the 9-account form (operator pair + the payer’s sender-reputation PDA, lazily created rent-exempt) on every third-party create, and append the payer’s attestation as a tenth account when one exists on-chain. A present-but-invalid attestation fails the purchase (error 19 / error 17) instead of silently repricing. See Reputation-scaled first contact on the stamps page, which also documents the new read-onlysithbit postoffice reputation <WALLET>lookup (cumulative spend + effective first-contact rate in bps, floor included) and the floor setter. - The wasm frombox builders (
create_frombox_tx/create_frombox_unsigned) gained a trailing optionalattestationparameter and emit the 9-account reputation tail on third-party creates; existing JS callers are unaffected. - The compute-unit table’s
CreateFromboxrow now measures the 9-account default-tail path — 48,625 CU measured / 72,000 fenced (the old 12,251 / 35,000 row measured the owner-legacy list) — andSetReputationFloorlands at 6,756 / 30,000.
2026-07-22 — v0.38.0 (verified-sender attestation: a domain vouches for its sending wallet)
- A domain can now attest its sending wallets on-chain. New
Verified-sender attestation
concept page and
Attest a verified sender
CLI reference: a sending organization proves control of its domain’s
DNS — the same staged DNSSEC proof
domain authorizerides — and mints aSenderAttestationrecord binding the domain to a wallet, the protocol’s trust mark for organizational senders. Attesting requires noMailDomainaccount and confers no serving rights; a domain may attest any number of wallets, one revocable record per (domain, wallet) pair. - Two new domain-program instructions:
AttestSender(discriminant 17, permissionless — the attested wallet rides the payload) andRevokeSenderAttestation(18, holder-signed close with rent refund; the PDA re-derives from the signer, so no other key reaches the record), plus the newsender_attestationPDA seed — see the program reference and the new blake3 table row. - The attestation fee is delegate-tunable: new mail-side
SetSenderAttestationFeeinstruction (discriminant 47,sithbit postmaster fee attestation <LAMPORTS>) tunes the one-time feeAttestSenderpays the postoffice (defaultDEFAULT_SENDER_ATTESTATION_FEE_LAMPORTS= 0.01 SOL, capped atMAX_SENDER_ATTESTATION_FEE_LAMPORTS= 0.1 SOL; over-cap refuses with new custom error 101SenderAttestationFeeAboveCap, and a zero fee stores the “unset” sentinel and resolves to the default). The postoffice account grew 176→184 bytes (versioned reads default older accounts; the setter upgrades in place). See the tunable-constants table. - Both query surfaces ship: the read-only
sithbit domain attestation <MAIL_DOMAIN> <WALLET>lookup (every build), and the gRPC gateway’s newGetSenderAttestationcall —{domain, wallet}→{attested, attested_at}, where a clean absence answersattested = falseand a failed chain read isUNAVAILABLE, never afalse. A client badge over these reads is planned but not yet shipped. See Looking up an attestation. - The compute-unit table’s three attestation
rows (landed with the measurement suite) are part of this release:
AttestSender322,474 CU measured / 345,000 fenced,RevokeSenderAttestation11,224 / 34,000,SetSenderAttestationFee6,546 / 30,000.
2026-07-22 — v0.37.0 (client-certificate download from the extensions)
- The Thunderbird
and Outlook
client pages’ certificate-login sections now document the extension
path: each extension’s settings surface gained a “Certificate
sign-in” section whose Download client certificate button derives the
<pubkey>.crt/<pubkey>.keypair in-extension — byte-identical tosithbit mailbox create-cert’s output, and deterministic per wallet (re-downloading anywhere yields the identical certificate). Import into the mail client or OS store stays manual; locked and external (Phantom/Ledger) wallets cannot derive and the CLI path remains the canonical route.
2026-07-22 — v0.37.0 (privacy concept diagrams)
- The What’s public and private page gained two concept diagrams: one under What your operator holds showing where a password-less account’s mail sits and what at-rest sealing does and does not protect against, and one under What you control laying out the five account-holder privacy settings and their defaults.
2026-07-22 — v0.37.0 (do-not-disturb vs. autoresponder diagram)
- The Do not disturb
concept page gained a side-by-side diagram contrasting the classic
accept-and-autoreply flow (mail piles up with postage to settle; the
“I’m away” reply may never reach the sender) with SithBit’s
refuse-at-the-door
450(the sender’s own mail server queues and retries; nothing piles up and no stamp is burned).
2026-07-21 — v0.37.0 (stamp-fee operator split + honest gRPC fee fields)
- The per-stamp protocol fee now splits with the recipient’s MX
operator.
CreateFrombox/AddStampsaccept an optional trailing “operator tail” — the recipient’s mailbox, its named domain, and the domain authority. When present (the CLI, wasm builders, and web prepay all build it automatically), the authority receivesoperator_share_bps(default 10%) of the hybrid fee and the postoffice the remainder; the buyer’s total is unchanged. Lapse and filler rules mirror the settlement share, and legacy account lists keep the whole fee with the postoffice — the tail is optional, so no client breaks and no instruction payload changed. The owner waiver still precedes the split. See The per-stamp protocol fee. FromboxResponsecan now say “fee unknown”. Newbool stamp_fee_known(field 6) on the gRPC response:falsemeans the gateway’s postoffice read failed and the two fee arms are 0 — unknown, not free — which no value convention could express since a stored flat fee of 0 legitimately charges nothing. The failed read stays non-fatal and uncached.- The wasm frombox builders (
create_frombox_tx/add_stamps_txand their unsigned twins) gained trailing optionaloperator_domain/operator_authorityparameters (both-or-neither); existing callers are unaffected.
2026-07-21 — v0.36.0 (Core Concepts go GUI-first, with concept graphics)
- Every Core Concepts page now points at the GUI clients first. The
Addresses, Mailboxes,
Fromboxes, Aliases,
Domains, Email,
and marketplace pages describe each
user action via the getting-started wizard, the client
panes, and the marketplace web page; the
sithbitCLI equivalents moved into footnotes (or stay inline only where no GUI exists — domain registration, auctions, escrowed transfers, and the advertiser campaign flow). - Four new concept diagrams: Mailboxes gained the address–mailbox–aliases relationship, Fromboxes the sender–recipient–postage triangle, The Marketplace a three-stall market-square map of names, domains, and attention, and Privacy’s one-line summary a public-envelope vs sealed-letter split view. (Campaigns already carries its lifecycle diagram.)
2026-07-21 — v0.36.0 (glossary: sans-io)
- The glossary gained a sans-io entry, and the term’s mentions on Standards and RFC coverage now link to it (hover for the definition tooltip).
2026-07-21 — v0.36.0 (settlement basis-point rates: hybrid stamp fee + tunable operator share)
- The per-stamp protocol fee is now a hybrid: third-party stamp
purchases at
AddStamps/CreateFromboxpay the greater of the flat per-stamp fee and a bps share of the escrowed postage (stamp_fee_bps, defaultDEFAULT_STAMP_FEE_BPS= 100 = 1%, capped atMAX_STAMP_FEE_BPS= 1 000). At the defaults the arms cross at 0.01 SOL of postage per stamp — cheap friend-tier stamps still pay the flat fee, while a default-priced 1-SOL stranger stamp now pays 0.01 SOL instead of 0.0001. The recipient-self-funding waiver covers the whole hybrid unchanged (“friends mail you free” is untouched), and the refundable signature surcharge is never in the bps base. See The per-stamp protocol fee. - The operator share is now delegate-tunable: the 10%
OPERATOR_SHARE_BPSsplit atDeleteMailsettlements, reply-bounty claims, escrowed alias transfers, marketplace sales, and auction settlements now reads the postoffice’soperator_share_bpsfield (default 1 000 = today’s behavior, capped atMAX_OPERATOR_SHARE_BPS= 2 000). Behavior at the default is byte-identical; an unreadable postoffice charges the protocol defaults (the rate read never blocks a settlement). - New
SetSettlementBpsinstruction (discriminant 46, delegate-only) sets both rates in one instruction; over-cap rates refuse with new custom errors 99OperatorShareBpsAboveCap/ 100StampFeeBpsAboveCap. A zero rate stores the “unset” sentinel and resolves to its protocol default — the bps rates cannot be tuned to literal zero. The postoffice account grew 160→176 bytes (versioned reads default older accounts; writers upgrade in place). See the program reference and the tunable-constants table. - Every quote surface knows the hybrid:
sithbit postoffice fee stampprints both arms, the newsithbit postoffice fee settlement/sithbit postmaster fee settlement <OPERATOR_SHARE_BPS> <STAMP_FEE_BPS>read and tune the rates (Postmaster administration), the CLI stamp-purchase preview quotes the hybrid against the actual postage, the gRPCFromboxResponsegainedstamp_fee_bps, and the webmail prepay card and onboarding funding page price quotes through a new wasmstamp_purchase_feeexport. - New section: Modeling the postoffice’s revenue base — the honest segmentation (waived owner purchases, flat-dominant friend tiers, priced-out strangers) that motivates the settlement rates as the scalable, capped levers.
2026-07-21 — v0.35.0 (length-tiered premium pricing for short alias names)
- Registering a 1–4 character alias now pays a per-length premium claim
fee instead of the flat fee; names of 5 or more characters are
unchanged. Defaults: 10 SOL (1 char), 1 SOL (2), 0.1 SOL (3), 0.05 SOL
(4) — short names are scarce assets (36 one-character combinations) and
are priced accordingly, on the registrant’s side per the positioning
principle. The schedule lives on the postoffice
(
ALIAS_TIER_FEES_LAMPORTS, account grown 128→160 bytes, versioned reads default older accounts) and is delegate-tunable via the newSetAliasTierFeesinstruction (discriminant 45), each slot capped at 10× its default (MAX_ALIAS_TIER_FEES_LAMPORTS; over-cap refuses with new custom error 98AliasTierFeeAboveCap). See Economics — Alias holders and the tunable-constants table. - Delegate reservations stay fee-free at every length — the postmaster reserves premium short names for rent alone and resells them on the marketplace at seller-set prices; see Reserve aliases in bulk.
- The price always shows before you pay.
sithbit alias createprints a fee preview (per premium name + run total, or the delegate waiver notice),sithbit postoffice fee aliasprints the effective per-length schedule with its caps, and the newsithbit postmaster fee alias-tiers <1> <2> <3> <4>tunes it — see Create an alias and Postmaster administration. The webmail aliases pane quotes “Registration fee: N SOL” live as you type (from the fetched postoffice account, protocol defaults when unreadable), and the onboarding wizard’s funding check prices a premium handle by its length.
2026-07-21 — v0.34.0 (IMAP SPECIAL-USE mailbox attributes, Tier 1)
- The IMAP server now advertises
SPECIAL-USE(RFC 6154) and marks the well-known top-level mailbox names —Sent,Trash,Drafts,Junk(alsoSpam),Archive— with their\Sent-style attributes in LIST responses, case-insensitively, so clients file sent/deleted/draft mail into the same folders everywhere. INBOX and nested names carry no role; theLIST (SPECIAL-USE)selection filter andCREATE-SPECIAL-USEare not supported. See the Standards support IMAP table.
2026-07-21 — v0.33.0 (IMAP advertises SASL-IR)
- The IMAP server now advertises
SASL-IR(RFC 4959) in the greeting and CAPABILITY responses, wherever theAUTH=mechanisms are offered. The initial-response form of AUTHENTICATE was already accepted; the advertisement lets clients discover it instead of probing. See the Standards support IMAP table.
2026-07-21 — v0.32.0 (docs: setup and earnings join the SithBit CLI; onboarding leads with the web wizard)
Documentation-only: the version tags the unchanged protocol state.
- The two CLI walkthroughs move into the SithBit CLI reference tree:
First-run setup (
sithbit setup, now the tree’s first subtopic) and Revenue snapshot (sithbit earnings, between Campaigns and Closing accounts). Cross-links follow (Fromboxes’ USD-annotation pointer, the CLI Quickstart’s walkthrough link, and Solana clusters’ faucet note). - “Setup and earnings” becomes Getting started — the page now opens with the browser wizard the four web clients share (the audience most users belong to), keeps the standalone get-started and refused-sender pages, and points terminal-comfortable readers at the two relocated CLI topics. The page’s URL and section anchors are unchanged.
2026-07-21 — v0.32.0 (docs: tables wrap in place instead of scrolling)
Documentation-only: the version tags the unchanged protocol state.
- Prose tables no longer cut off their last column behind mdBook’s
horizontal scrollbar — felt hardest in the Configuration
reference’s key/default/meaning tables.
Book-wide CSS (
css/brand.css) now spans tables across the text column, slims the cell padding, left-aligns headers, and lets long tokens in every column but the first break at the overflow point (config keys never break mid-token; a width floor keeps “Default” readable beside a long “Meaning”).
2026-07-21 — v0.32.0 (docs: deploy-page service table and provider links)
Documentation-only: the version tags the unchanged protocol state.
- Running a mail server’s service table now lists
the optionally-embedded IPFS node among
sithbitd’s roles, with its “needed when” column noting that role applies only under[ipfs] kind = "embedded"— a fleet delegates tosithbit-ipfsdor a pinning service instead. - Choosing a commodity provider — each provider name now links to that provider’s developer sign-up page (or product home page where sign-up URLs are region-specific).
2026-07-21 — v0.32.0 (docs: Icon legend joins the Glossary)
Documentation-only: the version tags the unchanged protocol state.
- The Icon legend now sits in the Glossary
sidebar section, right after Terms and
definitions, instead of under Appendix: Reference —
the two term-lookup pages now live side by side. The source file (and its
deployed URL) is unchanged; only the
SUMMARY.mdplacement moved.
2026-07-21 — v0.32.0 (docs: landing-page hero leads with earned postage)
Documentation-only: the version tags the unchanged protocol state.
- Welcome landing tweaks — the hero paragraph now says the sender-paid postage is earned by you, not just that it prices out spam, and the “No gatekeeper, no single company” card drops its featured accent border to sit as a regular card; the spam-pricing card is the page’s only featured one.
2026-07-21 — v0.32.0 (sithbitd installs as a systemd or Windows service)
MINOR — an additive deployment capability.
- Running as an OS service —
the new
sithbitd service install/sithbitd service uninstallsubcommands install the daemon under systemd (a generated unit file with restart-on-failure, network ordering, and commented unprivileged-user/low-port-capability lines;--printrenders it without writing) or the Windows service control manager (an auto-start registration whose internalservice runverb re-anchors the recorded working directory and config before the daemon boots). Neither install activates anything behind the operator’s back — thesystemctl/Start-Servicestep is printed, not run.
2026-07-21 — v0.31.0 (Closing accounts joins the SithBit CLI reference)
PATCH — documentation-only.
- The closing-accounts reference now lives in the SithBit CLI tree as
its Closing accounts
subtopic, right after Campaigns — retitled from “Closing accounts and
reclaiming rent”, following the campaign reference out of the appendix.
All cross-links follow, and the old deployed URL
(
appendix/closing-accounts.html) redirects to the new page so external bookmarks keep working.
2026-07-21 — v0.31.0 (Campaign CLI reference moved under the SithBit CLI)
PATCH — documentation-only.
- The
sithbit campaignreference now lives in the SithBit CLI tree as its Campaigns subtopic, beside the other command references, instead of in the appendix. All cross-links follow, and the old deployed URL (appendix/campaign-cli.html) redirects to the new page so external bookmarks keep working.
2026-07-21 — v0.31.0 (Sponsored mailbox creation: a domain authority provisions for its users)
MINOR — additive public-ABI change (a tail field on CreateMailbox and
on the Mailbox account, plus three appended error codes).
- A domain’s on-chain authority can now create a mailbox for a different
owner. Sponsored mailboxes
explains the concept and its three guards: only the named domain’s
authority may pay, the default postage is forced to the 1-SOL spam floor,
and no self-alias is bundled. The CLI surface is
mailbox create --for <address>(requires--domain). - The mailbox account records its funder, and closing refunds the funder.
The
Mailboxaccount gains a tailfunderfield (shown bymailbox getand the wasm account decoder);mailbox close --finalizenow routes the mailbox’s rent to the recorded funder — the owner itself on a self-created mailbox (unchanged), the sponsor on a sponsored one — with the CLI passing the funder account automatically. The pending-close account’s rent still refunds to the owner who funded the request. - Program & PDA reference
gains error codes 95–97 (
SponsoredMailboxRequiresDomain,UnauthorizedDomainSponsor,FunderAccountInfo) and updates theCreateMailbox/FinalizeCloseMailboxrows. This retires the lastTODOin the workspace’s Rust tree (the payer/owner split in the mail program’s create processor).
2026-07-21 — v0.30.1 (Navigation reorder: standards up front, clients first under Using SithBit)
PATCH — documentation-only.
- Standards and RFC coverage moved to the front matter, directly after the Introduction — the wire-compatibility story now greets a reader before the concept chapters instead of trailing them.
- “Using SithBit” reordered around the reader’s journey: GUI clients leads the section, followed by Setup and earnings (moved here from Core Concepts), with Economics after them.
- “RFC” is now a glossary term. The glossary’s Mail protocols section defines it, so RFC references linked to it get the standard hover tooltip; the standards page links its first prose mention.
2026-07-21 — v0.30.1 (Full sithbit-console tutorial and reference in the appendix)
PATCH — documentation-only.
- The
sithbit-consoleadmin TUI now has a full appendix page. The sithbit-console admin TUI documents the operator console end-to-end: prerequisites (a reachable account API and theadmin_walletsallowlist), running and configuring it, the wallet-challenge login, a tutorial through both tabs (Accounts → Mailboxes → Messages with chain states, the on-chain balances pane, queue depths and the confirmed dead-letter requeue/discard workflow with the cloud-store claim window), a complete key reference, the console’s deliberate scope limits (API-only, no store access, no chain writes), and a troubleshooting table. The job-queues section and the configuration reference now link to it; previously the console was documented only in fragments across those two pages.
2026-07-21 — v0.30.1 (Client pages note on-chain domain ownership for wallet submission)
PATCH — documentation-only.
- The Outlook and Thunderbird client pages now carry the on-chain-ownership
half of the wallet-submission envelope rule. Both
Outlook and
Thunderbird previously phrased
the sender rule as “your own wallet address at a domain the server serves /
is authoritative for”, which omitted the v0.29.0 enforcement: on a
chain-connected submission server the wallet must also own that domain
on-chain (its recorded
GetMailDomainauthority), not merely have the server serve it. The pages now state that nuance at end-user altitude and add the matching553 5.7.1refusal case; the full rule (including the chain-less-dev-stack fallback to server-served domains only) still lives in the configuration reference.
2026-07-21 — v0.30.0 (Onboarding wizard warns on an unfunded wallet before the mailbox create)
MINOR — a new client capability and default onboarding behavior.
- The web onboarding wizard now checks the wallet balance before it claims a mailbox. On the Review and Finish steps, a wallet that can’t cover the create cost (the account rents, plus the flat alias fee when a handle is claimed — about 0.0013 SOL bare, 0.0124 SOL with a handle) gets a plain-language warning naming the wallet, its balance, and how much more to transfer. The warning does not block the flow — you can fund the wallet out of band and continue.
- A funded-then-failed create no longer shows the raw chain error. If the create is attempted with too little SOL, the node’s “Attempt to debit an account but found no record of a prior credit” preflight rejection is rewritten into the same funding guidance, across the create, import, and connect-wallet paths.
- Single source of truth for the figure. The required-funding amount the
wizard quotes is computed by the same core routine the CLI
sithbit setupwizard uses, so the web and CLI figures can never drift.
2026-07-20 — v0.29.0 (Wallet submission envelope now checks on-chain domain ownership)
MINOR — a new enforcement default that affects deployments.
- A gateway-backed submission listener now requires the authenticated
wallet to own the envelope domain on-chain. For a wallet-literal
Wallet submission envelope,
a listener with a chain gateway (
[grpc]configured) no longer accepts<wallet>@<domain>merely because the domain is inlocal_domains; the domain must also be one the wallet is the recorded on-chain authority for, checked via the gateway’sGetMailDomainlookup (exact base58 match).local_domainsstill scopes which domains the listener serves; the authority check scopes which of those the authenticated wallet may send as. - Chain-disabled listeners are unchanged. A listener with no chain
gateway (an empty
[grpc]/ dev MX) has no per-wallet lookup available and falls back tolocal_domainsalone, so empty-config dev stacks keep sending.
2026-07-20 — v0.28.0 (Chrome extension gains trustless compose/reply parity)
MINOR — a new client capability and a new shipped default that affect deployments.
- The Chrome extension can now send trustlessly, at parity with webmail,
Thunderbird, and Outlook. The
Trustless viewer’s header
gains a Reply on-chain button, and the popup mounts the same floating
on-chain compose card the other GUI clients carry — a Compose on-chain
button opens it blank, Reply seeds it with the decrypted sender and the
parent message’s account address, and the seal → pin →
SendMaillifecycle is signed in the extension’s wasm module with no mail server in the path. - The extension ships a default IPFS pin origin. Connection settings
gains
ipfsPinUrl(defaulthttp://127.0.0.1:8182— an unauthenticated loopback sithbit-ipfsd) and its optionalipfsPinToken(default empty), where outbound sealed bodies are pinned; saving a non-loopback pin origin prompts for that host’s permission.
2026-07-20 — v0.27.1 (Second CID pointer linked on the mailbox page)
PATCH — documentation-only, no protocol change.
- The “Opting out of IPFS storage” section now links “CID”. The Opting out of IPFS storage prose said the on-chain message carries a “fetchable CID” as bare text; it now points to What is a CID?, matching the same page’s No-IPFS bullet, which already linked the term.
2026-07-20 — v0.27.0 (All nine dashboard panes documented on every GUI client; CID explained for non-technical readers)
PATCH — documentation and docs-tooling only, no protocol change.
- The Domains, Reply bounties, and Mailbox panes are now documented on all four GUI client pages. The Thunderbird, Outlook, Chrome and webmail pages previously described only six of the nine shared dashboard panes; the domain-marketplace pane (list or buy a domain), the reply-bounty settlement pane (claim a bounty on a message you replied to, or refund an expired one you placed), and the mailbox-config pane (claim the mailbox and set its handle, sending domain, default stamp price, and opt-out-of-IPFS flag) are now described on each, in that page’s own form.
- “CID” is now explained for non-technical readers. The
IPFS storage: benefits page opens
with a new “What is a CID?” section that explains a content identifier as a
fingerprint computed from a message’s exact bytes — the same content always
yields the same CID (so it is the address you fetch by) and any change yields
a different one (so it doubles as a tamper check) — with a two-row
illustration and a note that SithBit produces CIDv1 byte-for-byte identically
to Kubo. The glossary’s terse
CIDentry now links to it. - Docs-tooling: the mailbox-close timelock figure is now fenced. A new
mail_docs/check_timelock.pygate leg parsesMAILBOX_CLOSE_TIMELOCK_SECSfrommail_model/src/constants.rsand asserts, both ways, that the “7 days” quoted in the mailbox-close docs matches it — so retuning the constant or drifting the prose fails the docs gate. It is scoped by an explicit allowlist plus a mailbox-close context filter, so the identical “7 days” literal used for the domain-deactivation, reclaim, and bounty-window constants is not swept in. - The dashboard chain panes now load over a direct RPC connection, not only the account API. Balances, the encryption key, mailbox settings and the mailbox-close request now populate for a wallet-unlocked client with no account API configured — previously they re-rendered but stayed empty until an API token existed. The Aliases pane still needs the API, since there is no on-chain alias index to read directly. (The web-client screenshots were re-pinned to the updated source: this change is confined to the API-less load path, which the documentation screenshots — captured in API-backed mode — do not exercise, verified by re-capturing the inbox, settings and marketplace shots.)
- Docs-tooling:
check_anchors.pygains a--self-testleg. A checked-in, build-free fixture tree undermail_docs/tests/anchor_fixtures/(aclean/root that must exit 0 and abroken/root that must exit 1) proves the checker’s exit-code contract, mirroringcheck_links.py --self-test. Unlike the link fixtures, each anchor-fixture root ships both asrc/and a hand-authoredbook/HTML tree, because the checker validates#fragmentlinks against built-book anchors. It is a standalone dev command, not wired into the docs-gate chain; the defaultcheck_anchors.pyrun is unchanged.
See The Chrome extension, The webmail app and IPFS storage: benefits.
2026-07-20 — v0.27.0 (External wallets can buy stamps and claim a mailbox; Glossary promoted)
PATCH — client and docs only, no protocol change.
- External wallets (Phantom/Ledger) can now buy stamps and claim a mailbox. The dashboard gated those buttons on holding an unlocked in-app wallet key, even though the unsigned-transaction paths behind them were already wired and working for external wallets. Setting a stamp price, publishing an encryption key and settling reply bounties still require the in-app key — those have no unsigned equivalent — and the panes now say so specifically instead of telling every user to “unlock your wallet”.
- The mailbox-close pane is documented on the Thunderbird, Chrome, Outlook and webmail client pages, including the 7-day wait, that it is cancellable throughout, and that the encryption-key close stays instant. Outlook gained a full pane list, which it previously lacked entirely.
- The account-closing figure no longer shows
CloseMailboxas an instant one-step close; the mailbox and key legs now carry their own timings. local_domainson the submission listener is shown as a real configuration example rather than described in prose. Each SMTP role carries its own list — the MX section’s copy does not carry over — which is the domain half of the wallet-envelope rule.- The Glossary is now a top-level section in the navigation, immediately before Appendix: Reference. Its page keeps its existing address, so every existing link to it still works.
- The web-client screenshots were regenerated, which replaced a marketplace listings image that had been shipping as a blank page.
- The Core Concepts pages no longer assume you can read the source. References to internal file, function and type names, and to configuration keys and their file sections, have been rewritten as plain statements of what the system does — the concepts pages now explain the protocol without requiring a copy of the code beside them. Every fact those references carried is retained; only the way of stating it changed.
See GUI clients, Close a mailbox, Configuration and Terms and definitions.
2026-07-20 — v0.26.0 (Closing a mailbox is timelocked; the one-step close is disabled)
MINOR — BREAKING for clients that emit CloseMailbox. Closing a mailbox is
now a two-step, 7-day flow. RequestCloseMailbox (42) starts the clock and
refunds nothing — the mailbox stays open and keeps receiving mail;
FinalizeCloseMailbox (44), legal only after MAILBOX_CLOSE_TIMELOCK_SECS
(604,800 s), closes it and refunds the mailbox’s rent and the transient
pending record’s together; CancelCloseMailbox (43) aborts the request
meanwhile. The CLI spells these mailbox close, mailbox close --finalize
and mailbox close --cancel, and the flow is reachable in webmail, Outlook,
Thunderbird and Chrome, including trustless mode.
- The one-step
CloseMailbox(discriminant 16) is refused with error 94,InstantCloseDisabled. The discriminant still decodes, so indexers replaying history resolve old transactions — the same shape item 30 used forTransferAlias/UnilateralTransferDisabled(85). MAJOR stays0: the protocol is pre-launch. - New errors 91–94:
MailboxCloseAlreadyPending,NoPendingMailboxClose,MailboxCloseTimelockNotElapsed,InstantCloseDisabled. New PDA seedPENDING_MAILBOX_CLOSE_SEED(pending_mailbox_close) — the prefix is load-bearing twice over: the Mailbox PDA is bare-seeded on the address, andPendingMailboxClose,PendingDeactivationandPendingReclaimall serialize to the same eight bytes, so only the derivation distinguishes them. - Why a delay and not a fee. Instant rent reclamation made a burned sending identity free to discard. The owner of a mailbox is a recipient, and the positioning principle puts the cost burden on senders — so the lever is time, not money: the rent still comes back in full. Spammers get capital stuck for a week per burned identity and operators get a flagging window; honest owners see a delay on an action they take approximately never.
CloseKeydeliberately stays instant. It revokes a compromised delegated encryption key; a seven-day window there would leave MX servers sealing to a key the attacker holds, protecting the attacker rather than the owner.- Compute units: three new fenced rows — RequestCloseMailbox 13,334 / 36,000, CancelCloseMailbox 12,156 / 35,000, FinalizeCloseMailbox 12,858 / 36,000.
See Close a mailbox, Closing accounts, the threat model and Economics.
2026-07-20 — v0.25.0 (Wallet submission envelopes are pinned to the wallet’s own address)
MINOR — a new enforcement default on the submission path; no protocol
change. A wallet-authenticated submission session may now present exactly one
envelope sender: its own wallet base58, at a domain the listener is
authoritative for (local_domains). Another wallet’s address, its own address
at a domain the server does not serve, and the null sender MAIL FROM:<> are
all refused 553 5.7.1.
- The case-sensitivity fix is the security-relevant part. The previous rule
compared the envelope local part case-insensitively, which for base58 is
wrong:
Aliceandalicedecode to different keys, so a wallet session could send as a neighbouring valid wallet. The comparison is now exact. - Scope, stated honestly. The domain leg checks the domains this server serves, not the domains this wallet’s mailbox holds on-chain — the per-wallet reverse lookup is not reachable from the SMTP driver without new gateway surface. On a multi-domain instance a wallet may still send as itself at any domain that instance serves.
- Alias and password submission are byte-for-byte unchanged; the new rule is consulted only for wallet-literal identities.
- Dev-stack trap. With
local_domainsempty the check falls back tohostname, which the empty-config dev stack leaves aslocalhost, and the chain-disabled dev stack never discovers domains — so sending as<wallet>@sithbit.netthere now returns553where it previously worked. The refusal text names the sender, not the domain, so both the symptom and the one-line fix are written down. Note each SMTP role reads its ownlocal_domains: setting it under[smtp]does not affect the[submission]listener.
See Wallet submission envelopes, Thunderbird and Outlook.
2026-07-19 — v0.24.0 (Onboarding: passphrase confirmation, reachable connection settings)
PATCH — client/docs only, no protocol change. Four fixes found smoke-testing the Chrome extension loaded unpacked, all in shared client code, so every client gets them.
- Passphrase reveal and confirmation. The wallet passphrase set during onboarding could not be seen and was typed only once — and a typo there is unrecoverable: the wallet seals fine and only fails later, at unlock, with no way back. Every passphrase field now has a reveal (“eyeball”) toggle, and every field that sets a passphrase (the onboarding wizard’s step 1, the wallet manager’s import, the marketplace sign-in) now requires a matching confirmation before it will seal anything. See Web onboarding: the browser wizard.
- Connection settings are reachable during onboarding. In the Chrome extension they had been gated behind the signed-in view, which requires a registered on-chain mailbox — which in turn requires a reachable account API, the very thing those settings configure. With no API running, a new user was pinned in the onboarding wizard with no way to correct the URL or switch to trustless mode. They now sit in a collapsed disclosure at the foot of every view. See The Chrome extension.
- A meaningful message when the account API is unreachable. A refused
connection surfaced the browser’s bare
Failed to fetch. Clients now name the endpoint and the remedy, in language matched to the reader: a loopback URL means the reader runs the stack themselves, any other host means they are somebody’s mail customer. - A degraded popup no longer reads as broken. An unreachable account API is reported as a warning with the fix, and onboarding continues, instead of dumping a raw error and blocking.
2026-07-19 — v0.24.0 (Chrome extension: in-popup mail reader + side panel)
PATCH — client/docs only, no protocol change. The Chrome extension gains
a real in-popup mail reader, at parity with webmail: the shared three-pane
reader (folder rail, message list, message view, compose, and search) over the
account API’s /v1/mail surface when an account API
is reachable, and a trustless on-chain inbox (the mailbox’s messages listed
straight from Solana, bodies unsealed in wasm, no server) as the server-down
fallback and the only reader when the API url is left blank. The same surface
now also opens in Chrome’s persistent side panel via an Open in side
panel button (the toolbar-icon click still opens the transient popup). See
The Chrome extension.
2026-07-19 — v0.24.0 (Core Concepts section rename)
PATCH — docs only. The first documentation part, previously titled
Basic Concepts, is now Core Concepts in the navigation. Only the
displayed part title changed; the page URLs under basic-concepts/ are
unchanged, so existing links and bookmarks still resolve. (Earlier
change-history entries that name the old title are left as-is — they record
what the section was called at the time.)
2026-07-19 — v0.24.0 (Cloudflare backend: true multi-daemon writes)
MINOR — deployment-affecting default-behavior change. The cloudflare
store’s single-writer delivery caveat is removed: IMAP-uid allocation is now
a server-side atomic UPDATE … RETURNING on D1, and keyed leases moved from
Workers KV (best-effort, no CAS) to the same strict single-statement CAS the
SQLite/Turso stores run, on D1’s leases table — so any role may run
N-wide on Cloudflare, exactly as on postgres/aws/azure (see Scaling
out’s checklist and Which stores support
which split). The DMARC
drain also became one atomic DELETE … RETURNING, so concurrent daemons
partition report rows instead of double-reporting. The
[store.cloudflare]
kv_namespace_id key is retired: still accepted so existing TOMLs keep
parsing, but ignored, and no longer a required id — the KV namespace itself
is no longer a provisioning prerequisite. The Durable-Object route the
glossary recorded for this work was
superseded by these plain atomic D1 statements (no Worker-side code).
2026-07-18 — v0.23.0 (Addresses/Fromboxes/Email/Marketplace move into Basic Concepts + Technical Reference)
PATCH — docs only. Wave 1 of the docs audience reorg (item 42): Addresses, Fromboxes, Email, and Marketplace now split cleanly between a new Basic Concepts section (pure conceptual/explanatory prose, no CLI examples) and the CLI reference under Technical Reference → SithBit CLI. Every command-reference page now opens with a short referral link back to its concept page. Mailboxes, Aliases, Domains, and the GUI-clients pages are untouched this wave — the deferred remainder of the reorg. See Basic Concepts → Fromboxes and Basic Concepts → Email for the split’s shape.
2026-07-18 — v0.23.0 (CLI Quickstart relocated ahead of the docs audience reorg)
PATCH — docs only. The developer quickstart page moved from
getting-started.md to CLI Quickstart
under a new Technical Reference section, retitled to avoid colliding
with a future end-user “Getting Started” tutorial section (item 42’s
audience reorg, in progress). Operating a SithBit Server re-nested
under Technical Reference alongside it; no operator pages moved, only
the SUMMARY.md heading structure changed. Every inbound link across the
book was repointed to the new path.
2026-07-18 — v0.23.0 (DNS rows for the autoconfig/autodiscover hostnames)
PATCH — docs only. The DNS guide’s client-access
section now
spells out the two hostname records the native-wizard fallback path
needs — autoconfig.<domain> and autodiscover.<domain> pointed at the
domain-sithbit host — with the TLS-certificate SAN caveat. The routes
themselves were already documented on the
domain-sithbit page;
the zone-side half was missing.
2026-07-18 — v0.23.0 (compute-unit table now gate-fenced against the suite)
PATCH — docs tooling. A new docs-gate leg, mail_docs/check_cu_rows.py,
diffs the compute-unit table’s
measured/ceiling values against the integration suite’s fenced constants
(mail_client/tests/api/cu.rs), both ways, and checks the methodology
prose still quotes the suite’s grind allowance. The rounded-auction-rows
drift the previous entry corrects had sat silent since the auction wave —
this class of drift now fails the gate instead of waiting for a manual
sweep.
2026-07-18 — v0.23.0 (auction rows now quote exact measured CU)
PATCH — docs only. In the compute-unit table, the three auction rows had rounded “Measured CU” values while every other row quotes the integration suite’s exact fenced measurement. Aligned to the suite’s constants: SellAlias (open auction) 26,800 → 26,798, BidAlias 19,700 → 19,715, SettleAuction 26,700 → 26,674. Ceilings unchanged; all 25 rows now match the suite exactly.
2026-07-18 — v0.23.0 (cancel-instruction CU ceilings documented)
PATCH — docs only. The compute-unit table
now covers the three marketplace cancel flows fenced by the integration
suite: CancelTransferAlias (alias transfer cancel, 24,828 measured /
48,000 ceiling), CancelAliasListing (alias sell --cancel, 19,183 /
42,000), and CancelDomainListing (domain sell --cancel, 16,001 /
39,000). The methodology’s bump-grind variance note gains
alias transfer cancel as the widest swing recorded (9,816–24,828 CU).
2026-07-18 — v0.23.0 (threat-model lockbox metadata retitle)
PATCH — docs only. In the threat model, the lockbox scope bullet formerly led “Body only, in v1.” — stale now that the v2 sealed-envelope engine has landed. Retitled “Metadata stays visible.”: the SMTP envelope and headers travel unsealed regardless of lockbox version; the bullet’s substance is unchanged.
2026-07-18 — v0.23.0 (opt-in wallet-literal recipients on a chain-less MX)
MINOR — new configuration setting (default preserves existing behavior everywhere):
[smtp] accept_wallet_literals(defaultfalse). A chain-less MX (no[grpc]configured) refuses every recipient today; with this switch on, it accepts syntactically valid 32-byte base58 wallet-literal local parts — mirroring the account API’s chain-less compose route, where a literal wallet resolves to itself. No postage check applies on that path (no chain to consult), which is why the default stays off: leaving it unset keeps the postage gate intact and behavior byte-identical. Inert when[grpc]is configured. Applies tosithbitdand the standalonesmtp-serveralike. See Configuration.
2026-07-18 — v0.22.0 (marketplace guards: no deactivation while listed, no sale of an inactive domain)
MINOR — additive on-chain behavior change (new refusals using existing error codes; one instruction gains a required account):
RequestDeactivateDomainrefuses while a listing is open. The instruction now takes the domain-listing PDA as a required read-only account and refuses withDomainHasPendingListing(code 64) when a marketplace listing stands — a deactivation can no longer be staged under a live listing. Thesithbit domain deactivatebuilder passes the new account. See Program reference → Marketplace.BuyDomainrefuses an inactive domain. Settlement now re-checksis_activeat purchase time and refuses withInactiveDomain(code 25) — a deactivation finalized after listing can no longer sell a dead name. Inactive domains stay listable by design; the sale completes once the domain is reactivated. (An in-flight pending deactivation was already refused at buy time, code 29.)- Reference corrections riding the change:
DeactivationAlreadyPendingis code 29 (the page said 28), andBuyDomain’s account list is 9 slots (the row predated the pending-reclaim slot).
2026-07-18 — v0.21.1 (devnet keypair locations reconciled)
PATCH — repository layout and documentation only (no code behavior, on-chain ABI, instruction, error-code, or configuration change):
- Keypair homes reconciled.
keypair/again holds the mainnet-track vanity keypairs for all three programs; the live devnet mail/alias program keypairs moved beside the domain one atmail_client/tests/*-dev-keypair.json(the retired first-generation devnet pair is now git-history-only). The devnet vanity-ID appendix’s keypair-location prose and bothcpstaging workflows now reflect the layout, and the per-program README deploy snippets are correct as written again.
2026-07-18 — v0.21.0 (lockbox envelope: rich HTML + attachments in the engine)
MINOR — additive capability in the shared lockbox engine (no on-chain ABI, instruction, error-code, or configuration change; the shipped plugins’ user-facing behavior is unchanged today):
- The sealed payload is now a structured envelope. The shared compose/read engine seals a JSON envelope carrying the text body plus, when the sending client supplies them, rich HTML and attachments — as one sealed unit, with no wasm or on-chain change. Messages sealed by earlier versions remain readable (bare-body fallback). A 12 MiB pre-seal ceiling refuses oversized payloads with a clear error (sized so the double-base64 result clears the SMTP server’s default 25 MiB message-size limit). The Thunderbird and Outlook plugins still hand the engine only the plaintext body — host-side compose glue for HTML/attachments is a planned addition. See How it works and What v1 does — and does not — do.
2026-07-18 — v0.20.7 (smoke script rebuilds images)
PATCH — tooling and documentation only (no code behavior, on-chain ABI, instruction, error-code, or economic change; no configuration key, value, or default moves):
docker/smoke.shrebuilds before probing — the script now brings the compose stack up withdocker compose up -d --build, so a standalone smoke run rebuilds the images instead of silently probing stale local ones (the already-exportedchainprofile covers themail-grpcbuild too). Documented in The compose dev stack.
2026-07-18 — v0.20.6 (web-client terminology sweep)
PATCH — code comments only (no rendered UI, logic, or configuration change):
- “knob” retired from the web clients — the eight remaining
occurrences in
webclients/source comments (the shared panes and onboarding-wizard modules, and the fund/DND standalone pages’ operator-endpoint headers) now read “setting”, completing the v0.20.1/v0.20.4/v0.20.5 terminology sweeps. The screenshot manifest was re-pinned hash-only — no pixel changed, so the recorded captures remain valid.
2026-07-18 — v0.20.5 (build-features heading & terminology polish)
PATCH — documentation and comments only (no code behavior, on-chain ABI, instruction, error-code, or economic change; no configuration key, value, or default moves):
- Deploy’s build-features section renamed — the heading is now
Slim-build features
(formerly “Storage-backend build features”), reflecting everything
the section grew to cover: the storage backends and the key-source
(
akv/asm/gsm) and app-config (awsconf/azconf) cloud features. Inbound links in Deploy, Scaling out, and this page’s earlier entries follow the new anchor (URL only — the old entries keep their wording). - Configuration-reference cross-links — the key-sources and cloud-app-config passages in the configuration reference each point at Slim-build features for the per-binary slim build commands.
- Terminology residuals — the retired “knob” leaves its last
holdouts (the
ipfs_daemonandipfs_gatewaycrate READMEs and acheck_config_keys.pycomment; now “setting”), completing the v0.20.1/v0.20.4 sweeps.
2026-07-17 — v0.20.4 (terminology sweep completed in source)
PATCH — source comments and example-config prose only (no code behavior, on-chain ABI, instruction, error-code, or economic change; no configuration key, value, or default moves):
- Terminology sweep, source side — the informal “knob” is now retired
from the Rust source comments and test names (
mail_spooler,smtp_server,account_api,mail_store,mail_client,mail_grpc,ipfs_swarm,pop3_proto,app_config,key_source) and from the prose comments of the two canonical example configs (sithbitd.example.toml,sithbit_ipfsd.example.toml), completing the v0.20.1 book sweep. Same standing rule, same replacements: “switch” for boolean enable/disable entries, “setting”/“option” for tunable values, and case-appropriate rewrites (e.g. “deliberately not configurable”) elsewhere. Meaning is unchanged everywhere; earlier entries on this page keep their wording as the record of the retired term.
2026-07-17 — v0.20.3 (slim per-binary builds)
PATCH — build features and documentation only (no on-chain ABI, instruction, error-code, or economic change; a default build compiles the exact same feature set as before):
- Slim per-binary builds are now real — every binary crate forwards
its dependencies’ cloud features under the same names, so
--no-default-features --features <what you need>works at the binary you actually build. Forwarded alongside the storage backends:akv/asm/gsm(the credential-sealing key source’s cloud secret managers) andawsconf/azconf(the cloud app-config sources). See the build-features section for concrete slim build commands. Defaults still compile every cloud; a config naming a compiled-out cloud parses in every build and fails at load with a purposefulNotCompilederror naming the cargo feature to rebuild with. mail_store’s key-source edge is feature-forwarded — its formerly unconditional dependency on all ofkey-source’s clouds now follows the same per-cloud features, so store-consuming binaries can strip clouds too.
2026-07-17 — v0.20.2 (compose smoke runs mail-grpc live)
PATCH — dev/CI tooling and documentation only (no on-chain ABI, instruction, error-code, or economic change; nothing a deployment configures moves):
docker/smoke.shnow boots thechainprofile live — it exportsCOMPOSE_PROFILES=chainfor every compose call it makes (teardown included), somail-grpcstarts with the rest of the compose dev stack and must pass the healthy-wait. No validator is required: the gateway’s readiness gates on its own gRPC listener, never on chain connectivity. The previous smoke only statically parsed the profile (docker compose --profile chain config -q), which let a stale broken image sit undetected on a dev host — the live boot caught exactly such an image on landing.- CI’s smoke job lifts the profile too
(
.github/workflows/docker-publish.yml): the job-levelCOMPOSE_PROFILESbuilds the mail-grpc image alongside the other five before the smoke runs, so the gRPC gateway is exercised live on every push and pull request, not merely compiled.
2026-07-17 — v0.20.1 (terminology sweep: “knob”)
PATCH — documentation only (no code change):
- Terminology sweep across the book, per style direction: the informal “knob” gives way to industry-standard terms — “switch” for boolean enable/disable entries, “setting”/“option” for tunable values, and “settings” for collections. Meaning is unchanged everywhere, including in earlier entries on this page, which keep their versions, dates, and facts.
2026-07-17 — v0.20.0 (smarthost implicit-TLS dial)
MINOR — an additive capability affecting deployments (no on-chain ABI, instruction, error-code, or economic change):
- New
[spooler.smarthost] implicit_tlsswitch — defaultfalse; when set, the relay dials the smarthost with TLS from the first byte (the port-465 “SMTPS” style, named after the inbound listeners’ switch) instead of the default in-band STARTTLS, so STARTTLS never happens on the wire. The port is not auto-switched to 465 — it stays whatever the operator set — the handshake keeps the smarthost path’s strict webpki verification, and the direct-to-MX path is unchanged. This is the first consumer of the SMTP client machine’s implicit-TLS dial mode (SendMachine::new_tls) — see the[spooler]reference.
2026-07-17 — v0.19.0 (truthful TLS-RPT rows; report retention setting)
MINOR — a default-behavior change and a new deployment setting (no on-chain ABI, instruction, error-code, or economic change):
- TLS-RPT success rows are now flag-truthful — a §4.1 success row is recorded only when the completed conversation actually ended on TLS (the send outcome carries the negotiated flag), so a success row can no longer describe a plaintext session; a completed plaintext opportunistic delivery — including a declined STARTTLS offer that continued in the clear — records no row at all (neither a TLS session nor a failed attempt). This closes v0.18.0’s recorded gap; the honest limit that remains (“never offered” vs “offered but declined” — both unrecorded) is in the rewritten RFC 8460 conformance section.
- Pre-dial policy exclusions now record TLS-RPT rows — hosts a policy
excludes before dialing land never-dialed failure rows instead of
vanishing (the other v0.18.0 gap): a DANE-unusable host records
dnssec-invalidwith a baretlsapolicy block, an MX target outside an enforce-mode MTA-STS policy recordssts-policy-invalidrendering the enforce policy body, with the planner’s diagnostic infailure-reason-code. Unreachable/timed-out hosts and STS testing-mode mismatches still record nothing — see the configuration reference. - New
[spooler] report_retention_dayssetting — default0= keep forever; when set, an hourly worker prunes ingested DMARC aggregate reports (dmarc_rua/) and pending TLS-RPT rows (tlsrpt/pending/) older than the window. Off by default deliberately:dmarc_rua/is the dataGET /v1/admin/dmarc-reportsserves, and retention removes reports from that surface — see the[spooler]table.
2026-07-17 — v0.18.2 (Outlook + Thunderbird gain the trustless reply/compose card)
PATCH — additive client UI over existing chain capability (no on-chain
ABI, instruction, error-code, or economic change; the compose threads
SendMail’s existing bounty/expiry/reply parameters through the already
parity-fenced builders — the same call as item 39’s webmail card, v0.8.8):
- Outlook: Trustless reply and compose —
the taskpane’s trustless reader gains webmail’s Reply on-chain
action, and the mail view mounts the floating on-chain compose card
(reply-chip threading, bounty SOL + claim-window-days fields, inline
stamp prepay). Replies are on-chain sends — never a host SMTP
compose. The connection-settings pane gains the IPFS pin service
URL/token (
config.ipfsPinUrl/config.ipfsPinToken, default the loopback sithbit-ipfsd, unauthenticated). - Thunderbird: Trustless reply and compose — the same seam on the extension’s dashboard; the options page surfaces the pin URL/token, and a non-loopback pin origin joins the Save-click host-permission grant automatically.
2026-07-17 — v0.18.1 (postmaster page cross-link)
PATCH — documentation only (no code change):
- Initializing the postoffice —
the “held by anyone else” recovery path’s mention of the holder closing
the alias (
sithbit alias close) now links to Closing accounts, matching the transfer link beside it.
2026-07-17 — v0.18.0 (TLS-RPT reporting; DANE for MX-less domains)
MINOR — an additive capability and a default-behavior change affecting deployments (no on-chain ABI change):
- DANE now covers MX-less domains — the outbound relay’s default-on
daneenforcement previously skipped domains with no MX record (the documented RFC 7672 §2.2.1 subset). A DNSSEC-proven MX denial (Secure proof on the negative answer’s SOA) now marks the implicit-A fallback secure, so TLSA at_25._tcp.<domain>is consulted and enforced for signed MX-less recipients — see the rewritten DANE conformance caveat and the dns.md publishing note. The narrower remaining subset: denials without a validatable SOA stay insecure. Unsigned zones behave exactly as before. - TLS-RPT (RFC 8460) sending — new default-off
[spooler.tlsrpt]switch: dialed relay attempts record per-host TLS results, and a drain worker folds them into per-domain aggregate reports delivered to recipients publishing_smtp._tls.<domain>rua targets — over bothmailto:(DKIM-signed via the outbound relay) andhttps:(application/tlsrpt+gzipPOST). The new RFC 8460 conformance section carries the honest gaps (certificate-* taxonomy collapses into validation-failure with detail preserved; pre-dial exclusions record nothing; duplicate-on-crash tolerated via deterministic report ids). This closes the “no TLS-RPT” limitation recorded by the MTA-STS and DANE landings.
2026-07-17 — v0.17.0 (DMARC aggregate-report ingestion; postmaster alias claim)
MINOR — additive capabilities affecting deployments (all off by default or init-time only; no on-chain ABI change):
- DMARC RUA ingestion — SithBit deployments can now receive the
aggregate reports other operators send about their domains. Three pieces,
documented across dns.md, the
configuration reference, and a new
RFC 7489 §7.2 conformance section:
[smtp] postmaster_wallet(RFC 5321 §4.5.1 — bare/domained postmaster bypasses alias resolution and the frombox/postage gate so external reporters can deliver at all; unset keeps refusals byte-identical),[spooler.dmarc_rua_ingest](matched delivered recipients get their reports parsed with the vendored RFC 7489 parser and stored as JSON under the fixeddmarc_rua/blob prefix — additive to delivery, never a diversion), and the account API’sGET /v1/admin/dmarc-reports[/{id}]admin reader. Scope is deliberately ingest + store + surface only: auto-disabling accounts from RUA data is rejected on record (aggregate rows carry no join key to a local wallet; failing rows are almost always third-party spoofers). Norufingestion, no blob pruning yet. postmaster initclaims thepostmasteralias — the init transaction now atomically registers the globalpostmasteralias to the delegate, fee-waived by construction (the alias program’s delegate waiver reads the postoffice state written one instruction earlier; rent-only). A squatted name refuses loudly with nothing submitted — the postoffice is deliberately never created without its name; the alias program still has no reserved words, so the squat window is narrowed to deploy→init, not closed.
2026-07-17 — v0.16.1 (config-key docs gate; example-config drift fixes)
PATCH — documentation/tooling only (no behavioral change):
- New docs-gate leg —
mail_docs/check_config_keys.pydiffs the six canonical example configs’ keys (commented-out entries included) against the configuration reference in both directions, killing the drift class where a key ships in an example TOML but never reaches the docs (or vice versa). - Drift repaired by the new gate —
sithbitd.example.tomlgained the documented-but-missing[store.cloudflare]backend block,client_cert_authon the three authenticated listeners,[smtp.server]limits.*, and the[swarm]service-record freshness settings (the last also added tosithbit_ipfsd.example.toml); both IPFS binaries’ examples gainedobservability.otlp.metrics_interval_seconds; the reference gainedstore.aws.sqs_wait_time_secondsand the gateway’spublic_host, and the[store.blobs]s3/azure key lists are now machine-checkable code spans. - Example-value fix —
domain_sithbit.example.toml’s[mail_hosts.smtp]showed the retired 587/STARTTLS pair while claiming to show defaults; the in-code default (and the documented RFC 8314 posture) is 465/SSL. - RefundBounty CU fence — the expiry-gated sender reclaim was the one bounty-family instruction never CU-measured; the compute-units table gains its row (7,579 max measured, 31,000 ceiling — the cheapest fenced instruction: no reply-linkage check and no operator share on the refund path).
2026-07-17 — v0.16.0 (chain-account proxy read; accepting_at on the DND check)
MINOR — additive public-API changes (a new route and a new response field; no on-chain ABI or economic-model change):
- New authenticated chain read —
GET /v1/chain/account/{address}on the account API returns any raw account verbatim (ownerbase58,database64; 404 when absent), the generic escape hatch API-mode web shells use to decode accounts client-side — e.g. the bounty-claim resolver’s domain-account read, so a domained claim pays the domain authority instead of falling back to the filler pair. accepting_aton the anonymous DND check —GET /v1/dnd/{wallet}now carries the RFC 3339 UTC instant the wallet accepts mail again, gated three ways: currently excluded, owner opted in viaexpose_dnd_schedule, and the schedule ever reopens (a full-week recurring schedule omits it). The schedule page localizes it to the sender’s own clock (“accepting mail again at …”); the not-opted-in default stays yes/no only.
2026-07-17 — v0.15.2 (docs: introduction reordered around the no-token pitch)
Documentation-only: the introduction’s “Priced in SOL — No New Token to Trust” subtopic moved up to lead the protocol sections (ahead of “Decentralized Email”), and the former “Only on Solana” subtopic became a note box directly beneath it.
2026-07-17 — v0.15.2 (docs: privacy cross-links for the DND-exposure opt-in)
Documentation-only: What you control and the
privacy reference now name the
expose_dnd_schedule opt-in — anonymous schedule checks return only the
yes/no “away right now” answer unless the owner opts in — linking to
What the refused sender sees
for the semantics. The deploy guide’s GCP mail-tier
bullet now points at the PROXY-protocol container recipe as the exception
to “VMs, not serverless”.
2026-07-17 — v0.15.2 (ChainSender: pin service lazily required by send)
PATCH — web-client behavior change, no public-ABI change: ChainSender
no longer demands an IPFS pin URL at construction — only send() pins, so
the pin service is lazily required at pin time (a send without one refuses
at the pin stage before anything is pinned or submitted; prepay() never
pins). The onboarding fund page drops its dummy-pinUrl workaround.
2026-07-17 — v0.15.1 (mail-migrate replays the DND-exposure opt-in)
PATCH — behavioral defect fix, no public-ABI change: sithbit-migrate
now replays each account’s expose_dnd_schedule opt-in onto the target
store; previously a migration silently reset the flag to hidden
(fail-safe, but lossy for owners who had opted in). The
migration guide’s field list updated to match.
2026-07-17 — v0.15.0 (docs: diagrams catch up to the three-leg bounty split)
Documentation-only (the settlement change itself shipped at v0.14.0): the reply-bounty flow and campaign lifecycle diagrams — titles, box labels, and the campaigns page’s alt text and “paid twice” bullet — still described the retired 90/10 recipient-to-postoffice split. All now state the three legs: 90% to the claimant, 10% to the claimant’s active domain authority, with the postoffice collecting the share only when no active domain resolves.
2026-07-17 — v0.15.0 (self-service refusal links; DND schedule privacy)
MINOR — new default-off capability plus a public-API default-behavior
change (no on-chain ABI or economic-model change; the widened-at-v0.10.0
rule): a single new [smtp]/[submission] setting, self_service_base_url
(default unset = every refusal stays byte-identical to the legacy text),
makes the RCPT-time refusals link self-service pages — the postage
refusals (450 4.7.0 out of stamps, 550 5.7.0 no frombox) append
{base}/fund.html?to=…&from=…, and sithbitd’s do-not-disturb refusal
(450 4.2.1) appends {base}/dnd.html?to=… (the standalone
smtp-server carries only the funding link — it has no DND gate). The
two pages ship in the onboarding web bundle: fund.html quotes the
stamp price trustlessly off the chain (postage + settlement surcharge +
live protocol fee) and takes a Phantom/Ledger prepay; dnd.html shows
the recipient’s away state. Alongside them, the anonymous
GET /v1/dnd/{wallet} route’s default behavior changed —
privacy-tightening: it used to return the full exclusion list to any
caller, and now always answers excluded_now but includes the
exclusions array only when the owner opted in via the new
expose_dnd_schedule account flag (PATCH /v1/account, default
false; a wallet with no account answers excluded_now: false).
- New Do not disturb page: the case for reject-at-RCPT DND over accept-and-autoreply (the sender’s MTA queues and retries; no unread pile-up; the sender learns at send time; a refusal burns no stamp), what a refused sender finds on each linked page, and the schedule-sharing opt-in.
- Configuration reference:
the
self_service_base_urlrow in the[smtp]/[submission]table and a new one-setting-two-pages subsection, including the standalone-smtp-serverscope note. - account-api: the DND schedule surface —
the authenticated
GET/PUT /v1/account/dndroutes, theexpose_dnd_scheduleflag onGET/PATCH /v1/account, and the changed publicGET /v1/dnd/{wallet}contract, called out as a behavior change. - Self-service pages for refused
senders: the
two pages in the onboarding bundle and their
<meta>endpoint settings (sithbit-rpc-url/sithbit-api-url, localStorage fallbacks, same-origin default under the account API’s[static]root).
2026-07-16 — v0.14.0 (ClaimBounty operator share to the domain authority)
MINOR — economic-model change (no wire-ABI change: the instruction’s
discriminant and payload are untouched): ClaimBounty’s 10% operator
share (OPERATOR_SHARE_BPS) now pays the claimant’s domain authority
when their mailbox names an active domain — the DeleteMail settlement’s
domain-resolution rules, filler-account guards included — and falls to
the postoffice when the chain legitimately doesn’t resolve (no mailbox,
no domain named, domain closed or inactive; the prior behavior, preserved
for domainless claimants). The claimant keeps the remainder including the
rounding dust, exactly as before, and RefundBounty is unchanged. The
instruction grew from 4 to 7 accounts (mailbox, domain, and authority
appended; the mailbox PDA stands in as a filler when no domain is named).
Re-measured: 24 847 CU (was 21 624), fenced at 48 000 (was 45 000).
- Reply bounties: the claim-split
bullet is rewritten for the three destinations — claimant, domain
authority when active, postoffice fallback — and ties the operator leg
to the
DeleteMailsettlement’s resolution rules. - Claiming the bounty: the user-facing payout callout now names the domain authority as the operator leg’s destination, with the postoffice fallback.
- Compute-unit consumption: the ClaimBounty row carries the new measurement and ceiling, noting it is measured on the domained 7-account path.
2026-07-16 — v0.13.0 (domain-sithbit mail_hosts table correction)
Documentation defect fix (no version bump — docs-only): the
domain-sithbit [mail_hosts]
table still claimed
the advertised SMTP default was 587/STARTTLS; the code default has
been 465/SSL since the RFC 8314 cutover (v0.9.0, fenced in
domain_sithbit/src/config.rs tests), and configuration.md already said
so. Same drift class as the v0.11.6 sithbitd port corrections.
2026-07-16 — v0.13.0 (MTA-STS policy publication)
MINOR — new default-off capability (no on-chain ABI or economic-model
change; the widened-at-v0.10.0 significant-additive-capability rule):
domain-sithbit now publishes a domain’s MTA-STS policy (RFC 8461) at
GET /.well-known/mta-sts.txt, rendered from a new optional [mta_sts]
config section (mode default "testing", mx patterns, max_age
default one week). The section is validated fail-fast at boot — an
unknown mode, an enforce/testing policy without an mx pattern, or a
max_age above the RFC’s one-year ceiling (31557600, fenced equal to
the value the sending relay clamps fetched policies to) refuses to start —
and with no section the endpoint replies 404, so existing deployments are
untouched. TLS stays the fronting proxy’s job (a certificate for
mta-sts.<domain>), and the _mta-sts.<domain> discovery TXT record
stays operator-managed DNS.
- New domain-sithbit: publishing the MTA-STS
policy
subsection: the route, the opt-in 404 contract, the startup failure
modes, and the
mta-sts.<domain>A/CNAME + TLS-proxy fronting. - New
[mta_sts]— MTA-STS policy publication reference section: the key/default table and the boot-time failure modes. - RFC 8461 conformance section: the “sending side only” scope bullet is replaced by a publish-side bullet — what is implemented (§3.2 serializer, well-known route, startup validation, the shared one-year ceiling) and what is not (no per-domain policy map, no TLSRPT, TXT record stays operator DNS).
- Sending mail: SPF, DKIM, DMARC,
PTR: the MTA-STS
inbound guidance now points at the built-in endpoint instead of “host
the policy file yourself”, and ties the TXT
idbump to editing the[mta_sts]section.
2026-07-16 — v0.12.0 (DANE outbound enforcement)
MINOR — additive behavioral change (no on-chain ABI or economic-model
change; the widened-at-v0.10.0 new-enforcement-default rule): the outbound
relay now looks up and enforces recipient MX hosts’ DNSSEC-validated DANE
TLSA records (RFC 7672) by default on direct-to-MX delivery — a validated
usable TLSA set pins the STARTTLS handshake to the published certificate
data (preferred over MTA-STS wherever both apply), and any failure defers
rather than downgrading — behind a new [spooler] dane switch (default on).
Send-side only: publishing TLSA records stays operator DNS work. Only
tightens delivery to domains that sign their zones and publish TLSA;
everything else keeps the MTA-STS/opportunistic posture.
- New RFC 7672 conformance section: TLSA discovery over a DNSSEC-validating resolver and the per-host outcome matrix, DANE-EE/DANE-TA verifier semantics (EE skips name/expiry/chain; TA path-validates anchored at the matched chain cert), the DANE-over-MTA-STS composition rules, the documented CNAME and implicit-A subsets, and the send-side-only scope.
[spooler]— outbound workers: thedanekey and a paragraph on the pinned handshake, the stricter-of-both rule under an MTA-STS enforce policy, never-dialed bogus hosts, and the debugging escape hatch.- Sending mail: SPF, DKIM, DMARC,
PTR: a DANE note —
outbound needs no configuration; publishing
_25._tcp.<mx-host>TLSA records in a DNSSEC-signed zone protects your own inbound mail, with the recommended3 1 1form, the openssl digest recipe, and key-rollover guidance. - Threat-model subsection updated: DANE is now implemented and closes both MTA-STS residuals (trust-on-first-use and cache lifetime) for recipient domains that deploy DNSSEC + TLSA.
2026-07-16 — v0.11.6 (sithbitd default-port corrections)
PATCH — documentation/example corrections only (no code change;
the binds themselves never moved): the sithbitd docs and example
config claimed default ports the code never had. The in-code defaults
are SMTP 2525, IMAP 1430, POP 1100 (imap_server/src/config.rs,
pop_server/src/config.rs), and the submission listener has no
distinct default — disabled by default, it would inherit SMTP’s
2525, so its bind_addr must be set explicitly when enabled. The
2143/2110/2587 numbers are the docker-compose files’ explicit rebind
convention, not defaults. Corrected in the sithbitd
page, the listener-section
table,
the deploy quick-start, and
mail_spooler/sithbitd.example.toml (whose [store] prose also now
lists the cloudflare kind alongside the other backends).
2026-07-16 — v0.11.5 (production import documents for the cloud config stores)
PATCH — deployment content and repo-side tooling only (nothing a
deployed binary does changes: the cloud config tier itself shipped at
v0.11.0, and importing these documents is an operator opt-in): the
repository now ships ready-to-import production configuration for a
complete six-service sithbit.com deployment — sithbitd, account-api,
mail-grpc, domain-sithbit, sithbit-ipfsd, sithbit-gateway — for both
cloud config stores, under iac/appconfig/.
- AWS AppConfig: six commented per-service TOML documents
(
iac/appconfig/aws/), imported verbatim as freeform hosted configuration profiles — AWS stores the document opaquely, so the TOML comments are the in-store field documentation. These documents are the single source of truth for both clouds. - Azure App Configuration: a generated kvset import file
(
iac/appconfig/azure/sithbit.kvset.json) — per-service-prefixed:-separated keys on the NULL label, each TOML comment carried as the key’sdescriptiontag (the kvset profile is the only import path that preserves per-key metadata). Sparse per-service overrides (iac/appconfig/azure/overrides/) swap the store kind, blob shape, and key sources to their Azure forms; theappconfig-genworkspace tool merges and emits, and the test suite fails on a stale kvset, a document that no longer parses into its service’s real config struct, or a broken cross-service invariant. - Secrets stay out of the store by construction: the documents carry
key-source coordinates (Secrets Manager / Key Vault) and obvious
CHANGEdummies, machine-enforced by pinned placeholder tests. - The cloud app-config
section
points at the import documents (and now lists all six bootstrap
prefixes — the two IPFS binaries were missing); the
Production deployment bullet gains
the same pointer; the store-creation and import runbooks live in
iac/README.md.
2026-07-16 — v0.11.4 (store-name refresh on the extension pages)
PATCH — documentation only (no code change): the store-install sections now link the stores themselves, and Microsoft’s rebrand is reflected.
- Chrome: Installing from the store — “Chrome Web Store” now links to the store.
- Outlook: Installing from the store — Microsoft AppSource has been renamed Microsoft Marketplace; the section says so (linking the store) and uses the new name throughout. The v0.11.2 entry below keeps its historical “AppSource” wording. The in-client Apps → Get Add-ins flow is Outlook UI and is unchanged.
- External links open in a new window via the site-wide
external-links.jshook, as usual — no per-link markup.
2026-07-16 — v0.11.3 (PROXY protocol trusted-proxies allowlist)
PATCH — additive hardening setting, default off-path (the defaults
preserve prior behavior exactly; no on-chain ABI or economic-model
change): listeners running with proxy_protocol = true can now
restrict which socket peers are permitted to speak the preamble,
instead of trusting whoever reaches the port.
- New
proxy_trustedkey in the shared[*.server]section: a CIDR allowlist (e.g.["10.0.0.0/8", "2001:db8::/32"]; bare addresses count as /32 or /128, and v4 entries match v4-mapped peers on dual-stack listeners) of the peers allowed to send a PROXY preamble. Untrusted peers are refused before a single header byte is read, closing the address-spoofing hole a directly reachable client would otherwise have. Empty (the default) trusts any peer — the pre-allowlist behavior, suitable when only the balancer can reach the port. Ignored unlessproxy_protocolis on; a malformed entry fails serve startup with an error naming it. - The four annotated example configs (
sithbitd.example.toml,smtp_server.toml,imap_server.toml,pop_server.toml) show the default (proxy_trusted = []) commented out besideproxy_protocol, house style.
2026-07-16 — v0.11.2 (extension store-install instructions)
PATCH — documentation only (no code change; the extensions are not
yet published to any store): the three extension-client pages each gain
an Installing from the store section ahead of the build-and-sideload
path, with obviously-placeholder listing tokens
(_todo_store_listing_name_ / _todo_store_listing_url_) that resolve
when the real store listings go live, plus an honest note on what each
store offers for pre-release distribution.
- Thunderbird: Installing from the
store —
addons.thunderbird.net search/listing install; MailExtensions need no
signing, so the self-distributed
.xpistays a fully supported permanent channel (ATN listings are public-only). - Chrome: Installing from the store — Chrome Web Store “Add to Chrome”; the Web Store’s Unlisted/Private visibilities and trusted-tester draft sharing allow a non-public pre-GA listing.
- Outlook: Installing from the store — AppSource / in-client Get Add-ins search; AppSource is public-only, so the private paths are sideloading and the Microsoft 365 admin center’s Upload custom app tenant-wide deployment.
- The GUI clients overview points at the three new sections with the placeholder caveat.
2026-07-16 — v0.11.1 (commodity hosting: generic VM + Postgres + S3)
PATCH — documentation only (no code change; the recipe rides existing backends): the vendor-independence claim gets its commodity chapter — any provider with a VM, Postgres, and S3-compatible object storage runs the full stack — plus a ranked six-provider comparison.
- New Hosting on a generic VM: Postgres + any S3-compatible
storage
section: the two-edit recipe (
[store] kind = "postgres"+[store.blobs] kind = "s3", with[ipfs.blobs]riding the same trait), the four operational caveats a big cloud would otherwise absorb (build features, certbot with a restart--deploy-hookfor the load-once TLS acceptor, file-based key sources, outbound port 25), and a six-provider ordered list — Hetzner, OVHcloud, Scaleway, Linode, Vultr, DigitalOcean — ranked on port-25 posture and PTR/rDNS control first, managed-Postgres availability second. - Containers behind a PROXY-protocol balancer are documented as
viable for the mail tier (decision 2026-07-16), superseding the
older VM-only guidance: the listeners’ existing
proxy_protocol = truesupport recovers the real client IP for DNSBL/limits/SPF, so LB-fronted containers qualify when the balancer injects the preamble; proxies without it stay ruled out. (iac/README.md’s client-IP constraint note was revised to match — outside this book.)
2026-07-16 — v0.11.0 (cloud app-config sources)
MINOR — additive capability (no on-chain ABI or economic-model
change): every TOML-config binary can now pull its settings from AWS
AppConfig or Azure App Configuration — a new resolution tier
directly above the config file, opted into per binary by a single
bootstrap env var ({PREFIX}_AWSAPPCONFIG / {PREFIX}_AZAPPCONFIG) and
skipped entirely when neither is set, so the zero-config contract is
untouched. Settings only, never secrets — key material keeps going
through key sources.
- New Cloud app-config sources: AWS AppConfig or Azure App
Configuration
section: the bootstrap variables, the per-provider payload idiom
(AWS: one whole TOML document, deep-merged; Azure: per-key values
nested on
:, case-sensitive), the neither/both/compiled-out rules (awsconf/azconffeatures), ambient authentication, and the env-gated live probes. - How a setting
resolves: the
chain gains the cloud tier between the TOML file and
./.env—{PREFIX}_{PATH}env overrides still win over cloud values. - Container images and Production: config can arrive from a cloud app-config source instead of a mounted TOML file; the mail-grpc keypair callout notes only key-source coordinates travel through it.
- The three annotated example files (
sithbitd.example.toml,mail_grpc.example.toml,domain_sithbit.example.toml) spell the cloud tier into their layer-chain headers; the loader’s crate-level reference isapp_config/README.md(outside this book — the configuration section is the operator-facing description).
2026-07-16 — v0.10.0 (MTA-STS outbound enforcement)
MINOR — additive behavioral change (no on-chain ABI or economic-model
change): the outbound relay now discovers and enforces recipient domains’
published MTA-STS policies (RFC 8461) by default on direct-to-MX delivery —
an enforce-mode policy means verified TLS to a policy-matching MX or a
deferral, never a plaintext fallback — behind a new [spooler] mta_sts
switch (default on). Send-side only: SithBit publishes no policy endpoint of
its own.
- New RFC 8461 conformance section: policy discovery/parsing, the enforce branch and its defer semantics, the §5.1 policy cache, testing-mode logging (no TLS-RPT), and the send-side-only scope.
[spooler]— outbound workers: themta_stskey and a paragraph on the three policy modes, the defer semantics, and the debugging escape hatch.- Outbound mail and port 25: direct-to-MX delivery honors recipient policies by default; smarthost deployments are unaffected.
- Sending mail: SPF, DKIM, DMARC,
PTR: an MTA-STS note —
outbound needs no configuration; publishing the
_mta-stsTXT record and policy file protects your own inbound mail. - New threat-model subsection: the STARTTLS-downgrade threat on MX-to-MX delivery, what opportunistic TLS does not protect against, and the trust-on-first-use / cache-lifetime residuals.
2026-07-16 — v0.9.3 (hosting on Google Cloud)
PATCH — no ABI or economic-model change (docs + infrastructure templates only; zero application code). SithBit’s third hosting cloud, proving the vendor-independence seams end to end:
- New Hosting on Google Cloud
section in the deployment chapter: blobs = a GCS bucket over its
S3-interop endpoint (HMAC credentials,
region = "auto"— the existings3blob kind, no new backend); tables/leases/queue =kind = "postgres"against Cloud SQL; secrets = the v0.9.3gsmkey source; outbound port 25 is unconditionally blocked on GCE (unlike AWS/Azure, no lift on request) so the smarthost is the supported outbound shape (inbound MX unaffected); the mail-port tier belongs on a GCE managed instance group behind an external passthrough Network LB (source-IP preservation — the GCP analog of the VMSS-not-ACI rule), while the private mail-grpc gateway fits Cloud Run. - New
iac/gcpTerraform module: the GCS bucket + dedicated service account + HMAC key always; an optional customer-managed Cloud KMS key; opt-in Cloud SQL Postgres; an opt-in internal-only Cloud Run v2 mail-grpc unit with thegsmkeypair selector.iac/awsgains the matchingmail_grpc_keypair_asmselector (task-roleGetSecretValue) as the volume-free alternative to the EFS mount. - Scaling out, the glossary (new GCS entry), migration, monitoring, and the production compose example now name the GCS/Cloud-SQL shapes where they list backends.
2026-07-16 — v0.9.3 (multi-cloud secret managers)
PATCH — no ABI or economic-model change (a config/deployment
capability; every existing config parses byte-identically). Key
sources
now fetch from AWS Secrets Manager (kind = "asm") and Google
Secret Manager (kind = "gsm") alongside the existing Azure Key Vault
(kind = "akv") and local files, everywhere a key source is accepted —
JWT/DKIM/credential-sealing keys, every server’s TLS pair, the
domain-sithbit delegate key, and mail-grpc’s signing keypair:
- Configuration reference rewritten: the key-sources section (heading
renamed — old deep links to
#key-sources-files-or-azure-key-vaultnow target the cloud-secret-managers anchor) documents all three clouds’ TOML shapes, auth chains (managed identity / AWS credential chain / ADC), and the live-probe env vars; per-field tables and the account-api, domain-sithbit, and mail-grpc pages generalize their file-or-Key-Vault phrasing. - Per-cloud cargo features (
akv/asm/gsm, all default-on): the build-features caveat in Deploy now describes the real mechanism — compile out the clouds you don’t use; a compiled-out kind still parses and fails at load naming the feature. - Cloudflare deliberately absent: its secrets products are write-only over the API (no fetch path), noted in Deploy and the configuration reference.
- Every example TOML’s commented
akvline gains an(or kind = "asm" / "gsm")pointer.
2026-07-16 — v0.9.2 (the Marketplace topic & campaigns)
PATCH — documentation only, no ABI or economic-model change (the participant beacon, campaign CLI, and their money flows all shipped in v0.9.0; this surfaces them in the user-facing guide). A new top-level Marketplace topic under “Using SithBit” introduces the three things that trade on SithBit — alias names, domains, and attention — with two subtopics: Trading names (the alias/domain resale market, linking the existing per-name how-tos) and Campaigns, an audience-facing introduction to opt-in inbox monetization written for both advertisers and non-technical participants, with a hand-drawn campaign-lifecycle diagram. Supporting changes:
- Campaigns highlighted as a headline feature. A new campaign feature
card on the Welcome page and a new
campaignterm icon (megaphone) across the icon system (legend). - Economics gains a Campaigns section tracing the campaign-wallet-funded per-recipient flow (rent + postage + bounty + fees) as a batch of existing flows, and Economics moved ahead of “GUI clients” in the reading order.
- The marketplace pane doc now documents its Participants tab, reconciling a gap with the shipped web surface.
- Running a mail server opens with a vendor-independence pitch (the storage, blob, IPFS, and secret seams are trait-abstracted with multiple backends), and its subtopics are reorganized into a motivated arc — Go-live essentials, The services, Day-2 operations.
- Welcome page card grid rebalanced. The feature cards no longer strand “Works with the inbox you already use” alone on its own row: the spam-pricing hero card keeps its full-width row, and the remaining six cards now flow three across in two even rows (“No gatekeeper” keeps its accent border but joins the grid).
2026-07-16 — v0.9.1 (mail-grpc honors JSON_RPC_URL)
PATCH — config surface, no ABI or economic change. The mail-grpc
gateway now honors a bare JSON_RPC_URL environment variable
(precedence: env > the configured json_rpc_url > the Solana CLI
config), the one legacy env name kept from the clean break, for parity
with the sithbit CLI and the standard Solana convention. See
the mail-grpc chapter migration note and the
json_rpc_url row in the
configuration reference. This also fixed an integration-suite singleton
race (the in-process gateway’s config::get() no longer force-initializes
the process-wide config, so a read-only gateway boot can’t pre-empt the
binary’s one config::install).
2026-07-16 — v0.9.0 (the sithbit campaign CLI)
MINOR — additive CLI surface over the existing beacon ABI (item 44, the
group-offer authoring flow
of the participant-pool marketplace). No new on-chain instruction: the
command tree drives the create/update/close beacon instructions that
shipped with item 43 and the existing SendMail, so nothing about the ABI
moves. What is new is the chain-direct authoring surface a participant and a
campaign wallet use:
- A new
sithbit campaignCLI reference documents the whole tree — a participant’screate/update/close(opt in, rewrite, opt out; the advertised price is the mailboxdefault_postage, so a beacon needs a mailbox first) and a campaign wallet’ssearch/quote/send.searchruns the trustlessgetProgramAccountsscan and recovers each match’s sendable wallet from the beacon’s on-chainownerfield (the D-P1 append this session);quoteprices the matched set term-for-term against the on-chainSendMailfunding math;sendexecutes N direct-signed bountied sends, continuing past a per-recipient failure so one bad address can’t strand a paid campaign. - The participant-marketplace note
and its item-44 line
are the design record; each
campaignbounty rides the ordinary reply-bounty escrow, refundable to the campaign wallet if a recipient never replies.
2026-07-16 — v0.9.0 (participant-pool web surface)
MINOR — additive web/API surface (item 45, the
browser read path of the
participant-pool marketplace
design). The browser read path adds no on-chain ABI itself; the beacon’s
on-chain layout shipped with item 43 under this same v0.9.0 and was
extended this session with an appended 32-byte owner field
(PARTICIPANT_BEACON_LEN 113→145; tags stay first at offset 0) so the
trustless scan recovers each participant’s sendable wallet from the PDA.
The opted-in participant pool is now browsable end to end:
a new ListParticipants gRPC RPC on mail-grpc runs the trustless
on-chain beacon scan (getProgramAccounts + a memcmp filter over the
fixed-offset tag bitmap), account-api proxies it as the authenticated
GET /v1/chain/participants?tags=… index route
(account-api), and the
name marketplace grows a Participants tab
beside its For sale / Expired / Sold tabs — a lazy-loaded, JWT-authenticated
browse-and-filter surface over the pool. A beacon must carry every filtered
tag bit to match. Known limitation: the browser clients have no shared
tag-name vocabulary yet, so both the filter input and each row render tags
as raw on-chain bit positions (the mail_model TAG_* constants),
not human labels; group-offer authoring (quote/send) stays CLI-first
(item 44).
2026-07-16 — v0.9.0 (RFC 8314: production implicit-TLS mail posture)
PATCH — docs and config defaults only; no on-chain ABI, instruction, error-code, or economic change (item 47). The reference stack’s transport posture is hardened to RFC 8314 (“Cleartext Considered Obsolete”) — TLS on connect for submission and access, credentials refused before the connection is protected — and documented end to end. Nothing about what the servers can be configured to do changed at the wire level (STARTTLS listeners and the plaintext loopback dev stack still work); what moved is the advertised production default and the prose describing it.
- Protocol conformance
gains an RFC 8314 subsection and checklist item citing the three
on-by-default
require_tlsenforcement guards — SMTP submission (530withAUTHhidden from EHLO), IMAP (LOGINDISABLED+NO [PRIVACYREQUIRED]), and POP3 (-ERR Must issue STLS command first). mail_spooler/sithbitd.example.tomlgrows the commented production implicit-TLS stack —[submission.server]/[imap.server]/[pop.server]on 465/993/995 withimplicit_tls = trueand matching[*.tls]— with the STARTTLS 587/143/110 listeners demoted to opt-in secondaries, anddocker-compose.prod.example.ymlpublishes those same 465/993/995 (plus 25 MX) as the primary host ports.- Running a mail server: Production and a
new configuration production-posture
subsection
document the implicit-TLS primaries, the MX-on-25 exception, and the
require_tls-on-by-default rule. domain-sithbit’s advertised submission default flips 587/STARTTLS → 465/implicit-TLS (SSL) acrossMailHostsConfig::default, the Mozilla autoconfig (socketType=SSL) and Outlook autodiscover (Encryption=SSL) documents it serves, the[mail_hosts]reference default, and the DNS setup submission caveat. STARTTLS on 587 remains a supported opt-in; the ports now match the implicit-TLSSRVrecords that same page recommends.
2026-07-16 — v0.9.0 (threat-model prune)
PATCH — docs only. Removed the threat model subsection “History before the cutover shows the old postmaster key” — it advised rotating away from a pre-cutover single-postmaster key at adoption, but there has never been a production deployment, so no such historical key or pre-adoption chain history exists to rotate away from. The custody model itself (ceremony seeds + rotate-on-schedule delegate) is unchanged.
2026-07-16 — v0.9.0 (client-access SRV records)
PATCH — docs only. DNS setup
gains a section on RFC 6186 / RFC 8314 SRV records for client
autoconfiguration: the implicit-TLS labels SithBit’s production posture
prefers (_imaps 993, _pop3s 995, _submissions 465) and the STARTTLS
secondaries (_imap 143, _pop3 110, _submission 587), the RFC 2782
priority/weight/port/target fields, the .-target convention for
disabling a protocol, and how these relate to the autoconfig/autodiscover
documents and to the DHT-based service
discovery that avoids DNS altogether.
Ports match the
[mail_hosts]
defaults.
2026-07-16 — v0.9.0 (on-chain participant beacon)
MINOR — additive public ABI (item 43, the first build-out of the
participant-pool marketplace
design): three MailInstruction variants appended —
CreateParticipantBeacon (39), UpdateParticipantBeacon (40),
CloseParticipantBeacon (41) — plus the ParticipantBeacon account
(fixed 113-byte layout; the coarse tag bitmap sits at account offset 0 so
getProgramAccounts memcmp search works without deserializing), the
append-only 24-tag starter vocabulary in mail_model constants (32-byte
bitmap = 256 slots; bits are deliberately NOT validated on-chain, so
vocabulary appends never need a redeploy), the participant_beacon PDA
seed, and error codes 86–90. Creating a beacon requires the wallet’s
mailbox to exist, because the advertised participation price is the
mailbox’s default_postage
(decision 5);
closing it refunds the rent — opting out is free and complete. See the
program reference
for the instruction/seed/error tables. CLI authoring (sithbit campaign,
item 44) and the web surface (item 45) build on this next.
2026-07-16 — v0.8.11 (trustless webmail: external-wallet flavor on the Balances pane)
Client-side only: no on-chain ABI, instruction, error-code, or economic
change — balancesPane.buyStamps() now supports the external
Phantom/Ledger signing flavor (the unsigned wasm builders plus
sendUnsigned), mirroring mailboxConfigPane.commit()’s existing
split. Item 40 (v0.8.9) deliberately scoped this to the compose card
only; this fills in the Balances pane, so PATCH per the v0.8.7
precedent.
- No frombox yet? Prepay inline notes the Balances pane’s stamp purchases now ride both signing flavors too, matching the compose card’s inline prepay.
- Creating the frombox on first purchase cross-references the Balances pane alongside the compose card as riding the same create-or-top-up decision, now in both flavors.
2026-07-16 — v0.8.10 (screenshot manifest: curated shared-file lists replace wholesale hash)
Tooling-only: no on-chain ABI, instruction, error-code, or economic
change — a fix to check_screenshots.py’s own drift detection, so
this is a PATCH bump.
mail_docs/screenshots.manifest.jsonno longer hasheswebclients/sharedwholesale for every client. Each ofwebmail,onboarding, andmarketplacepreviously carried a blanketwebclients/sharedsource entry, so editing a shared file only one client actually reaches (e.g.connection-settings.js, webmail-only) falsely flagged the other two as needing a re-shoot — the same false-positive class the item-40 docs commit (ee30da5) had to re-pin around after an unrelatedwebclients/sharededit landed. Each client’ssourceslist now names only the shared files itsapp.js/index.htmlactually reach (traced via the import/fetch/ mount closure), while the client’s own directory stays wholesale (rglob’d) as before.check_screenshots.py’siter_source_fileshashes an individual file directly when asourcesentry names one rather than a directory, alongside the unchanged wholesale rglob. A new--selftestmode fixture-proves both directions: a one-client-only file edit leaves the other two clients untouched, and a shared-by-all file edit (e.g.panes.js) still flags all three.
2026-07-16 — v0.8.9 (docs: participant-pool marketplace design note)
Documentation-only: the version tags the unchanged protocol state.
- New design note:
The participant-pool marketplace
records the settled design for advertiser/survey campaigns over reply
bounties — the on-chain participant beacon (coarse tag bitmap + sealed
detail CID, coarse-by-construction privacy), mail-native key handout
for the detail blob, the two search paths over one fixed-offset
layout, the CLI-first
sithbit campaignauthoring surface, the advertised-price-is-default_postageidentity, the rejected alternatives, and the three implementation items it spawns. Nothing in it is implemented yet.
2026-07-16 — v0.8.9 (trustless webmail: inline prepay on the compose card)
Client-side only: the trustless compose’s no-frombox refusal becomes an
inline Prepay & send — quote, purchase, and automatic re-send of the
held draft, in both signing flavors. The new wasm exports
(create_frombox_unsigned/add_stamps_unsigned,
postoffice_account_address/decode_postoffice_account) are client
surface over unchanged instructions — no on-chain ABI, instruction,
error-code, or economic change (the prepayment economics shipped long
ago; this is an affordance over them) — so this is a PATCH bump per the
v0.8.7/v0.8.8 precedent.
- No frombox yet? Prepay inline documents the card — the stamp-count input (the ≥1-stamp non-owner floor), the exact per-stamp funding quote (postage + settlement surcharge + the postoffice’s live protocol fee, owner-waived), the fresh create-vs-top-up read at click time, and the deliberate no-auto-retry on a still-pending purchase.
- Creating the frombox on first purchase
cross-references the webmail surface riding the same
create-or-top-up decision as
frombox stamp.
2026-07-16 — v0.8.8 (trustless webmail: reply + bounty authoring)
Client-side only: the trustless webmail now authors what it could
already display — the reader gains a Reply on-chain action and the
compose card gains reply-bounty fields. No on-chain ABI, instruction,
error-code, or economic change (the compose threads SendMail’s
existing bounty/expiry/reply parameters through the already
parity-fenced builders, both signing flavors), so this is a PATCH bump
per the v0.8.7 precedent. This closes the v0.8.7 entry’s “future work”
note for this surface.
- Replying, and attaching a bounty
documents the new compose surface — Reply pre-fills the decrypted
sender and carries the parent message’s account address (the
blake3-hashed
--reply-tolinkage, no bounty required); the bounty fields mirror--bounty/--bounty-windowwith the same 7-day default window, and a born-expired window is refused in the page before anything is pinned — the same rule the chain enforces. - Attaching a bounty names the trustless compose as an authoring surface — and restates the standing decision that bounty authoring is direct-signed only: sends composed through a mail server stay bounty-less.
2026-07-15 — v0.8.8 (trustless webmail: send-lifecycle and pin-caveat diagrams)
Documentation-only: no ABI, instruction, error-code, or economic change — two hand-authored diagrams illustrating already-documented behavior.
- Sending without a server gains a lifecycle diagram covering the four client-side steps (resolve, check mailbox, check frombox, seal) through the pin step and the in-page-wallet/external-wallet signing branch to submit-and-poll.
- The pin lifecycle caveat gains a comparison diagram showing the operator’s server-delivery pin lifecycle and a trustless client’s pin lifecycle as independent paths converging on the same CID — why GC never reclaims a client-made pin on the operator’s behalf.
2026-07-15 — v0.8.7 (trustless webmail: external-wallet send)
Client-side only: the trustless webmail compose can now send through a
connected external wallet (Phantom/Ledger) — previously it required
an in-page wallet. No on-chain ABI, instruction, error-code, or
economic change (the external flavor emits the byte-identical SendMail
wire transaction through the already-parity-fenced unsigned builder), so
this is a PATCH bump per the kit-migration precedent.
- Sending without a server documents the two signing flavors — an unlocked in-page wallet signs in the page; a connected external wallet approves the same transaction built unsigned with it as fee payer, with sealing always happening in the page before anything leaves it. Bounty and reply fields remain future work on this compose surface (they exist in the CLI and the builders today).
2026-07-15 — v0.8.6 (domain-sithbit compose parity: mounted TOML, dead env var retired)
Deployment-surface only: the compose files move the last
env-configured service onto the mounted-TOML pattern — no on-chain
ABI, instruction, error-code, or economic change, and no server
behavior change (no binary is touched) — so per this file’s own rules
this is a PATCH bump, following the v0.8.4 compose-migration
precedent. It is also a correctness fix: the
production example still set
DOMAIN_SITHBIT_POSTMASTER_KEY_FILE, a config field removed by
v0.8.2’s hard break (the field is now delegate_key_file), and
because unknown DOMAIN_SITHBIT_* variables fail startup loudly,
copying the example verbatim crash-looped the domain-sithbit
container.
- The production example gains
sithbitd/account-api/mail-grpc parity —
domain-sithbitwas the last service configured through a wall of env vars:docker-compose.prod.example.ymlnow mounts a realdomain_sithbit.toml(viaDOMAIN_SITHBIT_CONFIG; start fromdomain_sithbit/domain_sithbit.example.toml) plus a separate read-only delegate-keypair mount the TOML’sdelegate_key_filenames, with a minimal-TOML sketch and the Azure Key Vault table-form alternative inline — mirroring the mail-grpc block v0.8.4 shipped. - The dead
DOMAIN_SITHBIT_POSTMASTER_KEY_FILEvar is retired from everything runnable: the production example’s crash-looping setting is gone, anddocker-compose.yml’s stale comment telling operators to set it now points at the current contract (delegate_key_filein the mounted TOML). The surrounding prose also stops calling it the “postmaster key” — the signer is the postoffice’s standing delegate key.
Deployment-surface only: the iac/ templates gain opt-in units that run
the unchanged published image — no on-chain ABI, instruction,
error-code, or economic change, and no server behavior change (no
binary is touched) — so per this file’s own rules this is a PATCH bump,
following the v0.8.4 deployment-surface precedent. It lands item 34,
the last of the three hardening items queued with the
gateway topology decision.
- Both IaC templates gain an opt-in
mail-grpcunit (deploy_mail_grpcin Terraform,deployMailGrpcin Bicep; default off — a plain apply/deploy keeps producing the store footprint with zero diff): ECS Fargate on AWS (security group + cluster/task/service), a VNet-integrated ACI container group on Azure. Both are BYO network (an existing VPC + private subnets, or an existing delegated subnet — the templates never create one) and private-only by construction: no public IP or load balancer, ingress on the gRPC/health ports only from caller CIDRs or the VPC’s own CIDR — the private-network posture as infrastructure rather than convention. See Provisioning with IaC andiac/README.mdfor the full parameter ↔ config mapping. - Keypair delivery splits per cloud, because the gateway’s
keypairis a key source (a file path or a Key Vault secret — never key content in an env var): AWS mounts an optional EFS volume read-only and pointsMAIL_GRPC_KEYPAIRat the file; Azure wires the AKV source through the nested env overlay (MAIL_GRPC_KEYPAIR__KIND=akv+__VAULT_URI/__SECRET_NAME) authenticated by the container group’s system-assigned managed identity — the operator grants that identity secret read on the vault (themailGrpcPrincipalIdoutput exists for exactly that role assignment). - Durable constraint recorded on the Azure unit: ACI cannot pass through the client source IP — acceptable for mail-grpc (private gRPC; callers are our own servers), but a future SMTP-server unit must be a VM scale set, because SPF/DNSBL need the real peer IP.
2026-07-15 — v0.8.4 (compose files onto the mail-grpc TOML/env config)
Deployment-surface only: the compose files, smoke script, and dev
scripts move onto the configuration surface v0.8.3 shipped — no
on-chain ABI, instruction, error-code, or economic change, and no
server behavior change (the binaries are untouched) — so per this
file’s own rules this is a PATCH bump. It lands item 35, queued by the
v0.8.3 hard break, and closes that break’s last loose end: nothing
runnable in the repo drives mail-grpc with the retired
GRPC_SERVER_ADDRESS/DEFAULT_KEYPAIR names any more.
- The dev
chainprofile is operative again:docker-compose.ymlwiresmail-grpcthroughMAIL_GRPC_*overrides (bind, RPC URL, alias-index path), and the signing keypair is a file mounted read-only —SITHBIT_CHAIN_KEYPAIRnames the host path (default: the checked-in devnet test key the retired.envflow held as JSON content) — keypair content in an env var is gone for good.SITHBIT_CHAIN_RPCstill retargets the cluster; the OTLP overlay now setsMAIL_GRPC_OBSERVABILITY__OTLP__ENDPOINT. - The production example gains
sithbitd/account-api parity:
docker-compose.prod.example.ymlmounts a realmail_grpc.toml(viaMAIL_GRPC_CONFIG) plus a separate read-only keypair mount, with a minimal-TOML sketch and the Azure Key Vault alternative inline. docker/smoke.shnow statically parses the chain profile (docker compose --profile chain config -q), so a compose regression there fails the smoke run;mail_docs/screenshot-tools/serve-stack.shandwebclients/README.md— the last live dead-name consumers — swept onto theMAIL_GRPC_*overrides.- Trap for compose authors: compose (v2.29.7) interpolates
${VAR:?}even in inactive profiles, so the profile’s variables take defaults (${VAR:-…}) rather than being required.
2026-07-15 — v0.8.3 (docs: sithbit CLI glossary entry)
Documentation-only: the version tags the unchanged protocol state.
- New glossary entry:
sithbitCLI (Operations & infrastructure section), disambiguated from the external Solana CLI it’s a substitute for in thesithbit config/sithbit wallet createworkflow. CLI Quickstart’s first mention of “CLI” now links into it, picking up the sitewide hover/focus definition previewjs/glossary-tooltip.jsalready gives every glossary link — no new JS or CSS needed.
2026-07-15 — v0.8.3 (docs: marketing landing page)
Documentation-only: the version tags the unchanged protocol state.
- New landing page: Welcome, now the book’s
index.html— mdBook always renders a source tree’sREADME.mdtoindex.htmlregardless ofSUMMARY.mdorder, so the new landing content took over theREADME.mdfilename and the Prelude moved to its ownprelude.mdfile (rendering asprelude.html) to make room;SUMMARY.mdstill lists Welcome ahead of the Prelude for the sidebar reading order. The landing page itself is a hero with the SithBit lockup and tagline, a feature-card grid ordered by end-user value proposition (spam priced out at the source, getting paid for your own inbox, an address tied to your wallet, sealed end-to-end mail, drop-in SMTP/IMAP/POP compatibility, no gatekeeper operator model), and closing links onward into the Prelude and Introduction. Styled by the newcss/landing.css, scoped under.sb-hero/.sb-grid/.sb-cardso it never affects the rest of the book; reuses the existing term-icon set and mdBook theme variables rather than introducing new artwork or colors.
2026-07-15 — v0.8.3 (mail-grpc onto TOML config; [grpc]-only verification for sithbitd)
Server-behavior changes, config surface only: no on-chain ABI,
instruction, error-code, or economic change, so per this file’s own
rules this is a PATCH bump, following the v0.8.2 precedent
(config-surface breaks and server behavior outside the protocol ABI
move PATCH). It is not docs-only — previously-required environment
variables are now ignored, a boot that previously refused without them
now comes up on in-code defaults, and a [grpc]-only sithbitd now
verifies recipients it previously could not.
- HARD BREAK —
mail-grpcmoved onto the layered TOML/env configuration every other SithBit binary uses (item 32(a); a clean break, user decision 2026-07-15). The legacy env-only surface is gone:GRPC_SERVER_ADDRESS,JSON_RPC_URL,DEFAULT_KEYPAIR(and its_VAULT_URI/_SECRET_NAMEcompanions),ALIAS_INDEX_DB,ALIAS_INDEX_POLL_SECONDS,ALIAS_CACHE_SECONDS, andHEALTH_BINDare no longer read. Config now comes frommail_grpc.toml(orMAIL_GRPC_CONFIG) withMAIL_GRPC_*env overrides; every key has a dev-friendly default, so an empty file runs a loopback gateway on127.0.0.1:50051— the private-network posture is now the default, not a convention — with the chain endpoint and signing keypair falling back to the operator’s Solana CLI config, exactly like thesithbitCLI. The keypair-content-in-an-env-var shape is gone with it: the TOMLkeypairis the sixth key source (a keypair-file path, or an Azure Key Vault secret). See the rewritten mail-grpc chapter and its configuration table. sithbitd’s[grpc]section now stands alone (item 32(b)):[grpc]without[ipfs]enables RCPT-time recipient verification (alias resolution + postage checks), at-rest sealing key reads, and alias logins, with the chain pipeline off (delivered copies stayreceived; boot logs the verification-only posture at info) — the MX posture that previously demanded an[ipfs]provider the edge never used.[ipfs]without[grpc]warns and boots with chain access disabled. Both sections together remain the full pipeline, unchanged. See the chain-pipeline section and the updated role-split presets.
2026-07-15 — v0.8.2 (domain-sithbit delegate key source; postmaster_key_file removed)
Server-behavior change, config surface only: no on-chain ABI, instruction, error-code, or economic change, so per this file’s own rules this is a PATCH bump, following the distinguishable-suspend-replies precedent (server behavior outside the protocol ABI moves PATCH). It is not docs-only — a previously-accepted config key is now refused at boot (second bullet) — and pre-launch, a config-surface break does not rise to the on-chain-ABI bar MAJOR is reserved for.
domain-sithbit’sdelegate_key_fileis now a key source — the fifth of the file-loaded secrets to take one. A bare string stays a local file path, byte-for-byte compatible with existing configs;{ kind = "akv", vault_uri = …, secret_name = … }opts into fetching the keypair JSON from an Azure Key Vault secret instead (a kind-less{ path = … }table is also a file). The hot-rotation custody contract is preserved: the key is loaded fresh from the configured source on everyPOST /domain(forakv, a fresh vault fetch per request), so rotating the delegate still needs no restart. Boot validation covers whichever source is configured — a bad vault secret fails startup exactly like a bad file did; unset still meansPOST /domainreplies 503.- HARD BREAK — the deprecated
postmaster_key_filealias is removed (user decision 2026-07-15). The config struct rejects unknown keys, so a config still namingpostmaster_key_filenow fails startup loudly instead of being accepted with a warning. Operators must act: rename the key todelegate_key_filebefore upgrading. See the domain-sithbit configuration table.
2026-07-15 — v0.8.1 (docs: populated web-client screenshots)
Documentation-only. Adds the populated-state screenshots the web-client
pages were missing: a mail-in-it inbox on
the webmail app, the signed-in
settings dashboard (balances + postage),
and live marketplace listings.
Captured by driving the real client bundles against a local mock
account-API that serves the same response shapes the clients are
unit-tested against (mail_docs/screenshot-tools/, Option C) — no chain,
store, or devnet dependency, so the frames are deterministic. Registered
in screenshots.manifest.json; protocol version unchanged.
2026-07-15 — v0.8.1 (docs: onboarding wizard steps 2–4 screenshots)
Documentation-only. Completes the browser-onboarding walkthrough in
Web onboarding: the browser wizard
with screenshots of the remaining wizard steps — claim-your-handle,
set-your-price, and review. These required a running backend (the wizard
calls the account API to establish the wallet and check mailbox
ownership), so they were captured against a local account-api +
mail-grpc→devnet stack with a fresh throwaway wallet. Registered in
screenshots.manifest.json; protocol version unchanged.
2026-07-15 — v0.8.1 (docs: mail-grpc gateway topology design note)
Documentation-only: the version tags the unchanged protocol state.
- New design note:
The mail-grpc gateway topology —
records the 2026-07-15 decision that
mail-grpcstays a separate service (three roles in one process: write gateway, read gateway, alias/sales indexer; options considered; what would reopen it), the two-key custody clarification (the gateway’s fee-payer keypair is notdomain-sithbit’s standing delegate), and the now-explicit private-network-only posture — the gRPC surface has no TLS or authentication, so it must never be publicly reachable. - mail-grpc chapter gains the network-posture
callout (prefer loopback/private binds over
0.0.0.0); Scaling out notes the gateway is not a fleet member — many workers share one gateway safely because store leases serialize each wallet’s writes.
2026-07-15 — v0.8.1 (docs: onboarding wallet-generation screenshot)
Documentation-only. Adds a screenshot of the onboarding wizard’s
“Create a new wallet” step — the one-time secret-key reveal, passphrase,
and “I have saved it” gate — to
Web onboarding: the browser wizard.
Captured from the standalone (no-backend) client with the generated
secret key redacted. Deeper wizard steps (handle/price/review) are not
included: advancing past the wallet step requires the account-API
backend, so those await a stacked capture. Registered in
screenshots.manifest.json; protocol version unchanged.
2026-07-15 — v0.8.1 (distinguishable suspend replies & operational polish)
Server-behavior changes, all backward-compatible: no on-chain ABI, instruction, error-code, or economic change, and nothing previously accepted is refused — so this is a PATCH bump, not the MINOR that at-rest sealing took (which changed what the system does with mail). The version moves because reply texts on the wire are behavior, not documentation.
- Suspended accounts now hear why (user decision 2026-07-15:
distinguishable everywhere). SMTP AUTH answers
535 5.7.13 Account disabled(RFC 3463 “user account disabled”, was the deliberately indistinguishable 5.7.8), IMAP answersNO [CONTACTADMIN] account disabled; contact your administrator(RFC 5530), and POP answers-ERR [SYS/PERM] account disabled; contact your administrator(RFC 3206). Safe disclosure: every disabled reply is issued only after the credentials verified, so only the account holder ever sees it. See the per-surface refusal table. sithbit-migratecarries the abuse controls. The account pass now copies the suspend flag and replays the rolling hour/day outbound-usage totals as of the migration instant (bucket timing is not recoverable through the store surface — caveat recorded); the run summary printsaccounts: N (M suspended). See the migration page.- Undatable dead jobs are pruned, not spared. Every storage
backend now stamps the bury time into the dead message itself, and
the hourly retention prune treats an entry with no readable date as
older than any cutoff — previously such entries escaped pruning
forever. The prune (
[spooler] dead_retention_days, default 30) is now documented under The job queues. - Mail listings surface reply and bounty facts.
sithbit mail get’s per-message listing appendsReply-to-hash:,Bounty:, andBounty-expires:lines when set (absent otherwise — a plain send’s listing is byte-identical to before). See Get mail. - The trustless viewer marks replies. The web/plugin viewer panes
render a “Reply to
<hash>…” line for reply messages, mirroring the CLI listing’s reply-to-hash treatment. - Internal: the sealed-envelope decompression cap now reports a distinct, operator-diagnosable error when a payload claims to inflate past the 64 MiB cap (a corrupt stream stays opaque, anti-probing). The cap itself is unchanged.
2026-07-15 — v0.8.0 (Solana clusters & RPC-endpoint appendix)
Documentation only; no code, ABI, API, or CLI change — the protocol version is unchanged per the versioning note above.
- A new appendix: Solana clusters and RPC endpoints. One page answering the questions every “configure an RPC endpoint” step assumes away: what Solana’s public clusters (devnet, testnet, mainnet-beta) are, which ones SithBit uses today — devnet hosts the live test deployment, local work runs on surfpool, mainnet-beta awaits launch — and what the URL you configure actually points at. CLI Quickstart introduces it at the point you first configure an endpoint.
- Book-wide cross-links. The natural “RPC endpoint” mentions across onboarding, deployment, the configuration reference, the GUI-client pages (Thunderbird, Outlook, Chrome, Lockbox), and the glossary now link to the appendix, so the term resolves to its explanation from anywhere in the book.
2026-07-15 — v0.8.0 (docs: first web-client screenshots)
Documentation-only. First screenshots of the browser clients, captured
from the standalone (no-backend) first-run states and embedded in their
pages: the webmail first-run wizard, the
web onboarding wizard,
and the marketplace sign-in screen. These are
now guarded by check_screenshots.py (registered in
screenshots.manifest.json), so a webclients/** UI change that isn’t
re-shot fails the docs gate. Protocol version unchanged.
2026-07-15 — v0.8.0 (docs: Apple Mail extensibility note)
Documentation-only. New reference appendix
Apple Mail extensibility (MailKit)
records Apple Mail’s supported extension surface (the four MailKit extension
points, the Sonoma removal of legacy mail bundles) and why SithBit ships no
Apple Mail client today — MEMessageSecurityHandler could unseal mail on
macOS, but there is no MailKit on iOS/iPadOS and no room for the shared
account pane. Protocol version unchanged.
2026-07-15 — v0.8.0 (at-rest sealing goes live in production)
The item-27 at-rest sealing machinery — shipped 2026-07-14 as a fixture-proven capability — is now wired on in production. No on-chain change; the bump is MINOR because the running system’s behavior changes for end users and one previously-accepted SMTP credential shape is now refused.
- Sealing is automatic on chain-connected deployments. Any
sithbitdor account API with a chain gateway seals password-less accounts’ delivered mail at spool time; there is no config setting (decided: the per-account rule — stored password ⇒ plaintext — is the only gate). The chain-less dev stack stays plaintext. See What your operator holds and sithbitd: At-rest sealing. - The IPFS copy is sealed at spool time too (decided: pin format
“seal-for-IPFS at spool”).
crypto_box_sealis non-deterministic, so the spool-time bytes are the canonical artifact the worker pins — a crash-window rerun re-pins identical bytes and the recorded CID never drifts. Reply-linkage ids are parsed in the same pass and persisted (a sealed body can never be re-parsed). Spool-time facts are canonical: a reading key rotated between delivery and pin takes effect from the next message. - SMTP refuses the reading-secret suffix (decided: reject). A
wallet-signature
AUTHwhose password carries the.base58(secret)login suffix is refused with a normal 535 on the submission path — the secret belongs only where reading happens (IMAP/POP/webmail login). - Wrap hygiene: deleting a message’s last copy now drops its DEK wrap rows and any not-yet-pinned sealed IPFS artifact alongside the blob bytes, on every storage backend.
2026-07-15 — v0.7.0 (alias-transfer consent — BREAKING)
A breaking protocol change: every alias transfer now requires the
recipient’s consent — the escrowed offer/accept flow is the only transfer
path, and the unilateral TransferAlias refuses. This reverses the
recorded 2026-07-05 decision (user decision, 2026-07-15): the
“never planted on a wallet that didn’t ask for
it” guarantee is now
protocol-wide instead of contradicting the old immediate path. Per the
versioning preamble MAJOR stays 0 pre-launch (the enum stayed
append-only in place), so this lands as a MINOR bump with the break
stated plainly: transactions submitting TransferAlias no longer
execute.
TransferAliasis disabled. Discriminant 1 still decodes (history replays cleanly, e.g. in the gRPC indexer) but the dispatch arm refuses with the appended error 85 (UnilateralTransferDisabled, “Unilateral TransferAlias is disabled; stage an OfferTransferAlias (fee may be 0) for the recipient to accept”). See Transfer an alias.- Zero-fee offers are legal.
OfferTransferAliasno longer requires a positive fee:alias transfer initstages a free hand-off by default (--feedefaults to 0, and--expires-inno longer requires it). The recipient still accepts — consent is structural on every path (accept/buy/bid signatures).OfferFeeZero(51) is retired in place, kept only for the frozen error-code ABI. - The flat transfer fee moved to the zero-fee accept. A free
hand-off’s
AcceptTransferAliascharges the delegate-tunableALIAS_TRANSFER_FEE_LAMPORTS(payer → postoffice), waived when the offer’s holder is the standing delegate — the waiver identity moved from the old path’s payer to the holder, keeping bulk-reservation hand-offs fee-free end to end. Priced offers keep the pure 90/10 split; no flat fee rides on top. - Clients follow. The CLI’s
transfer initis one offer-staging code path (the accept surfaces the flat fee or its waiver before signing);mail_wasmretirestransfer_alias_tx/transfer_alias_unsigned, and the web clients’ transfer pane stages a zero-fee offer with “recipient must accept” copy. The CU table swaps the TransferAlias row for OfferTransferAlias (18,894) and the zero-fee AcceptTransferAlias (13,838).
2026-07-14 — v0.6.0 (the domain-program split — BREAKING)
A breaking protocol change: the domain registry moved out of the mail
program into a new, third on-chain program. Per the versioning preamble
MAJOR stays 0 pre-launch (the instruction enums themselves stayed
append-only in place), so this lands as a MINOR bump with the break stated
plainly: transactions that submit domain instructions to the mail program
no longer execute.
- A third on-chain program owns the domain registry.
domain_program, IDDmaiNcmXsPw2juV9JoZSC47V5epAysQi3DJVk3fiBuUv(devnet twinDmaiNHGvprK2op7xqZHXp8UVXXmPUtkas96Goh5sCJQn), carries the domain lifecycle, the DNSSEC-proof authorize/reclaim flows, the domain marketplace, and its ownAdminCloseAccount(discriminant 16) asDomainInstructiondiscriminants 0–16. The domain, domain-listing, pending-deactivation, pending-reclaim, and proof-witness PDAs now derive under and are owned by the domain program. See the reworked Program & PDA reference. - The sixteen mail-side domain discriminants are retired. Sending
9–12, 22–24, 26–28, 31–33, or 35–37 to the mail program is rejected with
the appended error 84 (
InstructionMoved, “This instruction has moved to the domain program”).SetDomainFee(17) andSetRootKsk(25) stay mail-side: they mutate the postoffice, which remains a mail-program account the alias and domain programs read cross-program (fees, root KSK, delegate gate) — the split moved the registry, not the treasury. - The mail program is now unconditionally modexp-free. The
dnssec-proofCargo feature — and thesol_big_mod_expdeployability problem it gates — moved to the domain program (default-on there); the mail and alias programs’ default builds now deploy on any cluster. The modexp-free build page now describesdomain_program, the only program that still needs it. - No client-facing surface changed. The
sithbit domain …commands, the postmaster domain admin flows, the proof staging, the gRPC gateway’s domain/listing scans and sale-history walk, and domain-sithbit’sPOST /domainall follow the domain program transparently — account-meta lists are byte-identical pre/post split.sithbit postmaster reclaimnow drains all three programs’ accounts, postoffice last.
2026-07-14 — v0.5.9 (cleanup bundle: per-cause POP sealed refusals)
Server/client maintenance only; no on-chain ABI, economic, API-contract, or CLI change.
- POP3 now says why a sealed message can’t be served. Retrieving a
DEK-sealed message a session cannot read now answers with a cause-specific
-ERR [SYS/PERM] message is sealed at rest: …line — no reading key in this session, no wrapped key for this reader, or reading key does not match — using the same sealed-refusal vocabulary as the account API. Previously every such fetch collapsed to the generic-ERR [SYS/TEMP] message unavailable, which still covers genuine storage trouble (retryable). - Trustless clients decode the whole mailbox account. The web clients’
direct-RPC chain reader now surfaces
default_postage,domain, andno_ipfsalongsidemail_count, matching the wasm decoder — a strict superset of what the account-API proxy returns.
2026-07-14 — v0.5.8 (web clients: web3.js → @solana/kit codec bundle)
Client build/packaging maintenance only; no on-chain ABI, economic, API, or CLI change.
- The web clients’ vendored Solana library shrank by ~74%. The shared
client library’s vendored
@solana/web3.jsbundle (web3.esm.js, ~682 KiB) is replaced by a codec-only bundle built from@solana/kit7.0.0 (kit-codec.esm.js, ~178 KiB). The external-wallet bridge (Phantom/Ledger transaction signing) now rides kit’s wire codecs behind the same interface — no behavior change for any client. All six shells (webmail PWA, marketplace, onboarding, Outlook, Thunderbird, Chrome) rebuild and repackage against the new bundle.
2026-07-14 — v0.5.7 (trustless webmail, at-rest mail sealing)
No on-chain ABI or economic change — client, server/API, storage-schema, and infrastructure work only.
- Trustless webmail. The webmail PWA can now run with no account API at
all: leave the API URL blank in connection settings and the app unlocks
with the wallet alone, reads the chain over direct RPC (the same wasm
signing/decoding module the plugins use), enumerates the on-chain inbox,
opens sealed bodies locally, and sends — sealing to the recipient’s
published key, pinning through a configured pin service
(
sithbit-ipfsd), and submitting theSendMailtransaction itself. The recipient’sno_ipfsopt-out is honored client-side. Bodies pinned by the client sit outside the operator’s unpin/settle sweep — the client owns that pin’s lifecycle. See Trustless webmail. Both IPFS HTTP surfaces (sithbit-ipfsd,sithbit-gateway) now answer cross-origin browser requests (permissive CORS). - At-rest mail sealing (deployment capability). For accounts without a
stored mail password, a server deployment can envelope-encrypt delivered
copies at rest: each body sealed once under a fresh per-message
AES-256-GCM key, wrapped per reader with the sealed-box construction.
The client-derived reading secret rides the wallet-signature login
(IMAP/POP password suffix;
reading_secreton the account API token exchange), lives only in session memory, and unlocks decrypt-on-read over IMAP FETCH, POP RETR, and/v1/mail. Sessions without the key get a clear refusal, never ciphertext. Storage gains a per-reader wrap table (all six backends). Honest scoping — what this does and does not protect against — in What your operator holds. - Cleanups. The DNSLink config no longer prints its API token in debug
output; the AWS Terraform module’s DynamoDB GSI moved off the
provider-deprecated
hash_key/range_keyarguments; pin/unpin mentions across the book carry a pin icon (see the icon legend).
2026-07-14 — v0.5.6 (customer-managed KMS, first IaC, secret-log hygiene)
No on-chain ABI or economic change — a new optional store setting, deploy templates, and cleanups.
- Customer-managed KMS keys. The AWS store gains an optional
kms_master_key_idunder[store.aws](key ID, alias, or ARN): set, the DynamoDB table is created with KMS-backed SSE and the SQS queues switch from SSE-SQS to SSE-KMS under the same key; unset (the default) keeps today’s provider-managed encryption. See Cloud-store overlays. - First infrastructure-as-code. A new
iac/directory at the workspace root provisions what the servers otherwise create at startup: a Terraform module for the AWS store (DynamoDB + SQS, optional CMK, optional default-off S3 blob bucket) and a Bicep module for the Azure storage stack. Templates are statically validated only — the binaries remain fully zero-config-capable without them. - Secrets kept out of logs. The IPFS provider configs (Pinata JWT, Filebase secret key, remote daemon token) now redact their credentials from debug output, matching the store configs.
- Docs. A committed fragment-link validator (
mail_docs/check_anchors.py) now guards the book against silently broken anchors; the blake3 appendix’s “postoffice commitment set” icon matches its term; account API and IPFS benefits link to per-recipient pin providers.
2026-07-14 — v0.5.5 (per-recipient pin providers, docs repairs)
No on-chain ABI or economic change — a new operator-local account setting plus a docs pass. Storage gains three internal account columns for the sealed provider credentials (nullable, backward-compatible).
- Per-recipient pin providers. A mailbox owner can register their own
IPFS pinning provider (Pinata, Filebase, or a self-run
sithbit-ipfsd) with their mail server via the account API (GET/PUT/DELETE /v1/account/pin-provider, JWT wallet auth); delivery then pins their inbound sealed bodies to that provider in addition to the operator’s default pin — best-effort, never affecting delivery or chain state, and the operator pin stays authoritative. Credentials are sealed under the server credential key like mail passwords; theno_ipfsopt-out continues to suppress all pinning. See Per-recipient pin providers. - Anchor-link repairs. Seven intra-book fragment links to headings with a mid-heading icon were written with a single hyphen where mdBook’s slugifier emits a double hyphen, and silently pointed nowhere; all fragment links across the book now resolve.
- TOC nesting. The name marketplace and Lockbox pages are now nested under the GUI clients parent in the sidebar — they are features riding the clients, not top-level topics.
- Postoffice terminology. “Postmaster commitment set” is corrected to “postoffice commitment set” in the blake3 appendix and glossary, matching the code — the Merkle commitment lives on the postoffice account.
2026-07-13 — v0.5.4 (at-rest SSE, DMARC report completeness, GUI-client docs)
No on-chain ABI, economic, or public-API change — server-side hardening, DMARC report content, and a docs pass. Storage gains four internal DMARC columns (nullable, backward-compatible).
- Encryption at rest on cloud stores. The AWS backend now requests server-side encryption when it auto-creates its resources — SSE on the DynamoDB tables (AWS-owned key) and SSE-SQS on its queues — with no key configuration. Azure Storage/Tables and Cosmos are always encrypted at rest by the platform. Customer-managed KMS keys remain a deferred option. See Scaling out and Deploying.
- DMARC aggregate reports carry the published policy. RUA reports now emit
the evaluated
<policy_published>p/sp/adkim/aspf(previously left unset), stored per-record across every storage backend. See[spooler.dmarc_report]. - DMARC forensic reports carry the message envelope. RUF/ARF failure reports
now include the RFC 5965
Original-Mail-From,Original-Rcpt-To, andOriginal-Envelope-Ididentifiers. See[spooler.dmarc_ruf]. - blake3 documentation & naming. The blake3 appendix
intro and table now enumerate all five blake3 uses (adding the postoffice
Merkle commitment set); the PDA-derivation helpers’ hash parameters were
renamed
*_sha256→*_blake3to match what they actually carry (cosmetic — PDA values unchanged). - Threat model — per-authority accountability (S1). The threat model now documents the MX spoof-burn trust gap as a known, policy-mitigated assumption, with cryptographic per-authority accountability recorded as deferred work.
- GUI-client docs regrouped. Thunderbird, Outlook, webmail, and Chrome are now sibling subtopics under a new GUI clients landing page, and the browser onboarding wizard counts Chrome as its fourth shared-wizard web client.
2026-07-13 — v0.5.3 (Chrome extension, installable webmail, docs backfill)
No ABI, API, CLI, or storage change — two new client-side surfaces and a docs pass. Nothing on-chain is renumbered or relaid-out.
- Chrome extension. A fourth web-client shell (
webclients/chrome), an installable Manifest V3 popup for onboarding and wallet management — equivalent to the Thunderbird/Outlook shells, not a Gmail integration and not an in-popup mail reader (mail read/send rides any IMAP/POP/SMTP client). Reuses the shared Alpine+wasm core over a newchrome-store.js(chrome.storage) adapter;build.shproduces a loadablestaging/tree and a packagedsithbit-chrome.zip. See The Chrome extension. - Installable webmail (PWA). The webmail app now ships
a web app manifest + a static-shell service worker, so a browser can install
it (desktop “Install app” / mobile “Add to Home Screen”) and load the shell
offline. Mail content stays live — the service worker never caches the
account API (
/v1/*). - Reference/docs backfill. The Program & PDA reference
MailInstructiontable is now complete (all 39 variants); the glossary blake3 entry is corrected to its five current uses; and hand-writtentarget="_blank"was retired from the remaining pages (the global external-links hook governs them). - Docs landing page. The Prelude (
README.mdat the time; moved toprelude.md2026-07-15 when the marketing Welcome page took over theREADME.md/index.htmlslot) was the book’s root landing page; the Introduction moved to its own page.
2026-07-13 — v0.5.2 (SithBit brand identity)
Presentational only — no ABI, API, CLI, or storage change. A shared visual identity now spans the mdBook docs, the four web shells, and the two browser plugins.
- One brand, everywhere. A “dark-side” palette (near-black backgrounds, a
violet primary, a crimson accent used sparingly), an
S-monogram mark +sithbitwordmark, and a matching favicon/plugin-icon set. The docs, webmail, marketplace, onboarding, Thunderbird, and Outlook all carry it. See Brand & identity. - Single source of truth for the tokens. The palette is defined once as
--sb-*CSS custom properties inwebclients/shared/brand.css(consumed by the shells and plugins) and mirrored onto mdBook’s per-theme variables inmail_docs/css/brand.css; the plugin icons are rasterized from one canonical mark SVG.
2026-07-13 — v0.5.1 (reach: discovery keyserver, incoming delivery, key headers)
No on-chain ABI change — an additive gRPC field (AliasRequest.domain), a new
public HTTP endpoint, and a client mail-header feature build “reach” on top of
v0.5.0’s domain-scoped namespace. Nothing on-chain is renumbered or relaid-out.
- Incoming mail to
user@verified-domain. A SithBit MX now accepts inboundRCPT TO:<user@domain>for a verified domain: the recipient domain threads through the gRPC gateway’s domain-scopedDomainAliaslookup (global-alias fallback preserved; empty domain = the legacy path) to the designated wallet’s mailbox. Completes the delivery leg of the domain-scoped namespace — those addresses can now receive mail, not just be registered and resolved by a native client. See “Receiving mail at a domain-scoped address” (page removed in v0.42.0). - Public discovery keyserver —
GET /v1/chain/cert?email=. An unauthenticated account-API endpoint (HKP/WKD-style) resolves an email / alias / wallet to the recipient’s published X25519 key so any sender can discover it. Empty key ⇒ 200 (seal to the wallet itself); unknown recipient ⇒ 404. The data is already public on-chain; the endpoint is a convenience and a public enumeration surface operators may wish to rate-limit. See thecertkeyserver. - Autocrypt-style key headers on plugin mail. The lockbox
plugins now advertise the sender’s published key in an opportunistic
X-SithBit-Keyheader on outgoing mail and cache it from received mail, so a correspondent’s key is auto-discovered without a chain round-trip on replies. SithBit-specific (X25519, not OpenPGP Autocrypt); the chain stays the source of truth. See Autocrypt-style key discovery.
2026-07-13 — v0.5.0 (domain-scoped aliases)
Additive on-chain ABI: three new AliasInstruction variants
(RegisterDomainAlias, RemoveDomainAlias, UpdateDomainAlias), a new
DomainAlias account, and three appended error codes. Nothing existing is
renumbered — the protocol stays append-only and pre-launch.
- Domain-scoped alias namespace —
user@verified-domain → wallet. A verified domain’s authority can now mapalice@acme.com,bob@acme.com, … to wallets in a namespace only that authority may write to — distinct from the shared global-alias namespace, and with no registration fee (the authority already owns the domain). New CLI:sithbit alias register-domain <local@domain> --wallet <k>,alias update-domain(repoint in place),alias remove-domain(refund rent). See “Domain-scoped aliases” (page removed in v0.42.0). - Resolution precedence.
sithbit alias get user@domain— and the lockbox plugins’ recipient resolver — now resolve a domain-scoped mapping first and fall back to the global alias when none exists, so existing global aliases keep resolving unchanged. Lockbox mail touser@verified-domainseals to the domain-designated wallet. - Trust model. The domain authority alone controls its
user@domainmappings (create/repoint/remove) — the same authority already trusted to relay the domain’s mail. See the threat-model note.
2026-07-13 — v0.4.5 (lockbox mail v1)
New client capability; no on-chain instruction, account layout, or error-code
change (it reuses the existing SetMailboxKey instruction and the sealed-box
crypto). One additive CLI flag.
- Lockbox mail — client-side end-to-end encryption over ordinary email. The Thunderbird extension and Outlook add-in can now seal a message body to its recipient before it leaves your machine and unseal it after it arrives, so the mail server, relay, and stored copy all see only ciphertext. v1 seals to SithBit-native recipients (a raw wallet or a global alias); a recipient that can’t be resolved is sent ordinary plaintext, and sealing is all-or-nothing per message. Both ends need the plugin. See Lockbox: end-to-end encrypted mail.
- Recoverable reading key.
sithbit mailbox set-key --derivepublishes a reading key derived from the wallet (a deterministic wallet signature run through a KDF), so it regenerates on any device — including a hardware wallet — with nothing to back up. The trade-off (anyone who can make the wallet sign the fixed message learns the key) is documented in the threat model. - The
user@verified-domainnamespace is planned but not yet shipped — v1 aliases are domain-blind, soalice@acme.comandalice@other.comresolve to the same globalalice. A domain-scoped namespace owned by each verified domain’s authority is the next step.
New client surface; no on-chain instruction, account layout, error-code, or CLI
change (the on-chain CreateMailbox/CreateAlias/SetMailboxKey instructions
the flow uses already existed).
- Onboard with Phantom or Ledger. The web onboarding wizard — in all three mail clients and on the standalone get-started page — gains a third wallet path alongside create/import: connect an external browser wallet. Your funds-holding signing key never enters the browser (you approve the mailbox create in the extension); because a hardware wallet cannot open sealed mail, a low-value delegated reading key is generated in the browser and published in the same one-approval transaction, and mail reading routes through it. The honest trade-off (a browser-held reading key you save once, rotatable, whose loss costs only already-received mail) is documented. See Web onboarding.
- Add another wallet from inside a client. Each signed-in client dashboard now has an “Add another wallet” button that re-opens the wizard for a fresh wallet, so onboarding is no longer a first-run-only flow. See Onboarding a second wallet.
2026-07-12 — v0.4.3 (standalone onboarding page)
A new client surface; no on-chain instruction, account layout, error-code, API, or CLI change.
- A standalone “get started” page. The five-step onboarding wizard — create
or import a wallet, claim a mailbox and an optional handle, set your default
postage — is now also served on its own shareable URL (the
webclients/onboarding/bundle), so a brand-new user can be pointed straight at it with no mail client installed. It runs the identical wizard the webmail, Outlook, and Thunderbird clients show at first run, and is served same-origin byaccount_apilike the standalone marketplace page. See Web onboarding.
2026-07-12 — v0.4.3 (self-contained web clients & dependency maintenance)
Build, packaging, and dependency maintenance; no on-chain instruction, account layout, error-code, API, or CLI change.
- The web clients no longer load code from a CDN.
@solana/web3.jsis now vendored into the shared client library and served from the same origin, so the webmail, Outlook, and Thunderbird clients (and the standalone marketplace page) fetch no third-party script at runtime. This removes the Thunderbird extension’s last remote-code reference — the blocker for an add-on–store submission — and lets the clients run fully self-hosted and offline. - Dependency housekeeping. OpenTelemetry (operator telemetry) moved to the
0.32 release train; every wildcard (
*) workspace dependency was replaced with a proper version floor andmail-authpinned exactly, hardening reproducible builds. No runtime behavior change.
2026-07-12 — v0.4.2 (marketplace GUI & modexp-free deploy)
Client-surface, tooling, and operator additions; no on-chain instruction, account layout, or error-code change — the new deploy build gates code out without touching the default ABI.
- The name marketplace, in the web clients and plugins. Browse
aliases and domains listed for sale, buy them, and
list your own — as a standalone web page and as a pane inside the webmail,
Outlook, and Thunderbird clients, with For sale / Expired / Sold filters
(default: For sale). Buying and listing sign with a connected Phantom or Ledger
wallet (external signing), distinct from the in-wasm keypair used elsewhere;
browsing and sale history read the
/v1/chain/listingsand/v1/chain/salesendpoints. See The name marketplace. - A modexp-free deploy build for
AdminCloseAccount.mail_programgained adnssec-proofCargo feature (default on); a--no-default-featuresbuild drops thesol_big_mod_expsyscall so postmaster reclaim — and everything else — can deploy to devnet/mainnet-beta today, where that syscall is still inactive, at the cost of DNSSEC-by-proof coverage in that build. See The modexp-free deploy build.
2026-07-12 — v0.4.1 (marketplace read API, wallet-adapter builders & privacy docs)
Off-chain API, client tooling, and documentation additions; no on-chain ABI change.
- Browse listings and sale history. New read endpoints back the marketplace:
GET /v1/chain/listings(aBrowseListingsscan of every alias and domain listed for sale) andGET /v1/chain/sales(ListSales— full alias + domain sale history, parsed from program logs). See the account API. - Wallet-adapter (unsigned) transaction builders.
mail_wasmgained unsigned builders for the marketplace and onboarding instructions, so a Phantom or Ledger wallet can sign externally — the basis for the marketplace GUI’s external-signing path. - What’s public and private. Two new pages map SithBit’s privacy model end to end: a plain-language What’s public and private overview and the exhaustive field reference — covering the harvest-now-decrypt-later trade-off and public marketplace-purchase metadata. Cross-linked from the threat model.
2026-07-12 — v0.4.0 (postmaster reclaim)
Adds an on-chain administrative instruction; it is append-only, so no existing client breaks.
- Reclaim and reset accounts. A new
AdminCloseAccountinstruction (in both programs) lets the standing postmaster delegate close program-owned accounts and refund their rent — the basis of a newsithbit postmaster reclaimtool (behind a compile-timereclaimfeature, with a mainnet typed-confirm guardrail). See The Postmaster.
2026-07-12 — v0.3.1 (postmaster CLI regrouping & custody docs)
CLI-surface and documentation changes; the underlying on-chain instructions are unchanged.
- Postmaster commands shortened.
postmaster install-commitmentbecomespostmaster commitment, andpostmaster rotate-delegatebecomespostmaster delegate(command labels only — theInstallCommitment/RotateDelegateinstructions are unchanged). See Postmaster key custody. - New reference material. A per-server RFC-coverage table and two service-discovery diagrams.
2026-07-12 — v0.3.0 (alias auctions & web onboarding)
- Alias auctions. Aliases can now be sold by ascending-bid
auction, not only at a fixed price: new
SellAlias(auction mode),BidAlias, andSettleAuctioninstructions, with an anti-snipe extension and a 90/10 fee split. Drive it withsithbit alias sell --auction/bid/settle-auction. See Auction an alias. - Guided web onboarding. A 5-step first-run wizard (create wallet → mailbox
→ alias → keys → earnings) now greets new users across the webmail, Thunderbird,
and Outlook clients, mirroring the CLI
setupflow.
2026-07-11 — v0.2.1 (CLI tooling & ergonomics)
CLI-surface changes only; no protocol ABI or economic change.
- Build a proof witness from live DNS. New
sithbit domain gather-witness <domain>collects a domain’s signed DNSSEC chain from a recursive resolver and serializes the witnessdomain authorize/domain reclaimstage, re-walking it locally before it is ever submitted (behind the opt-ingatherbuild feature). See Building the witness withgather-witness, and the zone-setup notes — including Cloudflare-hosted domains — in Publishing the DNS records the proof needs. - Root-KSK commands regrouped under
ksk.postmaster set-root-kskbecomespostmaster ksk set,postmaster root-ksk-from-ianabecomespostmaster ksk iana, and a new read-onlypostmaster ksk getprints the fingerprint currently anchored on thePostOffice(orunset). See Prerequisite: publish the root KSK. - Derive the root-KSK fingerprint from IANA.
sithbit postmaster ksk ianaturns IANA’s published root trust anchor (root-anchors.xml) into the base58 valueksk setwants — a pure offline conversion. Its new--fetch-anchors <DIR>downloads the anchor, its detached S/MIME signature, and ICANN’s CA bundle, then prints theopensslverification command and the follow-up derive step (it does not derive until you have verified). During a root-KSK rollover (two active anchors, as with the current KSK-2024 introduction) it warns and recommends the newest byvalidFrominstead of erroring blindly, and a new--key-tag <TAG>pins a chosen anchor. See Rollovers: more than one active anchor, Downloading and verifying the anchor, and Getting ICANN’s CA independently. - Fee getters grouped.
sithbit postoffice fee stamp/fee domain/fee aliasreplace the flatfee/domain-fee/alias-fee, and the delegate setters are nowsithbit postmaster fee stamp/fee domain/fee alias(wasset-stamp-fee/set-domain-fee/set-alias-fee). See The Postmaster → Checking status. - Alias transfer grouped.
sithbit alias transfer init/transfer accept/transfer cancelreplacetransfer/accept-transfer/cancel-transfer. See Transfer an alias. - Alias listing cancel folded in.
sithbit alias sell <alias> --cancelreplacesalias cancel-sell. See List an alias for sale → Cancelling a listing. - Alias create is variadic.
sithbit alias createnow takes one or more aliases (reading stdin when none are given), absorbingalias create-bulk, which is removed. See Bulk reservation. mailbox credentials.sithbit mailbox derive-passwordis renamedsithbit mailbox credentials(it prints the mail username + password pair).
2026-07-11 — v0.2.0 (Set 7)
- Frombox prepayment. A third-party sender’s first stamp purchase must now
include at least one stamp; a zero-stamp frombox can only be created by its
owner. The standalone
frombox createcommand was removed —frombox stampnow creates the frombox on first purchase. See Add stamps → The prepayment rule and Creating the frombox on first purchase. - Set a price before the frombox exists.
frombox updatenow creates the frombox first (as a zero-stamp, owner-only account) when it does not yet exist, so a recipient can set their per-sender price up front. See Update postage → Setting a price before the frombox exists. - Escrowed alias transfer in the web clients. The escrowed offer / accept / cancel transfer flow is now available in the web and email-plugin marketplace pane, matching the CLI. See Transfer an alias → Selling an alias: escrowed transfer for a fee.