Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Welcome

Email you own. Spam you don’t pay for.

A decentralized mail protocol on Solana — postage paid by the sender and earned by you, pricing spam out at the source; addresses tied to your wallet instead of a rented domain; and mail encrypted end-to-end and stored off-chain on IPFS. Standard SMTP, IMAP, and POP3 underneath, so the inbox you already use just works.

Read the Prelude → Skip to the Introduction


Get paid for your own inbox

Postage from strangers lands directly in your wallet. You’re compensated for the attention you’re already spending defending it, instead of a provider monetizing your data for free.


Turn your inbox into an opt-in income stream

Opt into the campaign pool and advertisers pay you — postage to land in your inbox, and a bounty when you reply. You pick the topics you’ll hear about; nobody reaches you uninvited or for free.


An address that’s actually yours

Tied to your wallet, not a domain someone else controls. Move it to a new domain any time and keep your history and identity intact — nobody can revoke it out from under you.


Private by default

Every message is sealed straight to your wallet and stored on IPFS, not on a company’s server. No provider ever holds your mail in the clear.


Works with the inbox you already use

Standard SMTP, IMAP, and POP3 to the letter — your existing mail client just works. No new app to learn, no migration required.


No gatekeeper, no single company

SithBit is a protocol, not a product. Anyone who owns a domain can run a mail server and earn a share of the mail it carries — the same footing as every other operator on the network.

Curious how a blockchain 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.

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 blockchain email.

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.

Worse, 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 in exchange for a losing battle. And that provider usually also owns the domain your address lives on — your identity on the internet is rented, not owned, and it stops existing the moment they decide it should.

The mail program fixes the actual incentive: it charges the sender, not the recipient, and lets you set the price. 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.

Blockchain Email

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 stranger has to pay to reach you (new mailboxes default to 1 SOL/message), and you can raise, lower, or waive that price per sender at any time. Spam has to actually be profitable after your price, not just after a filter’s guess.
  • You get paid for your own attention. Postage from strangers accrues directly to your wallet — you’re compensated for the inbox space you’re already defending, instead of paying a provider or letting them monetize your data instead.
  • Known senders cost you nothing extra. Each correspondent gets their own prepaid postage balance for mail to you (a “frombox” — see Fromboxes). Allow-listing someone just means funding theirs yourself, and that 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.
  • Your address is yours. It’s tied to your wallet, not 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.

See Economics for the full lamport-by-lamport ledger of who pays and who collects at every step.

Priced in SOL — No New Token to Trust

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.

That means no mint anyone controls, no allocation for insiders to dump, and nothing to “rug.” Sending and receiving mail never requires acquiring, wrapping, or swapping into some other asset first — 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, a blockchain-based mail system needs to clear millions of transactions per second — throughput only the Solana blockchain provides today.

Decentralized Email

Pricing solves the incentive; storage solves the trust problem. This system uses the Interplanetary File System (IPFS) to store mail bodies, encrypted by the sender, so no central authority ever holds your messages in the clear — see IPFS storage: benefits to users for what that means for you, and where to go for the IPFS project itself. Taken together, it’s possible to send email between two addresses using only the blockchain for transactions and IPFS for storage — bypassing the internet’s traditional SMTP relay infrastructure entirely.

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 stream that can offset it.
  • The barrier to entry is a small, flat, one-time fee, not a gatekeeper’s approval — 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.
  • You’re never locked into someone else’s infrastructure. Because routing and pricing live on-chain rather than inside one company’s servers, you can self-host a domain you already own instead of renting identities out to users the way traditional email providers do.

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 spam economy that finally works without throwing away Thunderbird, Outlook, Apple Mail, or the mobile client already on your phone.

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.

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.

RFCTitle / featureNotes
RFC 5321Simple Mail Transfer ProtocolCore command sequencing and reply codes; null sender and postmaster envelope forms.
RFC 3463 / RFC 2034Enhanced mail system status codesENHANCEDSTATUSCODES; class digit derived from the reply code.
RFC 4954SMTP authentication (AUTH)Submission mode requires AUTH over TLS.
RFC 3207Secure SMTP over TLS (STARTTLS)Pre-TLS buffer discard per §4.2/§6.
RFC 61528-bit MIME transport8BITMIME.
RFC 3030Chunking and binary MIMECHUNKING/BDAT, BINARYMIME.
RFC 1870Message size declarationSIZE.
RFC 2920Command pipeliningPIPELINING; inherent to the sans-io design.
RFC 3461Delivery status notification parametersDSN: RET/ENVID/NOTIFY/ORCPT carried on the envelope.
RFC 3464Delivery status notification report formatFailure DSNs generated by the relay on exhausting the retry schedule.
RFC 6522The multipart/report media typeContainer for both DSN and ARF reports.
RFC 3848ESMTP transmission typeswith keywords in the Received: trace header.
RFC 8601Authentication-Results headerRecords SPF/DKIM/DMARC verdicts on accepted mail.
RFC 7208Sender Policy Framework (SPF)Inbound-relay sender authentication (via the adopted mail-auth).
RFC 6376DomainKeys Identified Mail (DKIM)Verified inbound; signed once on outbound spool entry.
RFC 7489Domain-based Message Authentication (DMARC)Full evaluation and disposition (alignment, p=reject/p=quarantine, pct sampling) plus §7.2 aggregate (rua) and §7.3 failure (ruf) reporting. Selectable via sender_auth = "dmarc".
RFC 5965Abuse Reporting Format (ARF)message/feedback-report body of each forensic report.
RFC 6591Authentication-failure reporting via ARFThe auth-failure report emitted per DMARC failure.
RFC 7435Opportunistic securityBest-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.

RFCTitle / featureNotes
RFC 3501IMAP4rev1States, mailbox semantics, and the full command set.
RFC 2177IDLEPush notification of mailbox changes.
RFC 6851MOVEAtomic message move.
RFC 3691UNSELECTClose a mailbox without expunging.
RFC 5161ENABLECapability negotiation.
RFC 2342NAMESPACEA single fixed personal namespace.
RFC 4315UIDPLUSUID EXPUNGE, APPENDUID/COPYUID response codes.
RFC 7888Non-synchronizing literals (LITERAL-)Framing lives in the imap-next driver; the capability is advertised here.
RFC 5530IMAP response codesExtended NO/BAD response codes for precise failure signalling.
RFC 4959SASL initial client responseOne-round-trip AUTHENTICATE; SASL-IR is advertised wherever the AUTH= mechanisms are.
RFC 6154SPECIAL-USE mailbox attributesTier 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 2595 / RFC 8314TLS for IMAPLOGINDISABLED until the connection is protected; implicit TLS is the primary deployment.
RFC 9051IMAP4rev2Not advertised. rev1 is the baseline; the rev2 extensions above are advertised individually. ESEARCH, LIST-EXTENDED, and CONDSTORE/QRESYNC are deferred.

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.

RFCTitle / featureNotes
RFC 1939Post Office Protocol version 3The AUTHORIZATION → TRANSACTION → UPDATE state machine, plus APOP (§7).
RFC 2449POP3 extension mechanismCAPA, capability limits, extended response codes.
RFC 2595TLS for POP3STLS with pre-TLS buffer discard.
RFC 5034POP3 SASL authenticationThe AUTH command.
RFC 3206POP3 SYS/AUTH response codesAUTH-RESP-CODE.
RFC 6856POP3 support for UTF-8UTF8, 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.

RFCTitle / featureNotes
RFC 4422SASL frameworkMechanism-neutral hooks shared across SMTP/IMAP/POP.
RFC 4616SASL PLAINThe primary wallet-signature mechanism.
RFC 2195CRAM-MD5Challenge/response for clients without PLAIN-over-TLS.
RFC 4422 §5.1SASL EXTERNALIdentity 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.

RFCTitle / featureNotes
RFC 5322Internet Message FormatHeader and message parsing/generation.
RFC 20452049MIME (parts 1–5)Structure, media types, encodings.
RFC 2047MIME encoded-wordsNon-ASCII header field values.
RFC 2231MIME parameter value extensionsContinuations and charset/language in parameters.
RFC 6531Internationalized email addresses (SMTPUTF8)Validated in addr-spec parsing (email_address).

Anti-abuse and transport hardening

RFCTitle / featureNotes
RFC 5782DNS blocklists (DNSBL)Address-reversal query conventions for the connection blocklist.

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

In the Solana blockchain, an “address” is the public key portion of a 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 duel 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: [email protected]

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. [email protected]), 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 SendMail instruction 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-chain MailDomain account. 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-sithbit service 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 (SPF5/DKIM6) relies on.
  • A domain-less from address must match the signer’s own wallet. If the recipient address has no @domain at 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 SendMail on 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 MailDomain account 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.Com and sithbit.com are 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 for [email protected] also applies to [email protected]. 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 [email protected], you might create the alias myalias, and use [email protected] as your public email address.

Unlike public key addresses, aliases are case-insensitive. So [email protected], [email protected], and [email protected] are all aliases for the same public key address.


  1. A keypair (or “wallet”) in the Solana blockchain is specifically a public key and its associated private key derived from the ED 25519 eliptical curve.

  2. The 32 bytes of the key are base58 encoded to produce the string.

  3. 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 publically 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.

  4. From the terminal: Create a mailbox and Update a mailbox.

  5. For the formal specification, see RFC 7208 (Sender Policy Framework); dmarc.org’s overview explains how SPF, DKIM, and DMARC work together in practice.

  6. For the formal specification, see RFC 6376 (DomainKeys Identified Mail).

  7. For the formal specification, see RFC 7489 (Domain-based Message Authentication, Reporting, and Conformance); dmarc.org also has practical deployment guidance.

  8. 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. The default price should be set very high to prevent SPAM from being sent to the mailbox.
  • 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 spoof guard, not a convenience — address resolution checks the alias registry before treating a string as a raw wallet address, so an unclaimed address string could otherwise be registered as someone else’s alias, silently intercepting mail meant for your wallet. See Aliases for the full explanation from the alias side, including why most users never need to register an alias by hand.

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


  1. From the terminal: Looking up a mailbox.

  2. Set it at create or update time from the terminal: Create a mailbox or Update a mailbox.

  3. From the terminal: Close a mailbox.

  4. Sponsors are typically domain operators scripting provisioning — see Create a mailbox — sponsored creation for the command.

  5. 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 anti-spam lever. A freshly created frombox inherits the recipient’s mailbox’s high default price, 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.

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

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

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.


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

  2. From the terminal: Update a frombox.

  3. From the terminal: Add stamps.

  4. 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. 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 security measure, not just a convenience: address resolution checks the alias registry before falling back to treating the string as a raw wallet address, so if your address string were left unclaimed, a malicious user could register it as their alias and quietly receive mail addressed to your wallet. Claiming the self-alias at mailbox creation closes that spoof. Most users therefore 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

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; ListAliases answers UNAVAILABLE until 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.


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

Domain-scoped aliases

An alias normally lives in one global namespace: whoever registers alice first owns it everywhere, and alice@anything resolves to that one wallet. A domain-scoped alias is a second, separate namespace layered on top of it — it maps local-part @ verified-domain to a wallet, and only that domain’s authority may create entries in it. This lets an organization that controls acme.com on-chain assign [email protected], [email protected], … to its people’s wallets, in a namespace nobody else can write to.

The two namespaces never collide: [email protected], [email protected], and the global alice are three different on-chain accounts — the domain-scoped account is hashed on both the domain and the local part under a seed distinct from the global alias seed, so registering one never touches or shadows the other.

Who can register a domain-scoped address

Only the wallet recorded as the domain’s authority may write into that domain’s namespace. There is no anti-squatting fee the way there is for a global alias — the namespace is scoped to a domain the authority already owns, so squatting isn’t possible in the first place, and the authority pays only the new mapping account’s rent.

Trust note. The domain authority chooses which wallet each user@domain maps to, and can repoint or remove it at any time. This is not a new trust relationship — that same authority already operates the domain’s mail relay and is fully trusted for relayed mail. A domain-scoped address is only ever as trustworthy as the domain that issues it.

Resolution precedence: domain-scoped wins, global is the fallback

Because both namespaces can define an address like [email protected] at once — a domain-scoped mapping and a same-named global alias are unrelated accounts — something has to decide which one wins. The rule is simple and applied consistently everywhere an address is resolved: the domain-scoped mapping is checked first, and only when no domain-scoped entry exists does resolution fall back to the global alias namespace (the historical, domain-blind behavior).

That single precedence rule does triple duty:

  • Native resolution — a client turning [email protected] into a wallet address checks the domain-scoped namespace first, then falls back to the global alias alice.
  • Inbound mail delivery — a SithBit MX server receiving mail addressed to [email protected] over SMTP applies the identical precedence to decide which wallet’s mailbox receives it, so a domain that has never registered any domain-scoped addresses keeps delivering through the plain global-alias path, unchanged.
  • Encryption-key discovery — looking up the public key to seal mail to an address composes the same domain-then-global lookup before returning a key.

The practical effect: existing global aliases keep resolving exactly as before even after a domain starts registering its own scoped names, and a domain only ever affects addresses under its own suffix — it can never shadow or hijack a global alias belonging to someone else’s domain (or none at all).

A domain’s authority creates, repoints, and removes these mappings from the terminal — see Register a domain-scoped alias, which also covers the mechanics of inbound delivery and key discovery.

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.

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. SendMail into 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 (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. SendMail into 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, sithbitd deployment, 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


  1. From the terminal: Looking up a domain inspects any domain’s registration and status. ↩2

  2. See Deactivating a domain for the commands.

  3. See Transferring a domain for the command.

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

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.

Email

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.

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 SendMail instruction — 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:

  1. Send — 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.
  2. Get — 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).
  3. Pin — 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.
  4. Delete — 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


  1. The sithbit CLI drives each of these operations directly from the terminal; each section below links its full command reference.

  2. End users normally never run these commands — an ordinary mail client and its MX server perform the same operations on your behalf.

  3. 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 450 is 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:

  1. 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.
  2. 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.
  3. The page confirms the purchase and tells the sender what happens next: after a 450, waiting is enough; after a 550, 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.

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.

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 sithbit CLI drives the same operations: alias sell/buy, domain sell/buy, and the whole sithbit campaign tree 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.
  • 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 — which means both have a resale market. 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…UseApplies to
Offer a name to whoever pays first, at a set priceThe marketplace’s listing form1aliases + domains
Let buyers bid the price up over a windowAuction (alias sell --auction)aliases
Hand a name to one specific buyer for an agreed feeEscrowed transfer / domain transferaliases + 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.


  1. From the terminal: List for sale (alias sell --price) / domain sell.

  2. From the terminal, alias buy / domain buy take a name that is currently listed.

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.gaming and region.apac — and reach exactly that set.
  • 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 counts the matched set and itemizes the per-recipient cost — message rent, postage, bounty, and fees — so you see the total before a single lamport moves.
  • 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.
  • 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

  1. 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.
  2. Discover. An advertiser runs a trustless search for beacons carrying every tag they want, and gets back each match’s sendable wallet.
  3. Quote. The advertiser prices the matched set, itemized per recipient, before committing funds.
  4. Send. The advertiser sends one bountied message to each match — the loop continues past any single failure so one bad address can’t strand a paid batch.
  5. 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 campaign tree drives both sides — a participant’s create/update/close and an advertiser’s search/quote/send.

What’s public and private

SithBit is email built on a public blockchain, and that shapes its privacy model in a way ordinary email does not. The short version: SithBit puts the envelope on the chain — public and permanent — and keeps the letter sealed. 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

PublicPrivate
On-chainyour wallet address (= your email address), account settings, who-mailed-whom + when, stamp and marketplace prices, SOL balances and transfersnothing — the chain is a public ledger
Off-chainthe sealed body’s ciphertext on IPFS (fetchable by anyone with the CID); the SMTP envelope your relay seesthe 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_ipfs preference, 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: the sender wallet, a blake3 hash of the from address (never the string), a timestamp, and the body’s storage locator. 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.

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_ipfs opt-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.

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.
  • 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:

  1. 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.
  2. 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_ipfs is the lever if you don’t want to make that bet.
  3. Your purchases are visible. Marketplace buys tie your wallet to the names you acquire, publicly.

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


  1. “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.

GUI clients

Not everyone lives at a terminal. Alongside the sithbit CLI, SithBit ships four graphical clients — the Thunderbird extension, the Outlook add-in, the webmail app, and the Chrome 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:

The three extension pages each open with an Installing from the store section (Thunderbird, Chrome, Outlook) 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. Everything else these clients do is the account-management and onboarding surface, not a replacement for your mail app.

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

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:

  1. Resolves each recipient to a wallet — a raw wallet address, or a alias — using only a public Solana RPC endpoint.

  2. Looks up the recipient’s published encryption key (or falls back to sealing straight to their wallet address).

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

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 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 [email protected] 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, a global alias, or a domain-scoped user@verified-domain address. A verified domain’s authority can map user@its-domain to a wallet, and that mapping takes precedence over the global namespace — so [email protected] reaches the wallet acme.com’s authority designated, while a bare alice (or an alice@… with no domain-scoped mapping) still resolves through the global namespace. See Domain-scoped aliases. 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 only — for now. The sealed payload is a structured envelope that can carry rich HTML and attachments alongside the text body as a single sealed unit. The current Thunderbird and Outlook plugins, however, still hand it only the plaintext body — reading the composed HTML and attachments out of the host mail client is a planned addition, and HTML/attachment support in the plugins arrives with it.

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 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 the operator must run

The extension talks only to account-api. For the aliases, balances, and encryption-key panes, the API needs its [chain] section configured (a mail-grpc gateway plus a Solana RPC endpoint — see the configuration reference); without it those panes report the surface as unavailable while login and the settings panes keep working.

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

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

  • 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).
  • 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 -x reads; 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 both its rent and the transient request record’s to your wallet. 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 @domain is dropped), but you still supply a mail password. Get it either from the self-serve enrollment page the operator hosts (/addin/enroll.html on 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 -k ~/.config/solana/id.json
    

    which prints the pair offline (no RPC, no on-chain write).

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.

  1. Mint the certificate — offline, no RPC:

    sithbit mailbox create-cert -k ~/.config/solana/id.json --out sithbit
    

    This writes sithbit.p12 — the combined, password-less PKCS#12 bundle, the one file Thunderbird imports — alongside sithbit.crt (the certificate) and sithbit.key (its PKCS#8 private key) as a PEM pair for other apps. Omit --out to 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 .p12 and .key files embed your wallet secret; guard them like the wallet itself.

  2. Install it in Thunderbird. Import sithbit.p12 in one step under Settings → Privacy & Security → Manage Certificates → Your Certificates → Import — the bundle is password-less, so leave the password prompt blank. Then, on each of the account’s SMTP (outgoing), IMAP, and POP server entries, set the connection security to SSL/TLS, the authentication method to Encrypted certificate, and select this certificate.

  3. 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 signed-in view carries a Certificate sign-in section directly below the panes, 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 one Thunderbird imports in one step, exactly as 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 -x reads.
  • 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 set keep_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-to makes (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.

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.

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, Outlook on the web, and classic Windows Outlook (Microsoft 365, version 2307+). Not Outlook for Mac — the unified JSON manifest doesn’t run there; a legacy XML manifest is the recorded fallback if Mac coverage becomes a need.

What the operator must run

Office add-ins are https-hosted web pages, so the operator serves the built bundle from account-api’s [static] section — 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). As with Thunderbird, the aliases/balances/keys panes need [chain] configured; login and the settings panes work without it.

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

cd webclients/outlook
./build.sh    # wasm-pack build + stages shared/ into staging/ + packages sithbit-outlook.zip

Point account-api at the bundle:

[static]
root = "webclients/outlook/staging"   # served under /addin

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. 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 is identical to Thunderbird’s:

  • 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).
  • 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 and switches the taskpane to the settings view (a plain view switch — the taskpane keeps no history); 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 both its rent and the transient request record’s. 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 @domain is not leaked), but you still supply a mail password. Get it from the operator’s self-serve enrollment page (/addin/enroll.html on 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 -k ~/.config/solana/id.json

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

  1. Mint the certificate — offline, no RPC:

    sithbit mailbox create-cert -k ~/.config/solana/id.json --out sithbit
    

    This writes sithbit.p12 — the combined, password-less PKCS#12 bundle your certificate store imports in one step — alongside sithbit.crt and its PKCS#8 key sithbit.key as a PEM pair for other apps (omit --out to print just the PEM blocks to stdout). The certificate is a self-signed Ed25519 leaf whose public key is your wallet address — no CA, no expiry to manage. The .p12 and .key embed your wallet secret; guard them like the wallet itself.

  2. 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.p12 and 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.

  3. 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 below the settings panes, 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) in one step. 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-to makes (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 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.

What the operator must run

The app is served same-origin from account-api’s [static] section, so the API base URL is simply the page’s own origin and no CORS setup exists at all. 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 and user@your-domain addresses 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.

As with the other clients, the aliases/balances/keys settings panes also need [chain]; login, mail reading, and the account settings work without it.

Building and serving

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

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 and #/settings views below.
  • (a brief loading view while it probes.)

Because the webmail bundle is served same-origin from account-api, it needs no endpoint configuration at all: the API base URL is simply the page’s own origin. The onboarding create relays through that same API, so its on-chain reach follows the API’s [chain] config exactly as the compose path does above.

Using it

The #/mail view is three panes. The folder rail lists your folders with unseen counts; 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 #/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 both rents return only when you come back after the wait and finish the close. 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, 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.

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:

  1. Resolve the recipient — a wallet address or alias — and their published encryption key from the chain.
  2. Check their mailbox. A mailbox with the no_ipfs opt-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.
  3. 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.
  4. 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 SendMail transaction 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. 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 the operator must run

For the aliases, balances, and encryption-key panes, the account-api needs its [chain] section configured (a mail-grpc gateway plus a Solana RPC endpoint — see the configuration reference); without it those panes report the surface as unavailable while login and the settings panes keep working.

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

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

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 at webclients/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: Balances (wallet SOL, on-chain message count, per-sender stamp lookup), 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 both rents come back only when you return after the wait and finish the close. 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/mail surface. 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 above 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-to makes (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 (default http://127.0.0.1:8180).
  • rpcUrl — the Solana JSON-RPC endpoint for trustless viewing (default a local surfpool at http://127.0.0.1:8899).
  • gatewayUrl — the IPFS gateway serving GET /ipfs/{cid} (default http://127.0.0.1:8183 — e.g. a sithbit-gateway).
  • ipfsPinUrl — the IPFS pin service that receives the sealed body on a trustless send (default http://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 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.

The marketplace ships two ways, both from the same shared core (webclients/shared/marketplace-panes.js):

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/listings and /v1/chain/sales account-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 *_unsigned builder, 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/sales the 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. Each row shows a participant’s wallet address, its self-attested tags, 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.

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.

Building and serving the standalone page

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"

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

CSP note. The standalone page bridges the wire transaction to the format the external wallet signs with the vendored codec-only @solana/kit bundle (shared/lib/kit-codec.esm.js) — served from the same origin as the page, so account-api’s script-src needs no extra origins.

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.

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.

  1. 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 create writes — 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.
  2. Claim your handle. Optionally register a human-readable aliasyourhandle — so senders can mail [email protected] instead 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).

  3. 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: a high default prices out strangers, and you lower it per sender for people you want to hear from (see Economics for how postage and prepaid stamps work). The default matches the CLI’s — one SOL. An optional checkbox keeps your message bodies off public IPFS (local storage only) from the start.

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

  5. 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 its [static] root (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.0 out of stamps / 550 5.7.0 no 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.
  • 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 its [static] root, like the get-started page above — needs no setting at all.

Economics

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:

  1. Buying (AddStamps / CreateFrombox): anyone may fund the frombox for a (from, recipient) pair. Each stamp costs the frombox’s required_postage plus 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 inherits required_postage from the recipient mailbox’s default_postage; only the recipient may change it afterwards (UpdateFrombox requires 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.
  2. 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).
  3. 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 — default OPERATOR_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 postageLamports below 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)

PaysCollects
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-listThat 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; 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 is stamp_fee_lamports from the postoffice account (default POSTOFFICE_STAMP_FEE_LAMPORTS = 100 000 = 0.0001 SOL) per stamp; the bps arm is stamp_fee_bps (default DEFAULT_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_bps of 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 at MAX_POSTOFFICE_STAMP_FEE_LAMPORTS (1 000 000), and the bps arm with SetSettlementBps (sithbit postmaster fee settlement <OPERATOR_SHARE_BPS> <STAMP_FEE_BPS>), capped at MAX_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 ungated sithbit postoffice fee stamp (both arms) or sithbit 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 is operator_share_bps (default OPERATOR_SHARE_BPS = 1 000 bps = 10%), the same delegate-tunable rate the DeleteMail settlement uses for the domain operator’s share — retuned with SetSettlementBps up to MAX_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_LAMPORTS lever 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 is operator_share_bps (default OPERATOR_SHARE_BPS = 1 000 bps = 10%), the same delegate-tunable rate the DeleteMail settlement, the escrowed alias transfer, and the reply-bounty claim use — retuned with SetSettlementBps up to MAX_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 no DOMAIN_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: close and the transfer paths (alias transfer init, the delegate’s domain 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 prices out spam from senders the recipient has never met. But “never met” describes two very different wallets — a spammer’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 spendFirst-contact rateAt the 1-SOL default postage
below 0.1 SOL10,000 bps (full price)1 SOL
from 0.1 SOL7,500 bps0.75 SOL
from 1 SOL5,000 bps0.5 SOL
from 10 SOL2,500 bps0.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_bps of the recipient’s default — DEFAULT_REPUTATION_FLOOR_BPS = 1,000 bps (10%) by default, delegate-tunable with SetReputationFloor (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 102 ReputationFloorAboveCap, 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 fires DeleteMail after_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 = false unpins it), so a past-window settled message is only trustlessly fetchable from the decentralized copy if keep_pin was 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 with ReclaimFromboxStamps, leaving the account alive on its rent), the two-step mailbox close and CloseKey (owner reclaims rent — the mailbox’s only at FinalizeCloseMailbox, 7 days after the request; the key account’s on the spot), CloseAlias (holder reclaims rent), CloseDomain (rent back to the recorded payer), and WithdrawPostoffice (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 via DeleteMail; 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_BPS is not applied and the domain authority collects nothing;
  • the recipient collects nothing — the postage they would have earned on a DeleteMail is 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 — 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 --bounty folds 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 DeleteMail settlement 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 (where DeleteMail’s unresolved share folds into the recipient’s postage, a claim’s goes to the postoffice). As in DeleteMail, 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 is operator_share_bps (default OPERATOR_SHARE_BPS = 1 000 bps = 10%), the same delegate-tunable rate the DeleteMail settlement 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-bounty returns the whole bounty to the sender. Like RefundMail above, 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_hash names 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 frontsWhich flow above
message account rent (≈, refundable)the stamp lifecycle — parked on the message account
a new frombox’s rent (≈, refundable)one frombox per (campaign wallet, recipient) pair
postagethe recipient’s price — settles to the recipient on DeleteMail
the escrowed reply bountyfolded into the message account, three exits
one SIGNATURE_FEE_LAMPORTS + one STAMP_FEE_SURCHARGE_LAMPORTSthe base tx fee and the two-signature settlement prefund

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:

  1. 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.
  2. 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.
  3. 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.

Constants worth knowing

Protocol economics are consensus constants in mail_model::constants. Four are fixed and never change without a program upgrade:

ConstantValueUsed by
SIGNATURE_FEE_LAMPORTS5 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_LAMPORTS10 000 (2 signatures)prefunds the SendMail + DeleteMail signature refunds; charged per stamp at AddStamps/CreateFrombox
DEFAULT_POSTAGE_LAMPORTS1 000 000 000 (1 SOL)the spam-pricing floor CreateMailbox applies when the instruction omits a price
POSTAGE_ROUNDING_LAMPORTS1 000the 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 valueHard capSet withCharged by
POSTOFFICE_STAMP_FEE_LAMPORTS100 000 (0.0001 SOL)MAX_POSTOFFICE_STAMP_FEE_LAMPORTS = 1 000 000SetStampFeeAddStamps/CreateFrombox, third-party purchases only — the flat arm of the hybrid per-stamp fee
DEFAULT_STAMP_FEE_BPS100 (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_BPS1 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_LAMPORTS10 000 000 (0.01 SOL)MAX_DOMAIN_AUTHORIZATION_FEE_LAMPORTSSetDomainFeeCreateDomain and AuthorizeDomainByProof — both paths cost the same
DEFAULT_SENDER_ATTESTATION_FEE_LAMPORTS10 000 000 (0.01 SOL)MAX_SENDER_ATTESTATION_FEE_LAMPORTS = 100 000 000SetSenderAttestationFee (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_LAMPORTS10 000 000 (0.01 SOL)MAX_ALIAS_FEE_LAMPORTSSetAliasFeeCreateAlias, names of 5+ characters
ALIAS_TRANSFER_FEE_LAMPORTS1 000 000 (0.001 SOL)MAX_ALIAS_TRANSFER_FEE_LAMPORTSSetAliasFee (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_LAMPORTS10 / 1 / 0.1 / 0.05 SOL for 1/2/3/4-character namesMAX_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_BPS1 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_LAMPORTS1 000 000 (0.001 SOL)MAX_PIN_LEASE_FEE_LAMPORTS = 10 000 000SetPinLeaseFee (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_LAMPORTS10 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 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 economicsCreateDomain takes an explicit payer (delegate or authority; the delegate always signs), charges DOMAIN_AUTHORIZATION_FEE_LAMPORTS to the postoffice, and records the rent_payer so CloseDomain can refund the rent to whoever funded it.
  • Close instructionsCloseFrombox (recipient reclaims residue + rent) and its sender-side counterpart ReclaimFromboxStamps (a wallet-address sender withdraws its own unspent postage; the account survives on its rent), RequestCloseMailbox/FinalizeCloseMailbox and CloseKey (owner reclaims rent; see the close timelock below), CloseAlias (holder reclaims rent), and WithdrawPostoffice (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_LAMPORTS charge on alias creation (and an ALIAS_TRANSFER_FEE_LAMPORTS charge 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 exit) 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.


  1. 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., [email protected], 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. [email protected], 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.


  1. If you already have the Solana CLI installed, solana config set --url <cluster> writes the same config file.

  2. If you already have the Solana CLI/SDK installed, solana-keygen new followed by solana-keygen pubkey gets 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:

  1. RPC endpoint. Shows the currently configured endpoint. Enter a new URL to switch clusters (this writes the same config sithbit config set --url edits), 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 of mail_client/src/commands/setup.rs, so an operator shipping a hosted RPC endpoint swaps them in one place.)
  2. 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 create writes). It asks first, so a re-run never overwrites an existing wallet; decline and it prints the manual sithbit wallet create command instead.
  3. 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 skip to move on unfunded. (The devnet web faucet at faucet.solana.com is the manual fallback when the RPC faucet errors.)
  4. Mailbox. Offers to claim your on-chain mailbox right now, defaulting to skip (only an explicit y/yes proceeds). Accept and it prompts for the mail domain (default sithbit.com) and the default stamp price in lamports (default one SOL), then drives the same on-chain create as sithbit 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 manual sithbit mailbox create command instead.
  5. 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, so sithbit mailbox get with 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.

  • 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 to sithbit.com when 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.

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 authority of the --domain domain; anyone else is refused (UnauthorizedDomainSponsor, error 96). Omitting --domain is refused client-side, and a raw instruction without a domain fails on-chain (SponsoredMailboxRequiresDomain, error 95).
  • Any --default-postage you 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>
  • 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) — true opts the mailbox out of public IPFS body storage, false clears 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

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.

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 by mailbox 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>

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 sithbit mailbox credentials command.

sithbit mailbox credentials \
  [-k, --keypair <path>]

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 fixed challenge that embeds that same public key, so a captured password cannot be replayed as another wallet. The servers verify the signature against the username, and nothing is stored server-side — the signature is self-proving.

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 prints the identical pair every time — paste it into a mail app once and you are done.

Arguments

  • -k, --keypair <path> (optional) — the wallet keypair file the credentials are derived from. Defaults to the keypair in your Solana CLI config when omitted.

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.

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 -k ~/.config/solana/id.json
  • 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.

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 \
  [-k, --keypair <path>] \
  [-o, --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 offer EXTERNAL on the listener (the client_cert_auth setting — see the configuration reference); your wallet address stays the username.

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

  • -k, --keypair <path> (optional) — the wallet keypair file the certificate is derived from. Defaults to the keypair in your Solana CLI config when omitted.
  • -o, --out <prefix> (optional) — 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 .p12 is 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 a mail app imports in a single step.

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
  • 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). 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)--finalize or --cancel was run with nothing pending.
  • MailboxCloseTimelockNotElapsed (error 93)--finalize was run before the 7 days elapsed.
  • InstantCloseDisabled (error 94) — the retired one-step CloseMailbox instruction was submitted.
  • --finalize and --cancel cannot be combined.

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, an alias@domain / wallet@domain email address, or a path to a .json keypair 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 .json keypair 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 [email protected]:

sithbit frombox get [email protected]

Inspect the frombox from [email protected] to a specific recipient alias, rather than your own wallet:

sithbit frombox get [email protected] [email protected]

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 (1000000000 lamports) 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 [email protected] 100000000
Update frombox address 7XkQ…Qp9 for <From:[email protected] 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 [email protected]

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 to 1.
  • --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 [email protected] --stamps 10
Purchasing 10 stamps for frombox 7XkQ…Qp9 <From:[email protected] 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-price removes 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.

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 (10000 lamports) that reimburses the sender’s later SendMail and DeleteMail signatures.

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.

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 ([email protected]) 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 -k ~/.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.

  • 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 create already registers your wallet’s own address as a self-alias automatically — a spoof guard, since an unclaimed address string could otherwise be registered as someone else’s alias and intercept your mail (see Aliases). Use alias create for 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 spoof guard 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_Doe and john_doe resolve identically.

See Closing accounts for how to close an alias and reclaim its rent.

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 TransferAlias instruction 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 -k 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 close refuses 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 -k 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 stderrMeaning
A listing's price must be positive; zero-price hand-offs use the transfer pathsStage 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 windowCancel or replace attempted within the first 5 minutes.
No listing is open for this aliasBuy or cancel on an alias with no staged listing.
The listing has expiredBuy attempted after the expiry instant; the holder can reclaim the rent with sell --cancel.
The alias has a pending transfer offer; cancel it before closingListing refused; cancel the escrowed offer first.
The alias has an open listing; cancel it before closing or transferringalias close or alias transfer init (any fee) refused while listed; cancel first.
The listing holder no longer owns the listed nameBuy refused: the listing predates a change of holder, so its recorded seller is no longer the wallet the alias resolves to.

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.
  • Omitting both defaults the auction to run 7 days.

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 + 7d cap 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 (--reserve at 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 stderrMeaning
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 reservealias bid below the reserve on an auction with no bids yet.
The bid does not clear the minimum incrementalias bid at or below high_bid + max(5%, 1_000_000).
The auction has not ended yetsettle-auction before the end instant.
This alias is being auctioned; bid instead of buyingalias buy attempted on an auction listing.
An auction with bids cannot be cancelledalias sell --cancel (or a replacement) after the first bid landed.
No auction is open for this aliasbid or settle-auction on an alias with no open auction.

Domain-scoped aliases

See Domain-scoped aliases for the namespace model, the domain-then-global resolution precedence, and who is trusted to register into a domain’s namespace. This page covers the sithbit alias register-domain / update-domain / remove-domain commands, inbound delivery, and the public encryption-key keyserver.

Register, repoint, remove

All three are signed by the domain authority’s keypair (-k), and take a local-part@domain address:

# Map [email protected] to a wallet
sithbit alias register-domain [email protected] --wallet <WALLET_PUBKEY> -k authority.json

# Repoint it to a new wallet in place (keeps the account and its rent)
sithbit alias update-domain [email protected] --wallet <NEW_WALLET_PUBKEY> -k authority.json

# Remove it, refunding the rent to the authority
sithbit alias remove-domain [email protected] -k authority.json

The --wallet the address points at need not be the signer — the authority designates it. The local part is normalized to lowercase and must use the same a-z0-9._- character set as a global alias.

Resolving a domain-scoped address

sithbit alias get resolves a domain-scoped mapping first, then falls back to the global namespace when none exists — see Resolution precedence:

sithbit alias get [email protected]
  • If acme.com registered alice, this returns that wallet.
  • Otherwise it falls back to the global alias alice, so existing global aliases keep resolving unchanged.

The same precedence drives the lockbox plugins: mail addressed to [email protected] seals to the domain-scoped wallet when one is registered.

Receiving mail at a domain-scoped address

Registering [email protected] and resolving it from a native client is only half of an email address; the other half is that internet mail addressed to it actually arrives. A verified domain’s user@domain addresses now do — a SithBit MX accepts an inbound RCPT TO:<[email protected]> and delivers it to the wallet the authority mapped. This is the delivery counterpart of native resolution: the same mapping that a client reads to seal mail is now the one the server reads to receive it.

Resolution runs on the same precedence as alias get above, but starting from the SMTP envelope rather than a CLI argument:

  1. The MX takes the recipient’s domain from the RCPT address and lowercases it, so it matches the on-chain PDA seed exactly (the domain-scoped account is hashed on the domain and the local part).
  2. It asks the chain gateway to resolve the local part within that domain — the domain-scoped DomainAlias lookup.
  3. If a mapping exists, delivery targets that wallet’s mailbox. If none does, resolution falls back to the global alias namespace, exactly as alias get does — so a bare alice global alias still receives, and an empty domain is the legacy global path. Nothing about pre-existing global-alias delivery changes.

Because the authority alone writes these mappings, it decides which wallet receives mail for each local-part it issues — the same trust already placed in it for relayed mail, now reaching inbound delivery as well as resolution.

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/[email protected]

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, a global alias, or a domain-scoped user@domain — and it resolves that identity to a wallet (composing the same domain-then-global precedence as resolving a domain-scoped address 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.

See Closing accounts for the general rent-refund model that remove-domain follows.

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, and domain deactivate require the CLI’s domain feature (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-sithbit service 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, AuthorizeDomainByProof walks 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 the domain_program test 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 transaction packet, it is staged into a program-owned buffer PDA first via chunked WriteProofWitness instructions, and the buffer is closed and its rent refunded afterward with CloseProofWitness (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:

  1. 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 chunked WriteProofWitness instructions (discriminant 27). Each carries an offset and a chunk of up to MAX_PROOF_WITNESS_CHUNK (900) bytes; the first write allocates the buffer to its declared total_len, and later writes fill it in until written_len == total_len.
  2. Authorize. A single AuthorizeDomainByProof transaction 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 a ComputeBudget set-compute-unit-limit instruction.
  3. 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 successful domain authorize; for a failed or abandoned attempt, run domain authorize --close-witness yourself.

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_exp syscall. 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 Secp256r1SigVerify and Ed25519SigVerify precompiles. So for each non-RSA RRSIG in the chain, the transaction includes one matching precompile instruction and passes the Instructions sysvar as an optional 6th account to AuthorizeDomainByProof. 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 iana prints — E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D for 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) and unbound (whose unbound-anchor ships a built-in copy of ICANN’s CA / the 2017 KSK to bootstrap root.key) reach you via APT/DNF’s GPG-signed repositories — a genuinely independent, cryptographically-verified channel. Point openssl -CAfile at unbound’s bundled cert, or just compare digests with unbound-anchor -v.
  • Fetch the CA over multiple independent network paths and compare. Download icannbundle.pem from 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 set is a delegate-only governance action (error 66, NotDelegate, for any other signer), gated behind the CLI’s postmaster feature. 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 optional 0x prefix 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-witness links a DNS resolver stack, so — like discover — it is gated behind the CLI’s opt-in gather feature and is absent from a stock sithbit binary. Build one that has it with cargo 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:

  1. 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.
  2. 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.
  3. 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-chain domain-sithbit flow 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:

  1. It stages the witness automatically, splitting it into ≤900-byte chunks and writing each with a confirmed WriteProofWitness transaction, printing Staged witness chunk N/N as it goes (the buffer protocol is How it works).
  2. It derives any precompile instructions the chain needs — for each non-RSA (ECDSA-P256 or Ed25519) RRSIG it re-walks the witness client-side (the same program_common chain walk the program runs, with a collecting verifier plugged into the same seam the on-chain precompile introspection uses) and builds one self-contained Secp256r1SigVerify / Ed25519SigVerify instruction 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.
  3. It then submits AuthorizeDomainByProof with a ComputeBudget set-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.
  4. On a successful authorization it closes the spent witness buffer automatically with CloseProofWitness, refunding the buffer’s rent to the payer and printing Closed 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 with domain 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-13 RRSIG whose 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 by Secp256r1SigVerify precompile 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 the ComputeBudget instruction 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:

  1. Request. domain reclaim stages 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). The MailDomain account itself is untouched — its authority does not change yet. The presence of that pending PDA is the “reclaim pending” flag.
  2. Wait. A 7-day clock runs (RECLAIM_TIMELOCK_SECS). During it the domain keeps its current authority and mail keeps flowing.
  3. Finalize. Once the clock elapses, domain reclaim --finalize installs 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.

  • 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 -k 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.

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 SendMail for it changes.
  • Because rent_payer is unchanged, a later domain close still 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 --keypair isn’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 transfer changes who administers one domain; installing a new postmaster or repointing the standing delegate changes who administers every domain in the deployment.

See also

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 sell for 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, only sell --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 stderrMeaning
A listing's price must be positive; zero-price hand-offs use the transfer pathsA 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 windowCancel or replace attempted within the first 5 minutes.
No listing is open for this domainBuy or cancel on a domain with no staged listing.
The listing has expiredBuy attempted after the expiry instant; the authority can reclaim the rent with sell --cancel.
A deactivation is already pending for this domainListing or buying refused while the deactivation timelock is in flight.
The domain has an open listing; cancel it before closing, transferring, or deactivatingdomain close, domain transfer, or domain deactivate refused while listed; cancel first.
The listing holder no longer owns the listed nameBuy 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.
  • 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 --keypair isn’t the postoffice’s recorded standing delegate. All four modes are delegate-only.
  • Finalizing before the 7-day timelock has elapsed is rejected on-chain.
  • --finalize and --cancel cannot be combined.

Note: when a domain is retired for good rather than temporarily suspended, see Closing accounts for domain close instead.

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) — the From: 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 --cid or --path is 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: --path only 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 own sithbitd, or a shared sithbit-ipfsd). --cid is 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 [email protected] \
  --from [email protected] \
  --path ./message.eml

Send a body you have already pinned, referencing it by CID:

sithbit mail send [email protected] \
  --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:[email protected] To:[email protected]>
https://explorer.solana.com/tx/…

Before you send

Two preconditions must hold or the transaction is rejected on-chain:

  • 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_ipfs opt-out set, mail send refuses before touching the CID: this command pins the body client-side with no operator store behind it, so 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.

  • 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>.eml under 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 to https://ipfs.sithbit.com/ipfs/; point it at your own node or a sithbit-gateway to avoid a third party.

Note: the decryption flags (--keypair, --delegated-key-path) require the CLI’s rand feature (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 [email protected] \
  --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.

  • 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

For the concept behind pinning — 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 (default http://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 (default https://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 (default https://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.

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, matching mail 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 pin to 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 by sithbit 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 [email protected] --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:

  1. Sender refund. The sender recovers the message account’s rent plus the fee for their original SendMail signature (both were prefunded by the stamp surcharge the sender paid at purchase).
  2. Deleter refund. The signer submitting this delete recovers this transaction’s signature fee — also prefunded — so settling costs the deleter nothing net.
  3. 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.
  4. 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.
  5. 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. Use delete to accept and settle; use refund to 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 [email protected] \
  --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 [email protected] \
  --path ./answer.eml \
  --reply-to <message account address>
  • --reply-to takes the bountied message’s account address — the base58 address sithbit mail get prints 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-to works 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 id mail send printed).
  • <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.
  • 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 it.

Two roles use this tree:

  • A participant runs create, update and close to opt in, edit and opt out. Opting in is setting a mailbox price: the advertised participation price is the wallet’s mailbox default_postage, so a beacon can only be published for a wallet that already has a mailbox.
  • A campaign wallet runs search, quote and send to reach the pool. send is 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>] [-k <KEY>]
sithbit campaign update  [--tag <TAG>]… [--detail-cid <CID> | --profile-file <PATH>] [-k <KEY>]
sithbit campaign close   [-k <KEY>]
sithbit campaign search  --tag <TAG>… [--limit <N>]
sithbit campaign quote   --tag <TAG>… --bounty <LAMPORTS> [--postage <LAMPORTS>] [--limit <N>]
sithbit campaign send    --tag <TAG>… --subject <TEXT> --bounty <LAMPORTS> \
                         [--body-file <PATH>] [--limit <N>] [-y] [-k <KEY>]

Tags

Every beacon carries a fixed-vocabulary tag bitmap. A --tag is one name from that vocabulary — an interest.*, skill.*, age.* or region.* name, e.g. interest.technology, skill.software, age.25_34, region.apac. Names are case-insensitive; an unknown name is rejected with the full valid list. --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 from the mail_model TAG_* constants; 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’s rand feature. See --ipfs-endpoint / --ipfs-token below.
  • --ipfs-endpoint <URL> — the sithbit-ipfsd endpoint a --profile-file is pinned to (default http://127.0.0.1:8182).
  • --ipfs-token <TOKEN> — bearer token for that daemon, when it requires one.
  • -k, --keypair <KEY_PATH> — the wallet publishing the beacon (defaults to the Solana CLI keypair).
  • -s, --skip-preflight — 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: -k, --keypair <KEY_PATH>, -s, --skip-preflight.

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 before committing funds. It counts the beacons matching the tag filter (the same trustless scan search runs) and multiplies by the per-recipient cost, itemized so you see where the money goes. The per-recipient figure mirrors the on-chain SendMail funding term for term: the message account rent, a new frombox’s rent, the prepaid postage, the escrowed bounty, one signature fee, and the per-stamp settlement surcharge.

sithbit campaign quote --tag interest.gaming --bounty 1000000
Campaign quote for 3 recipient(s):
  message rent              1670160 lamports
  frombox rent              1224960 lamports
  postage                1000000000 lamports
  bounty                    1000000 lamports
  signature fee                5000 lamports
  stamp surcharge             10000 lamports
  per recipient          1003915120 lamports
  total                  3011745360 lamports (3.01174536 SOL)

Flags:

  • --tag <TAG> — recipient tag filter (repeatable, required, logical AND).
  • --bounty <LAMPORTS> — the per-recipient bounty being offered.
  • --postage <LAMPORTS> — the per-recipient postage the quote assumes; defaults to the protocol default a mailbox charges unknown senders. Lower it toward the price you expect recipients’ fromboxes to actually charge.
  • --limit <N> — cap the quote at this many recipients (default: the full match set).

sithbit campaign send

Sends one bountied message to every recipient matching the tag filter. It selects recipients by the same trustless scan, prints the quote, gates on a confirmation (unless -y), 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.

sithbit campaign send \
  --tag interest.gaming \
  --subject 'Paid gaming survey' \
  --bounty 1000000 \
  --body-file ./invite.txt \
  --yes
Campaign quote for 3 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).
  • -y, --yes — skip the quote-then-confirm prompt and send immediately.
  • -k, --keypair <KEY_PATH> — the sending (and funding) wallet.
  • -s, --skip-preflight — 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, or none prepaid when 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 one Prepaid 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 [email protected]
Earnings summary for 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q0
  Wallet balance: 1.5 SOL (~$210.00)
  Mailbox asking price: 0.5 SOL (~$70.00) per email
  Prepaid from [email protected]: 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 [email protected]
...
  Prepaid from [email protected]: none prepaid

For the wider picture of how postage and prepaid stamps price out spam, 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.

CommandSignerWhat’s reclaimed
mailbox closeMailbox ownerThe mailbox account’s rent — only at --finalize, 7 days after the request (see below)
mailbox key closeMailbox ownerThe delegated encryption key account’s rent
frombox closeRecipient (mailbox owner)The frombox’s rent plus its remaining stamp value
frombox reclaimWallet-literal senderThe sender’s own unspent prepaid postage — not the rent, which keeps the frombox alive
alias closeCurrent alias holderThe alias account’s rent (refused while a transfer offer is pending — cancel the offer first)
domain closeDelegateThe 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:

  1. sithbit mailbox close requests 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.
  2. 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.
  3. 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 create command. 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 by mail 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

A SithBit deployment is at most six services, all built from this workspace:

ServiceBinaryRoleNeeded when
sithbitdmail-spoolerSMTP MX + submission, IMAP, POP, the spooler workers — and optionally the embedded IPFS node — in one processalways (the embedded IPFS node only with [ipfs] kind = "embedded" — a fleet points at sithbit-ipfsd or a pinning service instead)
account-apiaccount-apiwallet-challenge login → JWT; mail passwords, timezone, DND schedulesusers manage their accounts
mail-grpcmail-grpcgRPC gateway to the Solana programs (postage checks, SendMail, aliases)mail should reach the chain
domain-sithbitdomain-sithbitDNS-based domain verification and on-chain domain authorizationyou operate the domain registry
sithbit-ipfsdsithbit-ipfsdthe self-hosted IPFS node as a standalone daemon: HTTP pin API + optional public swarma fleet shares one node via [ipfs] kind = "remote" (a single sithbitd can embed the node instead)
sithbit-gatewaysithbit-gatewayread-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, or pop-server binaries 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, or cloudflare. 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 — postgres against Cloud SQL is the GCP shape; see Hosting on Google Cloud.)
  • Mail bodies pin through a blob store that is either local disk 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 shared sithbit-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 plain cargo build compiles 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:

Measurementdefault (sqlite)--features all
mail-store dep-tree crates (484 before the split)268485
mail-spooler dep-tree crates (812 before the split)655essentially the pre-split tree
sithbitd binary, --profile server (stripped by the profile)24.1 MB43.2 MB
sithbitd server-profile rebuild, warm dep cache¹2 m 59 s5 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 exec into a running service is impossible; use docker logs, docker cp, and the monitoring surfaces instead. When copying a live SQLite database out with docker cp, take the -wal and -shm sidecar 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: host deliberately: a loopback-bound surfpool is not reachable through Docker’s host-gateway from a bridge network, so mail-grpc shares the host network and serves on 127.0.0.1:50051 exactly like a native run.
  • Configuration rides MAIL_GRPC_* env overrides over the gateway’s in-code defaults (configuration) — the dev stack mounts no mail_grpc.toml. Two host-side variables feed them: SITHBIT_CHAIN_RPC points the profile at a remote cluster instead of the host surfpool, and SITHBIT_CHAIN_KEYPAIR names 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 key mail_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.

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 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 gcp store kind: [store] kind = "postgres" carries all three in one database, and a Cloud SQL Postgres instance is a plain url away. 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 with secretAccessor on 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 sithbitd on 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 gsm key source delivering the fee-payer keypair.

The iac/gcp Terraform module provisions this footprint — the GCS bucket + HMAC pair always; Cloud SQL and the Cloud Run mail-grpc unit 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 issues CREATE 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 same kind = "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:

  1. Build features. A default cargo build compiles only the SQLite backend; a source build of this recipe needs --features postgres,s3-blobs (or all) on the store-consuming binaries — see Slim-build features. The published GHCR images build all, so container deployments skip the concern entirely.
  2. 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-hook that restarts sithbitd, or renewals will sit unused on disk while the listeners keep serving the old certificate.
  3. 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 upcredential.key, the JWT key, and any DKIM key are the unrecoverable pieces (Monitoring and backups).
  4. 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. 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.

  1. 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.
  2. 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).
  3. 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.
  4. 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).
  5. 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.
  6. 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.toml and account_api/account_api.toml; containers find the file via SITHBITD_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-key description tags carry the same text), plus the store-creation and import runbooks in iac/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 to 0.0.0.0 in 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 and sithbitd.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 /certs directory (each a file or Key Vault key source), and require_tls is 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 the aws/azure store and work through the Scaling out checklist.
  • The store volume is precious — it holds sithbit.db, credential.key, jwt.key, and the blob directory. Back it up; see Monitoring and backups for what is unrecoverable. The alias-index volume 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 (keypair in mail_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 via SITHBIT_CHAIN_KEYPAIR, and the production example mounts mail_grpc.toml plus a separate read-only keypair file — or drops that mount entirely for the secret-manager forms. (The non-secret rest of mail_grpc.toml can likewise arrive from a cloud app-config source instead of a mount — carrying the key source’s coordinates, never the key.) The domain-sithbit delegate keypair authorizes domains on-chain — an operational hot key that can never sweep postoffice funds, rotated on a schedule via postmaster 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.

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.toml
  • account_api/account_api.toml
  • domain_sithbit/domain_sithbit.example.toml
  • ipfs_daemon/sithbit_ipfsd.example.toml
  • ipfs_gateway/ipfs_gateway.example.toml
  • mail_grpc/mail_grpc.example.toml

How a setting resolves

The TOML-config binaries (sithbitd, account-api, domain-sithbit, mail-grpc) layer each setting, lowest to highest precedence:

  1. the in-code default,
  2. the TOML file (sithbitd.toml / account_api.toml / domain_sithbit.toml / mail_grpc.toml in the working directory, or the path in SITHBITD_CONFIG / ACCOUNT_API_CONFIG / DOMAIN_SITHBIT_CONFIG / MAIL_GRPC_CONFIG),
  3. 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,
  4. ./.env,
  5. ./.env.$APP_ENV (APP_ENV comes from the environment or ./.env),
  6. 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)

The prefixes are SITHBITD, ACCOUNT_API, DOMAIN_SITHBIT, and MAIL_GRPC. 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, or the account-api’s tls.cert_file / tls.key_file), 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 all six fields whatever the field is named (key_file, credential_key_file, delegate_key_file, keypair, certs / key, cert_file / key_file).

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 (or cert_file/key_file) entry. No secret material lives in the config file itself; it carries only the secret’s coordinates. Per cloud:

  • akv names the vault_uri and secret_name. Authentication reuses the same managed-identity credential chain as the Azure storage backend (azure_identity’s ManagedIdentity): no new auth to configure.
  • asm names the secret_id — a secret name or a full ARN — with optional region (defaults to the ambient AWS configuration’s) and endpoint_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.
  • gsm names the project and secret, with optional version (default "latest"). Authentication is Application Default Credentials (workload identity, GOOGLE_APPLICATION_CREDENTIALS, or a gcloud user login).

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 gated on the SITHBIT_TEST_KEYVAULT_URI environment variable (pointed at a real vault). The ASM and GSM twins gate on SITHBIT_TEST_ASM_SECRET_ID (plus optional SITHBIT_TEST_ASM_ENDPOINT/SITHBIT_TEST_ASM_REGION — an ASM probe can target LocalStack) and SITHBIT_TEST_GSM_PROJECT / SITHBIT_TEST_GSM_SECRET. 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):

VariableMeaning
{PREFIX}_AWSAPPCONFIGSelect AWS AppConfig: application/environment/profile (exactly three non-empty segments)
{PREFIX}_AWSAPPCONFIG_REGIONOptional region override (default: the ambient AWS configuration’s)
{PREFIX}_AWSAPPCONFIG_ENDPOINTOptional endpoint URL, for emulators/local fakes
{PREFIX}_AZAPPCONFIGSelect Azure App Configuration: the store’s https://<name>.azconfig.io endpoint
{PREFIX}_AZAPPCONFIG_LABELOptional label filter; unset reads the NULL label only, never every label
{PREFIX}_AZAPPCONFIG_PREFIXOptional 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 GetLatestConfiguration call — 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:kind sets store.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 gated on environment variables: SITHBIT_TEST_AWSAPPCONFIG_APPLICATION / _ENVIRONMENT / _PROFILE (plus optional _REGION / _ENDPOINT — an AWS probe can target an emulator) with ambient AWS credentials, and SITHBIT_TEST_AZAPPCONFIG_ENDPOINT (plus optional _LABEL / _PREFIX) with an ambient managed identity.

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.

[health] and [observability] — every binary

Two sections shared by every TOML binary — mail-grpc included, since it moved onto the same layering:

KeyDefaultMeaning
health.bind_addr127.0.0.1:<per-binary port>The /healthz + /readyz listener; each binary defaults its own port (8190–8198, table in Monitoring)
health.enabledtrueDisable 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_seconds60Metric 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.

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.

[store] — shared with account-api

KeyDefaultMeaning
kind"sqlite"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")
credential_key_file"credential.key"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

[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:[email protected]: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.

KeyMeaning
account_idCloudflare account id (the /accounts/<id>/… REST path segment, and the subdomain of the derived R2 endpoint)
api_tokenBearer token authorizing the D1/Queues calls; redacted from logs and Debug output
d1_database_idD1 database id (the /d1/database/<id>/query segment)
kv_namespace_idRetired 2026-07-19 — leases now ride D1’s leases table. Accepted so existing configs keep parsing, but ignored
api_baseREST base URL override (mock servers / proxies); unset = https://api.cloudflare.com/client/v4
[store.cloudflare.queues]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]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).

KeyDefaultMeaning
grpc.endpoint(unset)The mail-grpc gateway, e.g. "http://127.0.0.1:50051"
ipfs.kind(inferred)"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.blobslocal ipfs/ dirEmbedded-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"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)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.listenloopback TCP + QUIC, ephemeral portsMultiaddrs 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[]Bootstrap peers (/…/p2p/<PeerId> multiaddrs) that seed the DHT routing table, keyed by PeerId
ipfs.swarm.providefalseAnnounce pinned mail-blob roots as DHT provider records (re-announced every reprovide_interval_secs, default 22 h)
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)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)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_secs15Membership 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_secs60Missed renewals this long mark a member dead; its share of the keyspace reassigns to the survivors
ipfs.cluster.gc_interval_secs3600Cadence 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_secs3600Minimum age before an unreferenced block is deleted — must comfortably exceed the longest plausible pin upload (a pin writes blocks before its manifest)
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.

[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:

KeyDefaultMeaning
enabledtrue / falseMX on, submission off by default
hostname(discovered)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
mode"mx" / "submission"Listener role
sender_auth"spf"MX only: "spf", "dmarc-lite", "dmarc", or "none" — see Sender authentication below
local_domains(discovered) 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)DNSBL zone to check the connecting peer’s IP against at connect, e.g. "zen.spamhaus.org"
dbl_zone(unset)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_authfalseRequest 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)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)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_literalsfalseChain-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

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 (Google 8.8.8.8, Cloudflare 1.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

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 the account API’s [static] root), and the RCPT-time refusals start telling senders how to fix themselves:

  • the two postage refusals — 450 4.7.0 (frombox out of stamps) and 550 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 at MAIL FROM, rejecting only a published hardfail (RFC 7208 §8.4); DKIM is verified and recorded in the Authentication-Results header but never rejects.
  • "dmarc-lite" — full DMARC alignment (RFC 7489) at end-of-DATA, but it bounces only on p=reject with 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=quarantine is recorded but not enforced, and pct sampling is not applied — enforcement is all-or-nothing.
  • "dmarc" — the full DMARC disposition. Alignment is evaluated as in dmarc-lite, but the domain’s entire published policy applies: p=reject bounces (550 5.7.1), and p=quarantine accepts the message but files the recipient’s copy into their Junk folder (auto-created on first delivery — no operator setup). It also honors pct: a deterministic per-message roll drops enforcement one step (reject→quarantine, quarantine→none) for the unsampled fraction, per RFC 7489 §6.6.4, so pct=100 always enforces and pct=0 never does. A "dmarc" MX can also emit RFC 7489 aggregate (rua) reports back to the domains it evaluates — off unless you enable [spooler.dmarc_report] — and per-failure 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.

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.

KeyDefaultMeaning
enabledtrueMaster 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_hourly50Hourly external-recipient allowance for a brand-new account
base_daily200Daily external-recipient allowance for a brand-new account
max_hourly500Ceiling the hourly allowance ramps up to
max_daily2000Ceiling the daily allowance ramps up to

The effective allowance is min(cap, base × 2^account_age_weeks) — at the defaults:

Account ageHourlyDaily
0 weeks50200
1 week100400
2 weeks200800
3 weeks4001,600
4+ weeks500 (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.

[imap], [pop]

KeyDefaultMeaning
enabledtruePer-protocol toggle
imap.watch_poll_seconds0Split 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_authfalseRequest a TLS client certificate and offer SASL EXTERNAL. Inert without [imap.tls]; client auth stays optional
pop.client_cert_authfalseSame, for POP — but EXTERNAL is offered on implicit-TLS (POP3S/995) listeners only (see the caveat below). Inert without [pop.tls]

[*.server] — the shared listener section

Every listener ([smtp.server], [submission.server], [imap.server], [pop.server]) takes the same fields:

KeyDefaultMeaning
bind_addr127.0.0.1:2525 / :1430 / :1100Listen 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_tlsfalseWrap the socket in TLS at accept instead of STARTTLS/STLS
proxy_protocolfalseExpect 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
proxy_trusted[]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. Empty (the default) trusts any peer — suitable when only the balancer can reach the port. Ignored unless proxy_protocol is on; a malformed entry fails startup naming it
limits.max_connections1024Concurrent connections across the listener
limits.max_per_peer16Concurrent connections per peer IP
limits.idle_timeout_secs600Session idle timeout

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

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 = true on 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 lower SRV preference (DNS setup).
  • require_tls refuses AUTH / LOGIN / USER until 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 and inert unless the matching […tls] section is present, since client-cert auth exists only over TLS. When it is on the listener merely requests the client certificate (optional TLS client auth), so password clients keep connecting on the same port; EXTERNAL is advertised and accepted only on a connection actually running under client-auth TLS. One caveat for POP: a POP listener fixes its mechanism list at connection start, so EXTERNAL is offered on implicit-TLS listeners (POP3S, port 995) only — a plaintext socket upgraded with STLS will not retroactively advertise it. SMTP submission and IMAP have no such restriction (STARTTLS or implicit TLS both work).

[spooler] — outbound workers

KeyDefaultMeaning
enabledtrueRun 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
hostname"localhost"EHLO name, Reporting-MTA, and the MAILER-DAEMON domain
local_domains[]Domains the DSN builder treats as locally deliverable
delay_dsnfalseEmit “delayed” DSNs on retry schedules
mta_ststrueHonor recipient domains’ MTA-STS policies (RFC 8461) on direct-to-MX delivery. Ignored when [spooler.smarthost] is configured
danetrueHonor 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_days30Hourly 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_days0Hourly 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.enabledtrueRun the auto-settle sweeper (only when the chain pipeline is enabled)
spooler.settle.after_days30Post-delivery grace window before a delivered copy is settled
spooler.settle.keep_pinfalseKeep the IPFS pin at settlement instead of releasing it

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

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 7489 §7.2.1 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.

KeyDefaultMeaning
spooler.dmarc_report.enabledfalseMaster switch. false (or section absent) records nothing and runs no worker
spooler.dmarc_report.org_name(required when enabled)Your reporter identity, written to the report’s org_name
spooler.dmarc_report.email(required when enabled)The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment
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_hours24Aggregation window length reported in the report metadata
spooler.dmarc_report.interval_hours24How often the worker drains and sends

Each interval 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 7489 §7.1 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 rua address gets no report.
  • A rua target 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_published policy fields (p/sp/adkim/aspf) are not populated — the store does not retain the reported domain’s published policy, only the per-message evaluation results.

Commented block from sithbitd.example.toml (defaults shown):

# [spooler.dmarc_report]
# enabled = false
# org_name = "Example Mail"
# email = "[email protected]"
# submitter = "example.com"
# extra_contact_info = "https://example.com/dmarc"
# window_hours = 24
# interval_hours = 24

[spooler.dmarc_ruf] — forensic (ruf) reporting

[spooler.dmarc_ruf] emits DMARC failure/forensic (ruf) reports — the RFC 7489 §7.3 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.

KeyDefaultMeaning
spooler.dmarc_ruf.enabledfalseMaster switch. false (or section absent) builds and sends nothing
spooler.dmarc_ruf.include_bodyfalseAttach the full offending message (message/rfc822) instead of the headers-only default — see the privacy note below
spooler.dmarc_ruf.org_name(empty — set when enabled)Your reporter identity, the report From: display name
spooler.dmarc_ruf.email(empty — set when enabled)The report From: and outbound relay origin. Should be a local, DKIM-signable address so reports pass your own alignment
spooler.dmarc_ruf.subject"DMARC Forensic Failure Report"The report Subject:; an empty value derives this default
spooler.dmarc_ruf.extra_contact_info(none)Optional operator contact URI. Accepted for parity with [spooler.dmarc_report], but the ARF forensic format carries no such field — informational only

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 7489 §7.1 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 7489 §7.3 content-minimization default: 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 ruf address (or whose fo= does not select the failure) gets no report.
  • A ruf target that fails the §7.1 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 = "[email protected]"
# subject = "DMARC Forensic Failure Report"
# extra_contact_info = "mailto:[email protected]"

[spooler.dmarc_rua_ingest] — DMARC report ingestion

The receiving side of DMARC aggregate reporting: when another operator’s receiver mails an RFC 7489 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.

KeyDefaultMeaning
spooler.dmarc_rua_ingest.enabledfalseMaster 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.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.

KeyDefaultMeaning
spooler.tlsrpt.enabledfalseMaster switch. false (or section absent) records nothing
spooler.tlsrpt.org_name(required when enabled)Your reporter identity, written to the report’s organization-name
spooler.tlsrpt.email(required when enabled)The report From: and outbound relay origin. Should be a local, DKIM-signable address (RFC 8460 §3 requires reports to pass DKIM)
spooler.tlsrpt.contact_info(mailto: the email)The report’s contact-info URI
spooler.tlsrpt.window_hours24Aggregation window length reported in the report metadata
spooler.tlsrpt.interval_hours24How often the drain worker runs

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: at each interval_hours tick 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-supported failure rows are reserved for enforced postures that abort the delivery.
  • Handshake failures are recorded with the general validation-failure code and the TLS error detail in failure-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 records sts-policy-invalid, each with the planner’s diagnostic in failure-reason-code. Unreachable or timed-out hosts still produce no row, and MTA-STS testing-mode mismatches stay warn-log only.

Commented block from sithbitd.example.toml (defaults shown):

# [spooler.tlsrpt]
# enabled = false
# org_name = "Example Mail"
# email = "[email protected]"
# contact_info = "mailto:[email protected]"
# window_hours = 24
# interval_hours = 24

account-api

KeyDefaultMeaning
bind_addr"127.0.0.1:8180"HTTP listen address
admin_wallets[]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
[store](same as sithbitd)Point it at the same store so one database serves both
jwt.issuer / jwt.audience"sithbit"Token claims
jwt.key_file"jwt.key"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)
jwt.ttl_hours24Token lifetime
[chain](absent)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"The mail-grpc gateway serving mailbox/key/alias/ frombox/tx-status reads
chain.rpc_url"http://127.0.0.1:8899"Solana JSON-RPC node for SOL balances and relaying client-signed transactions
mail.local_domains[]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
[quota](enabled, sithbitd’s defaults)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)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](absent)Serves a static directory on the API’s own origin (browser pages reach /v1/… without CORS) — the Outlook add-in’s built bundle is the intended occupant. Absent = no static routes; a missing root 404s per request
static.route"/addin"Route prefix the files appear under
static.root"wwwroot"Directory to serve (point it at webclients/outlook/staging)
[tls](absent)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.cert_file(required)PEM certificate chain — a key source: a file path (default) or a cloud secret-manager secret holding the PEM
tls.key_file(required)PEM private key — same file-or-cloud key source as cert_file

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.

KeyDefaultMeaning
api_url"http://127.0.0.1:8180"Base URL of the account API
keypair_file~/.config/solana/id.jsonSolana 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"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)
rpc_url"http://127.0.0.1:8899"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

KeyDefaultMeaning
bind_addr"127.0.0.1:8181"HTTP listen address
wwwroot"wwwroot"Static files (the verification front-end)
delegate_key_file(unset)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

Its on-chain calls resolve the RPC endpoint the way the sithbit CLI does: the config file at ~/.config/solana/cli/config.yml, managed with sithbit config get/set,1 overridden by a JSON_RPC_URL environment variable.

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

KeyDefaultMeaning
[mail_hosts.imap] host"127.0.0.1"Public IMAP hostname a client connects to
[mail_hosts.imap] port993IMAP port
[mail_hosts.imap] socket_type"SSL"Transport security (see below)
[mail_hosts.pop] host"127.0.0.1"Public POP3 hostname
[mail_hosts.pop] port995POP3 port
[mail_hosts.pop] socket_type"SSL"Transport security
[mail_hosts.smtp] host"127.0.0.1"Public submission hostname
[mail_hosts.smtp] port465Submission 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"Transport security

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.

KeyDefaultMeaning
mode"testing"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[]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_age604800 (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).

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

KeyDefaultMeaning
bind_addr"127.0.0.1:8182"HTTP listen address
auth_token(unset — open)Bearer token required on every request when set. Bind beyond loopback only with a token or network isolation — the pin API is a write surface
max_pin_bytes33554432 (32 MiB)POST body limit; larger uploads are refused with 413
[blobs]local ipfs/ dirBlock/pin storage — the same shape and semantics as sithbitd’s [ipfs.blobs]
[swarm](unset — no swarm)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)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

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

KeyDefaultMeaning
service_record_ttl_secs900 (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_secs300 (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)

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

KeyDefaultMeaning
bind_addr"127.0.0.1:8183"HTTP listen address. The surface is read-only, so no auth gate exists; bind it wherever readers live
public_host(unset — path gateway only)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/ dirBlock/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

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

KeyDefaultMeaning
bind_addr"127.0.0.1:50051"gRPC listen address. Loopback by default — the gateway is private-network-only by design; expose it deliberately, never by default
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_seconds30TTL 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
[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_seconds5How often the indexer polls for new alias transactions
[health], [observability]health on 127.0.0.1:8193The two shared sections above

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

Which services get a .env

mail_spooler, account_api, domain_sithbit, ipfs_daemon, and ipfs_gateway 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.

Other crates in the workspace deliberately don’t have one:

  • mail-grpc uses the same layered .env/.env.$APP_ENV mechanism as the binaries above (it reads them from the working directory, with MAIL_GRPC_* overrides), but ships no sample: its whole surface is the short table above, and mail_grpc/mail_grpc.example.toml already shows every key. The workspace-root .env that used to configure it is 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 sithbit CLI (mail_client) reads no .env at all; its only configuration inputs are the config file sithbit config manages1 and the JSON_RPC_URL environment variable.
  • mail_program / alias_program / domain_program are on-chain SBF programs with no runtime environment.
  • Every other workspace crate is a library with no binary target, so there’s nothing to configure at runtime.

  1. This is the same file (~/.config/solana/cli/config.yml, same location on Windows too) the Solana CLI’s own solana config command reads and writes, if you already have it installed — see CLI Quickstart. ↩2 ↩3

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.

DKIMsithbitd 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:[email protected]"

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:[email protected]; ruf=mailto:[email protected]"

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:[email protected]"

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:[email protected]"

(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:

  1. 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>"
    
  2. POST /domain on the domain-sithbit service with the domain name as the body. The service resolves that TXT record, validates the key, and — signing with its configured delegate key — submits CreateDomain naming the key as the domain’s authority.

  3. GET on 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 /domain replies 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 SendMail with 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 postmaster name. 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 operationsdomain create, the two-step deactivation (request, cancel, finalize), domain close, and domain 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 tuningpostmaster 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), and fee 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 fingerprintpostmaster ksk set (and the read-only postmaster 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>] \
  [-y] \
  [--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)). 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.
  • At mainnet launch, cut a build with the reclaim feature off. The command disappears from the CLI and — because the on-chain AdminCloseAccount handler is itself feature-gated out of the shipped bytecode — the whole close-anything path leaves the programs entirely. A reclaim-free launch build 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 mainnet exactly. This guardrail is never bypassable by -y; there is no unattended way to wipe mainnet.
  • On devnet or localnet it takes a light y/N prompt instead, and there -y does 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.
  • reclaim does 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 postmaster command tree — including the read-only get — is gated behind the CLI’s postmaster feature, which is not part of the default feature set. Build the CLI with cargo build -p mail-client --features postmaster (or --all-features) to get it. The default build still exposes the read-only query, as sithbit postoffice postmaster — the same underlying lookup as postmaster 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 postmaster command tree used throughout this runbook (init, commitment, withdraw, delegate, set-*-fee, ksk get/set/iana, and the read-only get) is gated behind the CLI’s postmaster feature, which is not in the default feature set. Build an admin CLI with cargo build -p mail-client --features postmaster (or --all-features). The default build exposes only the read-only query, as sithbit 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 delegatepostmaster commitment
Answers“who operates the deployment day to day?”“who owns the deployment?”
Changes on-chainthe delegate_address (the hot key)the commitment_root (the whole hidden owner set)
Cadenceroutine, on a schedule (quarterly is a fine default) + on delegate compromiserare, event-driven — ownership handover, or retiring a seed from the set
Touches the seeds?briefly, to sign — the seed set is unchangedyes, it replaces the seed set with a fresh ceremony’s
Ceremony needed?no — same seeds, next generationyes — 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 -k payer.json

What to store where

Two rules produce the whole custody scheme:

  1. 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.
  2. 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 values postmaster init used — 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 -k 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 -k 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_file from the configured key source — disk or Azure Key Vault — on every POST /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 --seed file and re-derives all leaves from the secrets. There is no flag to substitute a lost seed’s public leaf hashes, and no export-leaves command to produce the artifact in the first place. The library layer (mail_client’s ceremony module) 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 commitment to 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 init and 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

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. 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 the standard low mail ports. 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.

Two operational consequences:

  • Accepting mail for a password-less recipient asks the gateway for their published key at SMTP DATA time. A gateway outage tempfails the submission (451) — the same posture as the postage checks at RCPT — rather than silently downgrading anyone to plaintext.
  • The wallet-signature AUTH on 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.

See the Configuration reference for the full key/default table.

account-api: the account service

Standard port(s): bind 8180, health 8191.

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

A stored mail password is now optional (item 17). The POP/ IMAP/SMTP servers accept a wallet signature as the credential — username = the wallet address, password = a signature the wallet produces (see deriving the mail password) — so an account needs no stored secret to collect or send mail. (The servers accept a wallet-minted TLS client certificate as a second passwordless path — SASL EXTERNAL, gated by the listener’s client_cert_auth.) The account surface reflects this:

  • PUT /v1/account/password takes an optional password field. A non-empty value is graded by the usual strength policy, sealed, and stored (the classic shared-secret path). An absent or empty value is a no-op that declares the wallet-signature-auth state (the account authenticates by wallet signature, no stored secret required). It does not clear a previously stored secret — there is no delete path yet, so an account that already set a stored password keeps it until a future clear endpoint lands.
  • GET /v1/account returns a boolean mail_password_set telling a client whether a stored password exists — so a UI can offer “copy wallet mail password” versus “set a stored password” without ever reading the secret back.

The same surface carries the /v1/account/pin-provider routes, where a recipient registers their own IPFS pinning provider for inbound mail — see Per-recipient pin providers.

The do-not-disturb schedule lives here too (see Do not disturb for why refusing mail beats an autoresponder). The owner manages it authenticated — GET/PUT /v1/account/dnd read and replace the exclusion set wholesale — and one anonymous route answers senders:

  • GET /v1/dnd/{wallet} always returns {"excluded_now": bool} — whether the wallet is away right now, evaluated in its own timezone. A wallet with no account simply isn’t away: excluded_now is false.
  • The schedule itself (an exclusions array alongside excluded_now) appears only when the owner opted in via the account’s expose_dnd_schedule flag — PATCH /v1/account {"expose_dnd_schedule": true}, also reported on every GET/PATCH /v1/account response. Default false: hidden.
  • An accepting_at field — the instant the wallet starts accepting mail again, computed in the account’s own timezone and put on the wire as RFC 3339 UTC so the sender’s page can localize it to their clock — rides the reply only behind a triple gate: the wallet is excluded right now, and the owner opted in (the same expose_dnd_schedule flag as the schedule), and the schedule ever reopens. A recurring schedule covering all seven days around the clock never does — the field is then omitted, like every other case that fails the gate.

That default is a deliberate privacy-tightening behavior change: the route used to return the full exclusion list to any anonymous caller. Existing deployments now answer only the yes/no until each owner opts in — the self-service schedule page degrades gracefully either way, telling a refused sender “temporarily away” and showing the windows — and the accepting_at instant, localized to the sender’s own clock — only for opted-in recipients.

With a [chain] section configured, the API additionally serves the authenticated /v1/chain surface — an on-chain read proxy ( SOL balance, mailbox, published encryption key, aliases, per-sender stamp balances) plus a relay for transactions the client built and signed itself. This is the browser-reachable surface the Thunderbird extension rides: mail-grpc holds a hot signing key and speaks raw gRPC, so browsers never talk to it directly. The wallet-scoped reads answer for the JWT’s wallet; the relay never signs anything. One generic read rounds out the proxy: GET /v1/chain/account/{address} returns any raw account — {"owner": "<base58>", "data": "<base64>"}, the node’s bytes untouched — so an API-mode web shell can decode accounts client-side with the same wasm decoders the wallet-direct pages use (the bounty-claim resolver reads the domain account this way, so a domained claim pays the domain authority instead of falling back to the filler pair). An absent account is a 404; the data is public on-chain, but the route sits behind the same JWT as its read siblings. Without [chain], those routes answer 503.

The API also serves the /v1/mail surface — the user-facing mail routes (folders and folder CRUD, message pages with parsed summaries, full rendering, raw/part downloads, flags, move/delete, and a capped scan search with a resume cursor) over the same store the IMAP/POP servers serve. No IMAP bridge is involved: webmail and the Outlook add-in read the rows and blobs directly through these routes, authenticated by the same JWT as the account surface. The read routes need no configuration beyond [store], with one optional setting.

Message listing (GET /v1/mail/messages) and search (GET /v1/mail/search) ride an indexed, newest-first keyset primitive on every store backend — the store answers each page from an index range rather than scanning the whole mailbox — so the client-visible cursor contract (before_uid in, next_before_uid/resume_before_uid out, truncated) is unchanged. Two boundary facts worth knowing: an unknown mailbox 404s, while an existing-but-empty mailbox returns 200 with an empty page; and a search that scans the full candidate cap reports truncated=true with a resume cursor whose follow-up page is empty — a harmless one-extra-empty-continuation that a correct resume walk simply stops on.

By default a message fetch (GET /v1/mail/messages/{uid}) is a pure read: it never touches flags, so read state is whatever the client sets with an explicit PATCH — the recommended default, matching the IMAP model where the client owns \Seen. Operators serving clients that expect a fetch to imply “read” can opt in with the [mail] auto_mark_seen setting: when true, a successful fetch adds \Seen to that message after rendering (an opt-in read-receipt; the response body is unchanged). It defaults false, and — like every other setting — is shown commented at its default in the example config:

[mail]
# auto_mark_seen = false   # true: GET /v1/mail/messages/{uid} adds \Seen after fetching

(Message fetch is served by an indexed by-uid lookup plus a per-blob summary cache — a performance detail with no client-visible change.)

The one write route that leaves the store is compose (POST /v1/mail/send): the API builds the RFC822 message and spools it through the same core sithbitd’s SMTP sink uses — local recipients get mailbox rows and background chain jobs, foreign domains get relay jobs the spooler’s relay worker drains (the two binaries share the store, so no extra wiring), and the composer gets an already-read Sent copy. Routing is configured by the [mail] section: local_domains names the domains whose recipients live in this store (mirror sithbitd’s local_domains; aliases and the stamp precheck resolve over the [chain] gateway — without one, only literal wallet addresses resolve locally), and [mail.dkim] (same one-or-many shape as [spooler.dkim]) signs composed mail. Both default off: with no local domains everything relays — mail addressed to this deployment’s own domain then loops back through its MX, which is correct, just slower — and with no keys relayed mail goes unsigned.

Compose also enforces the outbound abuse controls: a suspended account is refused HTTP 403 on every send, and external (relayed) recipients past the account’s rolling hour/day allowance are refused HTTP 429 — local recipients are never counted (on-chain stamps price those). The policy is the API’s [quota] section, a deliberate twin of sithbitd’s [smtp.quota] / [submission.quota]keep the two in step so a sender meets one policy on both submission surfaces. See the Configuration reference for the settings and the age ramp, and Monitoring for the admin quota/suspend endpoints and the per-surface refusal codes.

The /v1/admin surface (a wallet on the admin_wallets allowlist, like every admin route — see Monitoring) also carries the DMARC aggregate-report reader: GET /v1/admin/dmarc-reports lists every report sithbitd’s [spooler.dmarc_rua_ingest] delivery hook has stored in the shared blob store — {"reports": [{"id": …, "modified_at": …}]}, modified_at RFC 3339 or null when the backend reports no timestamp, unpaginated (like its admin siblings), an empty store an empty list — and GET /v1/admin/dmarc-reports/{id} returns the stored report JSON verbatim (the parsed RFC 7489 report, as the ingest hook serialized it). A malformed id is 400 — ids are validated against the key whitelist ([A-Za-z0-9._-], at most 200 bytes) before the store is touched, so a crafted path can never read outside the dmarc_rua/ blob prefix — an unknown well-formed id is 404, and both routes share the standard admin 401/403. Nothing prunes the stored reports yet. See DNS setup for pointing your domain’s rua= address here in the first place, and the conformance appendix for what ingestion deliberately does not do with the data.

Two semantics worth knowing when these routes mutate mail:

  • Move preserves the source’s chain linkage; delete settles it. A move is a single linkage-preserving relocation: the surviving copy carries its on-chain message id, IPFS pin (cid), and chain state to the destination, and the source uid is gone. Nothing is torn down — no chain-delete job, no orphaned-blob delete — because the linkage travels with the moved copy rather than being settled. This deliberately diverges from IMAP MOVE, which settles the source; keeping the linkage means the moved mail stays available and on-chain-linked at its new home. Delete is the teardown path: it expunges the copy and settles the chain — chain-delete jobs for pinned/sent copies, byte deletion for orphaned blobs.
  • IMAP IDLE sees API changes with a delay. The API and sithbitd are separate processes, so a webmail mutation reaches an idling IMAP client via sithbitd’s change poller ([imap] watch_poll_seconds), not instantly. Flag-only changes on a SQLite store don’t bump the change sequence at all — they surface with the next real mailbox event.

With a [static] section configured, the API also serves a static directory (default route /addin) on its own origin — this is how the Outlook add-in’s built bundle (webclients/outlook/staging) is hosted, so the add-in’s pages call /v1/… same-origin with no CORS configuration. Office requires the add-in over https: either front the API with a TLS-terminating reverse proxy (the production recommendation) or enable the built-in [tls] listener. A dev certificate for sideloading is one command —

openssl req -x509 -newkey ed25519 -keyout key.pem -out cert.pem \
  -days 365 -nodes -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost"

— then trust cert.pem in the OS store (Office rejects untrusted certificates even on localhost).

The [tls] certificate and key (cert_file / key_file) and the JWT signing key (jwt.key_file) are each a key source: a local file by default — the zero-config path, and the only form the JWT key auto-generates into when missing — or a cloud secret-manager secret: Azure Key Vault ({ kind = "akv", vault_uri = "…", secret_name = "…" }, fetched with the same managed identity as the Azure store backend), AWS Secrets Manager (kind = "asm"), or Google Secret Manager (kind = "gsm").

When you need it: only if you’re exposing self-service account management — for example, the Thunderbird extension, the Outlook add-in, or a webmail UI — rather than administering accounts purely through the CLI.

Quickstart:

cargo run -p account-api

Config file account_api.toml, or point ACCOUNT_API_CONFIG at an alternate path. It shares sithbitd’s [store] — point both at the same database so account state and mail state stay consistent.

See the Configuration reference for the full key/default table.

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 (ResolveAlias, GetFrombox, SendMail, GetMailbox, GetMailboxKey, DeleteMail). MX/ mail servers call this instead of talking to Solana directly.

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

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-grpc no longer reads its legacy environment-only configuration. GRPC_SERVER_ADDRESS, DEFAULT_KEYPAIR (and DEFAULT_KEYPAIR_VAULT_URI / DEFAULT_KEYPAIR_SECRET_NAME), ALIAS_INDEX_DB, ALIAS_INDEX_POLL_SECONDS, ALIAS_CACHE_SECONDS, and HEALTH_BIND are all ignored. The one legacy name still honored is JSON_RPC_URL (bare, un-prefixed), for parity with the sithbit CLI and the standard Solana convention — it overrides the configured json_rpc_url. In particular, there is no keypair-content-in-an-environment-variable shape anymore: DEFAULT_KEYPAIR used to hold the raw JSON keypair array itself, while the TOML keypair names a key source — a keypair file path, or a cloud secret-manager secret. The nearest env-var equivalent is MAIL_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.

Note: without delegate_key_file configured, POST /domain replies 503 — DNS verification lookups still work, but on-chain authorization is disabled until a delegate key is set. delegate_key_file is 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 every POST /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.

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

RouteMethodClientPurpose
/.well-known/autoconfig/mail/config-v1.1.xmlGETThunderbirdMozilla autoconfig, served from the mail domain itself
/mail/config-v1.1.xmlGETThunderbirdThe 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.xmlPOSTOutlookPlain-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 its [static] mount, not by this service. It walks a user through wallet-challenge login and setting (or deriving) their mail password entirely client-side. 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 [static] root 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-tablehost defaultport defaultsocket_type default
[mail_hosts.imap]127.0.0.1993SSL
[mail_hosts.pop]127.0.0.1995SSL
[mail_hosts.smtp]127.0.0.1465SSL

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.

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.

Quickstart:

cargo run -p ipfs-gateway

Config file ipfs_gateway.toml, or point IPFS_GATEWAY_CONFIG at an alternate path.

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 Host of <label>.ipfs.example.com serves 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 base32 CIDv1, matching Kubo:
    • Label surface. A Host whose 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_host itself (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.
  • 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).
  • Location shape. 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_ipfs opt-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.

Set (or replace) a providerPUT /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 "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 "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 "Content-Type: application/json" \
  -d '{"kind":"remote","endpoint":"https://ipfsd.example.net:8182","auth_token":"<token>"}'

Success is 204 No Content. 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 or network isolation in front.

Check what’s configuredGET /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.

Remove itDELETE /v1/account/pin-provider:

curl -X DELETE -H "Authorization: Bearer $JWT" http://127.0.0.1:8180/v1/account/pin-provider
# → 204; future deliveries pin to the server default only

Deleting is idempotent — clearing an already-unconfigured provider still answers 204.

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 pin once 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_ipfs opt-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:

FileLoss meansBack up?
credential.keyevery sealed mail credential is orphaned — users must set new mail passwordsyes, first
jwt.key (account-api)every login session invalidated; auto-regenerates, users just log in againyes
sithbit.db (+ -wal, -shm)accounts, mailboxes, message metadata, queued jobsyes
blobs/ (local blob store)message bodies not yet pinned to IPFSyes
DKIM key, TLS keys, delegate keypair, mail-grpc’s signing keypairre-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 serveryes
alias_index.db (mail-grpc)nothing — it re-syncs from chain history on an empty fileno

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_ENDPOINT env vars override the configured endpoint inside the exporter — leave them unset for the config file to be authoritative.
  • Traces ride the RUST_LOG filter: a target silenced for logging is also not exported.

The metrics (all under the sithbit. prefix, labeled as noted):

MetricKindLabelsMeaning
sithbit.sessionscounterportaccepted SMTP/ IMAP/ POP sessions
sithbit.sessions.activeup/downportsessions currently served
sithbit.rcpt.refusalscounterreasonRCPT TO refusals (unknown_mailbox, relay_denied, temporary_failure, custom_<code>)
sithbit.jobscounterqueue, outcomejob dispositions (done / retry / bury)
sithbit.queue.depthgaugequeuebacklog incl. delayed + claimed jobs
sithbit.queue.oldest_age_secondsgaugequeueage of the oldest queued job (SQLite store only; cloud queues don’t expose it — use CloudWatch/Azure metrics there)
sithbit.chain.stuckgaugenon-terminal chain copies past the sweep horizon, per reconciler pass
sithbit.repin.outcomescounterkindrepin-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_secondsgaugeseconds since the alias index last synced (mail-grpc)

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 getBalancenot 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}/suspend with body {"suspended": true} (or false to 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”:

SurfaceConditionRefusal
SMTP submission, external RCPTover quota452 4.5.3 (transient — retry after the window rolls; local RCPTs in the same transaction are unaffected)
SMTP AUTHsuspended535 5.7.13 Account disabled (RFC 3463 “user account disabled”)
SMTP MAILsuspension landed mid-session (after AUTH)550 5.7.1 Account suspended
IMAP loginsuspendedNO [CONTACTADMIN] account disabled; contact your administrator (RFC 5530) — the same refusal answers post-login commands if suspension lands mid-session
POP loginsuspended-ERR [SYS/PERM] account disabled; contact your administrator (RFC 3206 permanent-failure code)
Compose (POST /v1/mail/send)suspendedHTTP 403
Composeover-quota external recipientsHTTP 429

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.

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:

StateMeaningTerminal?
locallocal-only copy (e.g. a sent-folder copy); the pipeline ignores ityes
receiveddelivered, waiting for the chain worker — the resting state when the chain pipeline is disabled (dev stacks)no
pinnedbody encrypted and pinned to IPFS; SendMail pendingno
senton chainyes
no_keythe recipient cannot receive encrypted mail (e.g. off-curve address); the local copy stays readable, a warning is logged, no bounceyes
chain_failedgave up permanently; the reason is in the logs and usually a buried chain jobyes

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:

BinaryHealth listener
sithbitd127.0.0.1:8190
account-api127.0.0.1:8191
domain-sithbit127.0.0.1:8192
mail-grpc127.0.0.1:8193
pop-server127.0.0.1:8194
smtp-server127.0.0.1:8195
imap-server127.0.0.1:8196
sithbit-ipfsd127.0.0.1:8197
sithbit-gateway127.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.sh scripts exactly this for the dev stack.
  • mail-grpc’s ListAliases RPC answers UNAVAILABLE (“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 (the sithbit.alias_index.staleness_seconds metric covers ongoing sync health).

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:

  1. 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).
  2. 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.
  3. Blob bytes — every stored message body, byte-for-byte, including any orphaned blob referenced by no message.
  4. 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 StoreConfigs 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).

The config resolves through the standard layering: in-code defaults → sithbit_migrate.toml (or the file named by SITHBIT_MIGRATE_CONFIG) → .env.env.$APP_ENV → real environment. Individual settings override from the environment as SITHBIT_MIGRATE_{PATH} with __ per nesting level — e.g. SITHBIT_MIGRATE_TARGET__KIND=aws, SITHBIT_MIGRATE_TARGET__AWS__TABLE=sithbit-prod.

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, or every stored mail password is orphaned and users must set new ones. Back it up first, migrate it alongside the store.
  • Timestamps are not preserved. created_at / updated_at metadata is reset to the migration time on the target; message internaldate and 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 uidvalidity and uidnext are 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 a uidvalidity bump.
  • 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

  1. Back up the source, especially credential.key.
  2. Stop sithbitd and account-api against the source store.
  3. Provision the target’s prerequisites (S3 bucket if using one; credentials/region for the backend).
  4. Write sithbit_migrate.toml with [source] and [target], carrying the credential key across.
  5. sithbit-migrate (dry run) — confirm the counts and that both stores open.
  6. sithbit-migrate --commit.
  7. Repoint sithbitd / account-api [store] at the new backend and start them.
  8. 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

  1. A shared store. [store] kind = "postgres", "aws", or "azure" (postgres is 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 to sync_interval_secs), so it does not give the cross-instance lease/queue consistency postgres/aws/azure do — 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’s leases table, and IMAP-uid allocation (uidnext) is a server-side atomic UPDATE … 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.

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

  3. 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: losing credential.key orphans every stored password.

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

  5. 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 enable proxy_protocol = true in each listener’s server section and the balancer. Never enable it on a listener that clients can reach directly — the preamble is trivially spoofable.

  6. Replica-aware limits. max_connections/max_per_peer are enforced per process; the fleet-wide effective cap is the per-instance value times the replica count.

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 — a crashed session on one instance cannot wedge the maildrop for the rest.
  • The reconciler may run on every instance; duplicate re-enqueues are absorbed by the chain worker’s state guards.
  • account-api replicas 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).
  • 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’s leases table — 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)onoffononon
MX edgeonoffoffoffoff
Submission edgeoffonoffoffoff
Pickup (IMAP/POP)offoffononoff
Workersoffoffoffoffon

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_seconds after the worker tier lands it.
  • Leaving the setting at its 0 default (“trust in-process push”) would leave idlers asleep forever on an instance where nothing delivers, so sithbitd applies a safety rider: IMAP on + both SMTP roles off + watch_poll_seconds = 0 auto-adopts 5 seconds, with an info log saying so. An explicit value is always the operator’s choice, and 0 keeps meaning in-process push whenever an SMTP role is co-resident.
  • The known seams above bind with full force here: \Recent is 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 SITHBIT_TEST_POSTGRES_URL (one_message_crosses_the_role_split_topology_on_postgres).

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:

  1. Cargo.toml — a cargo feature gating the backend’s SDK deps. Declare it in mail_store and keep all complete; 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);
  2. config.rs — a StoreKind variant plus its [store.<kind>] settings struct (never feature-gated: configs parse in every build);
  3. a backend module implementing the four repo traits (AccountRepo, MailRepo, JobQueue, KeyedLease) — blobs are orthogonal and stay behind AnyBlobStore;
  4. stores.rs — a <Kind>Stores alias with an open constructor (plus its BackendDisabled stub alias), one arm in the with_backend! macro (the workspace’s single backend dispatch point; sithbitd and account-api both route through it), and the enabled/disabled __with_backend_<kind> helper pair;
  5. lib.rs exports, feature-gated;
  6. tests/mod.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 for member_ttl_secs is 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 gc_grace_secs old — a pin writes its blocks before its manifest, so in-flight pins are never collected. 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.

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.

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

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 (NodeDelegationSignedDelegation 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}) so a crashed session cannot wedge it.

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. 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, honoring pct) and can emit both RFC 7489 aggregate (rua) reports and per-failure forensic (ruf) reports back to sending domains — the latter headers-only by default (RFC 7489 §7.3), full-message on explicit opt-in, gated by the §7.1 external-destination check.

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.

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.

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) and the kv_namespace_id config key is now accepted but ignored.

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 — 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 without re-reading the sentence. The set is deliberately small: it covers only the handful of terms that recur across the whole book (see the frequency data below), 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.)

IconTermMeaning
addressA Solana wallet address, which doubles as a SithBit email address.
mailboxThe 1:1 account for a wallet, holding its domain, encryption key, and default stamp price.
fromboxA recipient-owned prepaid-stamp account, one per (sender “from” address, recipient) pair.
stampPrepaid postage for one email from one sender to one recipient; sending decrements the count.
aliasA globally-unique, case-insensitive human-readable name that resolves to a wallet address.
domainA DNS mail domain authorized on-chain, whose authority relays that domain’s inbound mail.
mail / messageAn email — on-chain, a parked message account referencing the sealed body’s IPFS CID.
pinRetaining a mail body’s blocks on IPFS against garbage collection; unpinning releases them.
postofficeThe singleton admin account: it holds the delegate, the ownership root, and the fee values, and collects protocol fees.
postmasterThe postoffice’s owner — a hidden, ceremony-committed key set, not a single on-chain pubkey.
POPPOP3, the single-spool mail-retrieval protocol SithBit serves.
IMAPIMAP4rev1, the folder-based mail-access protocol SithBit serves.
daemonA long-running server process — e.g. sithbitd, the combined SMTP/IMAP/POP + spooler binary.
hashblake3, the fast hash used for two PDA seeds (the frombox “from” address and the alias name).
SOLSolana’s native token; all postage, fees, and rent are denominated in it (in lamports).
campaignA bountied outreach to opted-in participants: an advertiser reaches wallets that published a participation beacon, paying postage and reply bounties.

Why these terms

The set was 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. Terms that recur on a third or more of the pages carry an icon; the count also scopes the later book-wide application sweep — the higher the count, the more pages the sweep touches.

TermPages (of 66)
mail60
address51
mailbox48
domain46
alias40
message40
postoffice28
frombox24
postmaster24
hash23
POP19
IMAP19
SOL17
daemon12

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.

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

ProgramAddress
Mail programMaiLyqjRuHp8SSQHjiLMPmhBcuLitSta4YdoTiibXu4
Alias programALiasg6qDnwcY8HfyeC1AjXRFjyqpXxW4omtwF1i125q
Domain programDmaiNcmXsPw2juV9JoZSC47V5epAysQi3DJVk3fiBuUv

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 constantBytesOwning program
POSTOFFICE_SEEDpostofficemail
MAIL_MESSAGE_SEEDemailmessagemail
PUB_ENCRYPTION_KEY_SEEDencryption_keymail
FROMBOX_SEEDfromboxmail
PARTICIPANT_BEACON_SEEDparticipant_beaconmail
PENDING_MAILBOX_CLOSE_SEEDpending_mailbox_closemail
SENDER_REPUTATION_SEEDsender_reputationmail
MAIL_DOMAIN_SEEDmaildomaindomain
PENDING_DEACTIVATION_SEEDpending_deactivationdomain
PENDING_RECLAIM_SEEDpending_reclaimdomain
PROOF_WITNESS_SEEDproof_witnessdomain
DOMAIN_LISTING_SEEDdomain_listingdomain
SENDER_ATTESTATION_SEEDsender_attestationdomain
ALIAS_ESCROW_SEEDalias_escrowalias
ALIAS_LISTING_SEEDalias_listingalias
ALIAS_BID_SEEDalias_bidalias
DOMAIN_ALIAS_SEEDdomain_aliasalias

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:

InstructionEmitted by
SendMailmail send
DeleteMailmail delete
CreateMailboxmailbox create (with --for, a sponsored create: the payer must be the named domain’s authority, and the owner rides the instruction data)
UpdateMailboxmailbox 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
UpdateFromboxfrombox update
AddStampsfrombox stamp — same optional max_price_lamports ceiling, here checked against the frombox’s stored required_postage (error 107)
InitPostoffice postmaster init
InstallCommitmentpostmaster commitment (rides the variant position TransferPostmaster held before the delegation cutover — same discriminant, new ownership semantics)
SetMailboxKeymailbox key set
WithdrawPostofficepostmaster withdraw
CloseFromboxfrombox close
CloseMailbox(disabled since v0.26.0 — refuses with custom error 94 InstantCloseDisabled; discriminant 16 kept so indexers resolve history)
CloseKeymailbox key close
SetStampFeepostmaster fee stamp
RefundMailmail refund (recipient refuses; postage + rent go back to the sender)
SetDomainFeepostmaster fee domain (a postoffice mutation, so it stays mail-side despite the name)
SetAliasFeepostmaster fee alias
SetAliasTierFeespostmaster fee alias-tiers (the four premium claim fees for 1–4 character names; each slot capped, over-cap refuses with custom error 98 AliasTierFeeAboveCap)
SetRootKskpostmaster ksk set (likewise a postoffice mutation — the anchor the domain program reads cross-program)
ClaimBountymail claim-bounty
RefundBountymail refund-bounty
RotateDelegatepostmaster delegate
AdminCloseAccount (discriminant 38)postmaster reclaim (delegate-signed devnet/reset tool, --features reclaim); refuses a target holding value above its rent-exempt minimum with custom error 106 AdminCloseEscrowPresent
CreateParticipantBeacon (39)participant-pool opt-in (item 43); owner-signed, requires the wallet’s mailbox — CLI authoring lands with item 44
UpdateParticipantBeacon (40)wholesale profile rewrite (tags + sealed-detail CID; None clears the CID)
CloseParticipantBeacon (41)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 discriminantsInstructions
9–12CreateDomain, DeactivateDomain, CloseDomain, TransferDomainAuthority
22–24RequestDeactivateDomain, CancelDeactivateDomain, FinalizeDeactivateDomain
26–28AuthorizeDomainByProof, WriteProofWitness, CloseProofWitness
31–33ListDomain, BuyDomain, CancelDomainListing
35–37RequestReclaimByProof, 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):

InstructionEmitted by
CreateDomain domain create
DeactivateDomaindomain deactivate --false (instant reactivation)
CloseDomaindomain close
TransferDomainAuthoritydomain transfer
RequestDeactivateDomaindomain deactivate (opens the two-step timelock)
CancelDeactivateDomaindomain deactivate --cancel
FinalizeDeactivateDomaindomain deactivate --finalize
AuthorizeDomainByProofdomain authorize (permissionless DNSSEC-proof authorization)
WriteProofWitnessdomain authorize / domain reclaim (stages the RRSIG-chain witness buffer)
CloseProofWitnessdomain authorize --close-witness (reclaims a leftover witness buffer)
ListDomaindomain sell
BuyDomaindomain buy
CancelDomainListingdomain sell --cancel
RequestReclaimByProofdomain reclaim (opens the reclaim timelock by DNSSEC proof)
FinalizeReclaimByProofdomain 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
CancelReclaimByProofdomain reclaim --cancel
AdminCloseAccount (discriminant 16)postmaster reclaim (delegate-signed devnet/reset tool, --features reclaim); same value guard as its mail-side twin — over-rent targets refuse 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 — every postmaster fee command), SetRootKsk, the alias program’s fee-waived bulk reservation. A non-delegate signer is refused with code 66 (NotDelegate). The three AdminCloseAccount reclaim twins are delegate-gated too, with their own refusal code (AdminCloseUnauthorized) — and, 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, and RotateDelegate. 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

InstructionEmitted by
CreateAliasalias 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)
CloseAliasalias close
OfferTransferAliasalias transfer init (--fee defaults to 0 — a free hand-off)
AcceptTransferAliasalias transfer accept
CancelTransferAliasalias transfer cancel
ListAliasalias sell
BuyAliasalias buy
CancelAliasListingalias sell --cancel
SellAliasalias sell --auction (opens an ascending-bid auction)
BidAliasalias bid
SettleAuctionalias settle-auction
AdminCloseAccount (discriminant 12)postmaster reclaim (delegate-signed devnet/reset tool, --features reclaim); same value guard as its mail-side twin — over-rent targets refuse with error 106 AdminCloseEscrowPresent
RegisterDomainAliasalias register-domain (domain-scoped aliases)
RemoveDomainAliasalias remove-domain
UpdateDomainAliasalias update-domain

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:

FieldBytesMeaning
holder32The seller — the alias’s current holder, or the domain’s current authority; paid on purchase, and the only address that may cancel.
price_lamports8The fixed price any buyer pays.
created_at8Unix time the listing was staged; anchors the binding window.
expires_at8Unix 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):

InstructionAccounts, 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:

CodeVariantMessage
57ListingPriceZeroA listing’s price must be positive; zero-price hand-offs use the transfer paths
58ListingExpiryInvalidA listing’s expiry must be in the future
59ListingStillBindingThe listing is still in its binding window
60NoListingForAliasNo listing is open for this alias
61NoListingForDomainNo listing is open for this domain
62ListingExpiredThe listing has expired
63AliasHasPendingListingThe alias has an open listing; cancel it before closing or transferring
64DomainHasPendingListingThe domain has an open listing; cancel it before closing, transferring, or deactivating
65ListingHolderMismatchThe 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:

CodeVariantMessage
84InstructionMovedThis instruction has moved to the domain program

Raised by the mail program for the sixteen retired domain discriminants.

The participant-beacon tail (item 43):

CodeVariantMessage
86ParticipantBeaconAccountInfoFailed to retrieve participant-beacon account info
87ParticipantBeaconAlreadyExistsThe wallet already has a participant beacon
88ParticipantBeaconNotInitializedNo participant beacon exists for this wallet
89ParticipantBeaconRequiresMailboxA participant beacon requires the wallet’s mailbox to exist
90DetailCidTooLongThe 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:

CodeVariantMessage
91MailboxCloseAlreadyPendingA close is already pending for this mailbox
92NoPendingMailboxCloseNo close is pending for this mailbox
93MailboxCloseTimelockNotElapsedThe mailbox-close timelock has not yet elapsed
94InstantCloseDisabledInstant 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:

CodeVariantMessage
95SponsoredMailboxRequiresDomainA sponsored mailbox (payer ≠ owner) must name a domain
96UnauthorizedDomainSponsorOnly the named domain’s on-chain authority may sponsor a mailbox for another owner
97FunderAccountInfoFailed 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):

CodeVariantMessage
101SenderAttestationFeeAboveCapThe sender-attestation fee exceeds its protocol cap

Reputation-scaled first-contact pricing appended one more, the floor setter’s cap twin:

CodeVariantMessage
102ReputationFloorAboveCapThe 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, and the money-path hardening added two more — one guarding the admin reclaim tool, one the purchaser’s slippage ceiling:

CodeVariantMessage
105PinLeaseAccountInfoThe pin-lease account is missing from the instruction
106AdminCloseEscrowPresentThe admin-close target still holds escrow above its rent-exempt minimum
107PriceExceedsMaxThe stamp price exceeds the purchaser’s maximum

Code 106 is what makes AdminCloseAccount safe to expose: in all three programs it now 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. Code 107 is the purchaser’s side of the same 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 pointWhat it can do
MEComposeSessionHandlerInspect and annotate an outgoing message; add headers; block the send
MEMessageActionHandlerAct on an incoming message — move, flag, set a colour
MEContentBlockerBlock remote content (scripts, styles, images) in the message view
MEMessageSecurityHandlerSign, 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”

TokenLightDarkUse
background#FAFAFB#0B0B0Fpage background
surface#FFFFFF#16161Dcards, compose, popups
text#1A1A1F#E7E7EAbody text
primary (violet)#6D28D9#7C3AEDlinks, primary buttons, selection
accent (crimson)#E11D48#E11D48emphasis 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 S monogram (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 sithbit in 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 a light-dark() pair, so one reference works in both themes). The canonical artwork lives beside it in webclients/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/.rust themes; the full dark-side palette for the .coal/.navy/.ayu dark themes). mdBook can’t reach webclients/, 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.

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 / flowCLI commandMeasured CUFenced ceiling
SendMail (plain wallet-to-wallet) mail send23,61847,000
SendMail (relay from-address)mail send -f29,87653,000
SendMail (with bounty)mail send --bounty23,59247,000
SendMail (reply linkage)mail send --reply-to25,07548,000
ClaimBounty (domained operator-share path)mail claim-bounty24,84748,000
RefundBounty (expiry-gated sender reclaim)mail refund-bounty7,57931,000
DeleteMailmail delete15,11138,000
RefundMailmail refund16,21139,000
CreateMailbox (+ bundled CreateAlias) mailbox create32,08555,000
SetEncryptionKeymailbox key set10,81134,000
RequestCloseMailbox (opens the close timelock)mailbox close13,33436,000
CancelCloseMailbox (request aborted)mailbox close --cancel12,15635,000
FinalizeCloseMailbox (past the timelock; both rents refunded)mailbox close --finalize12,85836,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,25658,000
CreateAlias alias create14,88838,000
OfferTransferAliasalias transfer init18,89442,000
AcceptTransferAlias (zero-fee, flat fee paid)alias transfer accept13,83837,000
CancelTransferAlias (offer retracted past binding window)alias transfer cancel24,82848,000
ListAliasForSalealias sell20,30843,000
BuyAliasalias buy20,74744,000
CancelAliasListing (listing withdrawn past binding window)alias sell --cancel19,18342,000
SellAlias (open auction)alias sell --auction26,79850,000
BidAliasalias bid19,71543,000
SettleAuctionalias settle-auction26,67450,000
AuthorizeDomain domain create15,24238,000
ListDomainForSaledomain sell32,55356,000
BuyDomaindomain buy19,80743,000
CancelDomainListing (listing withdrawn past binding window)domain sell --cancel16,00139,000
AttestSender (staged DNSSEC-proof verified sender)domain attest-sender322,474345,000
RevokeSenderAttestation (holder-signed close)domain revoke-attestation11,22434,000
SetSenderAttestationFeepostmaster fee attestation6,54630,000
SetReputationFloorpostmaster fee reputation-floor6,75630,000
CreatePinLease (per-CID storage deposit)mail lease create34,22157,000
ClosePinLease (holder-signed drain)mail lease close7,31630,000
SetPinLeaseFeepostmaster fee pin-lease6,96230,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/:

  1. Each flow is driven end-to-end through the compiled sithbit CLI; the transaction signature is captured from the explorer URL the CLI prints.
  2. The landed transaction is fetched with getTransaction and the consumption read from meta.computeUnitsConsumed.
  3. 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-preflight lets 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

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 AuthorizeDomainByProof path that verifies a real DNSSEC chain-of-trust on-chain and mints the domain with no postmaster signature.

What ships and runs today:

  • the PostOffice account carries a governance-set root KSK fingerprint, published by the delegate-gated SetRootKsk instruction (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 transaction packet limit — 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 chunked WriteProofWitness writes;
  • the permissionless AuthorizeDomainByProof instruction then reads that buffer and runs the verifier — program_common::dnssec::walk_chain: RRSIG canonicalization (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-free sol_big_mod_exp syscall, and ECDSA-P256 (13) and Ed25519 (15) via Solana’s native precompiles introspected through the Instructions sysvar — DS delegation checks root → TLD → leaf, validity-window checks, and finally the leaf _solana.authority.<domain> TXT RRSIG — and on success creates the MailDomain with the proven ed25519 authority (base58 from the TXT). The old ProofVerificationDisabled gate 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 AuthorizeDomainByProof transaction must prepend a ComputeBudget set-compute-unit-limit instruction. The verifier calls sol_big_mod_exp, so the target cluster must have the enable_big_mod_exp_syscall feature 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 with solana feature status | grep big_mod_exp. Because the loader resolves every syscall in the whole binary at deploy time, this makes the default domain_program build (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 authorize stages the witness buffer itself (chunked WriteProofWitness), prepends the ComputeBudget limit, and for a chain with non-RSA links derives and emits the precompile proofs too — it re-walks the witness client-side through the same program_common chain walk the program runs, collects each delegated (public key, canonical message, signature) tuple, and attaches one native Secp256r1SigVerify/Ed25519SigVerify instruction per non-RSA RRSIG plus 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 fee domain create charges — 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 400k ComputeBudget limit keeps ~28% headroom over the worst measured shape. See the Authorize a domain by proof how-to and HANDOFF.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 is B_dns(world) = ("owns", domain, k) iff the live DNS of world binds _solana.authority.<domain> to k. Note well: B is 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 w that 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 a w exists.

  • Def. VI — Capability vs. Exercise. To have the capability for B is to be able to produce B’s output on demand. To have faithfully exercised B is 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.

  1. The recipe is not the meal. For the Verifier to check hash(B), the canonical bytes of B must be public; hence hash(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.)
  2. Opacity (Law II). Even given the Agent’s true bytes, deciding whether they compute B is undecidable. The test is therefore at once too strict — it rejects an Agent that computes B correctly but was compiled differently — and too weak — it accepts an Agent that merely contains B’s bytes yet never calls them, or calls them and discards the result.
  3. The absent world. B_dns depends 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_dnsprovable. 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 saw A execute, 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.

  1. Split the instruction. Introduce a new on-chain path — call it AuthorizeDomainByProof — beside today’s delegate-signed CreateDomain. 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 (respecting mail_model’s ABI-stability rule and SithBitError’s append-only discipline).
  2. 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 the PostOffice account and rotated by the delegate at governance pace.
  3. 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.
  4. 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 CreateDomain instruction 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_program and alias_program are 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_syscall is active. --no-default-features is a deploy-target build, not a new default. mail_program and alias_program need no equivalent — their default builds contain no sol_big_mod_exp reference 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-signed CreateDomain path.
  • Even if the six instructions were somehow reached, a binary without sol_big_mod_exp can 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

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): MaiLrDyjMHm7zC5yak9jmqDHctHXAgV3C1cFHW7Yd6f
  • alias_program (devnet): ALiasqsSbBw3txjZi6EqfcxFHc4sMYKRSVzDQdpG1X6S
  • domain_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.
#    --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> -k <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

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_auth at spf or 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 under p=quarantine is filed into the recipient’s Junk folder (and p=reject bounces), honoring pct sampling — a direct mitigation against forged-from relaying. 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 below 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 same trust extends to the domain-scoped alias namespace: the domain authority alone may map user@its-domain to a wallet, and may repoint or remove that mapping at will. A recipient who accepts [email protected] as an identity is trusting acme.com’s authority to have pointed it at the right wallet — including for lockbox sealing, where the mapping decides which key a sender seals to. This is the intended model (an organization issues addresses under a domain it controls), but a compromised or malicious authority can redirect its own domain’s addresses; it cannot touch the global alias namespace or any other domain’s.

The same mapping now also governs incoming mail. A SithBit MX resolves an inbound RCPT user@its-domain through the domain-scoped mapping before delivering, so the authority decides not just which key a sender seals to but which wallet actually receives mail for each local-part it issues. This is the delivery counterpart of the alias trust above — the same authority, the same blast radius (its own domain’s local-parts, nothing else), now extended from native resolution to inbound relay.

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 7489 §7.3 content-minimization default: 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 = true is an explicit operator opt-in that leaks the full message. It attaches the complete message/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’s ruf= operator the entire failing message.
  • The §7.1 gate limits who can receive reports. Before sending to any ruf= address outside the policy domain, the server enforces the RFC 7489 §7.1 external-destination authorization check (the target must publish a <policy-domain>._report._dmarc.<target> record). An attacker cannot point a victim domain’s ruf= 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 the from claim, 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 (default OPERATOR_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-day hard 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-auction can 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.

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 seven more 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 from address 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 [email protected][email protected] 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):

  • CloseFrombox returns 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’s CloseFrombox still 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 through AddStamps.

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.

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)

AccountPublic fieldsNotes
Mailboxmail_count, default_postage, domain, no_ipfs1:1 with a wallet via its PDA, so the owning wallet is public too. no_ipfs reveals your storage preference.
Mailbox keypub_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_hashNo address strings. Recipient = the wallet the PDA seeds on. Human From:/To:/Subject: live only in the sealed body. Survives DeleteMail.
Fromboxrequired_postage, stampsKeyed by a PDA on (blake3(from), recipient wallet) — the from string is never stored, only its hash.
Aliasaddress (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.
MailDomainis_active, authority, rent_payer, domain (cleartext DNS name)The domain name is stored in the clear.
Postofficefee fields, root_ksk, delegate_address, commitment_rootThe postmaster (owner) key is not on-chain — only an opaque Merkle commitment_root. See the postoffice admin keys.
AliasListingholder (seller), price_lamports, created_at, expires_at, mode, antisnipe_window_secs, high_bid_lamports, high_bidderMarketplace state, including the winning bidder’s wallet — who is buying what, for how much.
DomainListingholder (seller), price_lamports, created_at, expires_atSame, for domains.
DomainAliasaddress (the target wallet)The domain-scoped alias→wallet mapping, as public as the global one. The alias name is the PDA seed, so it is effectively public too.
AliasEscrowrecipient, fee_lamports, offered_at, expires_atA pending alias hand-off: who the alias is offered to, at what fee, and the offer’s window.
AliasBidbidder, amount_lamports, bid_atAuction state: every bidder’s wallet and bid amount are public while the auction runs.
ParticipantBeacontags (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.
SenderAttestationdomain (cleartext DNS name), wallet, attested_atPublicly binds a sending wallet to a domain — that is its purpose (the client trust mark). One account per (domain, wallet).
SenderReputationwallet, cumulative_spend_lamportsA sender wallet’s cumulative first-contact postage spend is public; the individual recipients behind it are not stored here.
PinLeasecid_hash (blake3 of the leased CID), holder (wallet), created_atPublicly 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.
PendingMailboxCloserequested_atThe PDA seeds on the closing wallet, so that a wallet is closing its mailbox — and when the timelock lapses — is public.
PendingDeactivationrequested_atSame shape, for a domain sitting in its deactivation notice window.
PendingReclaimrequested_at, authority, payerA 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. Concretely, one send exposes: the recipient wallet (the PDA seed), the sender wallet, a timestamp, the blake3 hash of the from address (not the string), and the storage locator (an IPFS CID, or a b3: marker when the recipient set no_ipfs). 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_ipfs keeps 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 from claim the chain trusts. This is a trusted role — see the domain authority is fully trusted for relayed mail.

See also

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_programMaiLyqjRuHp8SSQHjiLMPmhBcuLitSta4YdoTiibXu4
  • alias_programALiasg6qDnwcY8HfyeC1AjXRFjyqpXxW4omtwF1i125q
  • domain_programDmaiNcmXsPw2juV9JoZSC47V5epAysQi3DJVk3fiBuUv

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-M signers 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.

Open question for a specific deployment: whether SithBit’s canonical network intends to freeze the programs (immutable, no bug-fix path) or hold the authority in a multisig/DAO (upgradeable, trust the signers) is a governance decision, not a protocol fact this repository can settle. Evaluate the live authority, not this page’s aspiration.

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 authorityDelegate keyPostmaster (ceremony seeds)
What it controlsThe program bytecode — every rule Domain authorize/deactivate (timelocked), capped fee retune, root KSKPostoffice sweep, delegate rotation, ownership handover
Blast radius of compromiseRewrites all protocol rules; can invalidate every on-chain guaranteeOperational outage only — no path to funds or ownershipFull postoffice ownership, within the current rules
Bounded byOnly its custody (nothing on-chain caps a redeploy)On-chain fee caps, the deactivation timelock, one-delegate revocabilityThe Merkle commitment: each key usable once, set rotates on use
RecoveryRedeploy fixed bytecode — if the authority is still trustworthyOwnership-signed delegatecommitment 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

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 terms) 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-keygen or sithbit wallet produce, 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:

  1. Generate a brand-new X25519 keypair, used for this one message only.
  2. 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.
  3. Encrypt the plaintext with that shared secret using the XSalsa20-Poly1305 stream cipher, producing ciphertext plus a 16-byte authentication tag.
  4. 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:

  1. 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).
  2. 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.
  3. 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.

Further reading

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.

Three inputs of very different lengths each pass through blake3 and each produce a digest of exactly 32 bytes. Two of the inputs differ by only one letter, yet their digests share nothing — the avalanche effect. marshall marshalL arbitrarily long input — a whole file works too blake3 8f9b098fe8…e81b1aa310 14d9e93e9c…6d64ccbf36 10debc9c08…4b5b280d68 one letter changed — nothing in common always exactly 32 bytes

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

Hashing an input into a digest is one cheap forward pass. The reverse direction is crossed out: no algorithm recovers the input from the digest — an attacker can only guess candidate inputs and hash each one to check. [email protected] e72e1cd7…21912055 hashing: one cheap pass nothing computes the input back — only guess-and-check

Why SithBit hashes things at all

Three recurring jobs in the protocol are hash-shaped:

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

  2. 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 from address, 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.

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

Hash as a checkable commitment. When a reply is sent, the parent message account key is hashed with blake3 and the digest is stored in the reply as reply_to_hash. When the bounty is claimed, the program hashes the presented account key again and requires the two digests to match. 1. Sending the reply — commit to the parent's key bountied message account 7gWc3P…J8kQhx blake3 stored in the reply account: reply_to_hash = 057b93…13ba3a must match 2. Claiming the bounty — the program recomputes and compares the same account key, taken from the claim's account list blake3 057b93…13ba3a anyone can recheck the link; nobody can forge 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’s sol_sha256 syscall 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.
blake3's internal Merkle tree: the input is split into 1 KiB chunks, each chunk is hashed independently, pairs of chunk hashes are combined into parent hashes, and the parents fold into a single 32-byte root digest. chunk 0 (1 KiB) chunk 1 chunk 2 chunk 3 h0 h1 h2 h3 parent(h0, h1) parent(h2, h3) 32-byte root digest

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 hashedWhat the digest becomesChapter
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 prefixesAliases
Domain name (lowercased)The domain account’s PDA seed; pending-deactivation and domain-listing PDAs reuse itDomains
Normalized from addressThe frombox PDA seed, and the from_hash stored in every message account — the plaintext address never reaches the chainFromboxes, Sending mail
Bountied message account key (base58 text)reply_to_hash in the reply — the privacy-preserving claim linkageReply bounties
Claimed domain (lowercased)Part of the proof-witness buffer’s PDA seed, so each claimant stages into their own deterministic bufferAuthorize a domain by proof
Attesting domain (lowercased)Part of the sender-attestation PDA seed, combined with the attested wallet — one attestation per (domain, wallet) pairAttest 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-leafPostmaster 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

ClusterPublic RPC endpointWhat it’s for
devnethttps://api.devnet.solana.comThe developer playground. State can be reset, and SOL is free via faucet airdrops — nothing on devnet has real value.
testnethttps://api.testnet.solana.comWhere 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-betahttps://api.mainnet-beta.solana.comThe 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

See also

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:

  1. 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 — the keypair described 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.
  2. 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 — GetPinLease as a filtered getProgramAccounts scan for a CID’s leases, GetSenderReputation as 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 (ListAuthoritativeDomains answers “which domains is my signing key authoritative for”).
  3. The only home of the off-chain alias/sales indexer. ListAliases and ListSales are 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 keypair is 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 in domain-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.

Who consumes it — and who never does

Every consumer is another server; no end-user client ever dials the gateway:

  • sithbitd is the only writer. Its chain workers drive SendMail and DeleteMail off store-backed job queues, serialized per wallet by a store lease — which is why a cloud-store fleet of many sithbitd instances 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-server MX performs read-only postage verification (ResolveAlias + GetFrombox per recipient).
  • account-api routes compose recipients (alias → postage check) and backs its /v1/chain REST 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 reads GetMailbox/GetFrombox directly.
  • 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 gRPC surface carries no TLS and no authentication — anyone who can reach the port can sign with the gateway’s key. That is acceptable under exactly one posture, which is now the documented requirement:

mail-grpc must only be reachable on the private network segment where the other SithBit servers run. Bind it to loopback on a single box, or to a private interface/subnet in a fleet — never to a public address. It is server-to-server plumbing, not a public API.

The shipped deployment already leans this way (the compose chain profile serves 127.0.0.1:50051), and the iac/ templates’ opt-in mail-grpc units are the posture’s reference implementation at fleet scale: a private subnet the operator brings, no public ingress path, in both clouds. 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 surfaceKey custodyTest seamDep graph
A. Keep separate (chosen)one extra process (chain profile only)one AKV-capable holderintactconsumers stay proto-only
B. Embed in every consumer−1 process, but the indexer needs a new home and protocolkey copied to N processesall in-process fakes destroyedSolana stack linked everywhere, incl. RCPT-only MXes
C1. Writes into sithbitd only2 chain-access paths foreverkey copied to every workerwrite-path fakes brokensithbitd links the full stack
C2. Optional in-process hosting in sithbitdboth hostings maintained foreverunchangedintactsithbitd 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 (no private network available): unauthenticated remote signing becomes untenable — add authentication, or revisit C1.
  • 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 web surface (item 45) has since landed — see the shipped web surface; the CLI campaign tree (item 44) is in progress.

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_model constants (interests, skills, broad demographic bands — 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 sithbit command: it scans trustlessly with getProgramAccounts + 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:

  1. search — filter participants by tags (trustless scan);
  2. quote — price the campaign before sending: N × (message rent + each recipient’s postage + bounty + fees);
  3. 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 the default_postage identity.

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:

  1. Item 43 — the on-chain participant beacon (LANDED, v0.9.0): mail_model account + tag vocabulary constants (32-byte bitmap, 24 starter tags, bits not validated on-chain), mail_program create/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 its default_postage (decision 5).
  2. Item 44 — sithbit campaign + profile authoring in the CLI: beacon create/update/close commands, detail-blob encrypt+pin, campaign search/quote/send.
  3. 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 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/chain surface with the session JWT — the index is global, the JWT only authenticates the request.
  • account-api proxies that read to mail-grpc’s ListParticipants RPC, 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 set tag bits, and whether it published an off-chain detail document (the sealed detail CID of decision 2). Filtering is by a comma-separated list of tag bit positions (e.g. 0,10), applied with Apply.

Known limitation — tags render as raw bit positions. The browser clients have no shared tag-name vocabulary yet, so both the filter input and each row’s tags show the numeric bit positions of the on-chain bitmap, not human labels. The names live only in the mail_model TAG_* constants (program reference); wiring a JS-side name map onto those positions is deferred. The group-offer (quote/send) authoring flow of decision 4 also stays CLI-first for now (item 44) — the tab is a browse-and-discover surface, not yet an authoring one.

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

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. 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 get and mail pin degrade honestly. Point either command at an opted-out message and, rather than a confusing gateway 404, 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 send refuses an opted-out recipient. sithbit mail send computes and pins the body client-side with no operator store behind it, so it cannot honor the opt-out — the copy would end up on public IPFS against the recipient’s wishes. It therefore 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

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 in mail_model and solana_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_api gRPC 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_seal to 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 Email account’s cid field is an opaque byte string as far as mail_program is concerned — the program never touches IPFS and never validates that a cid corresponds 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 7489) evaluation and disposition — alignment folding to organizational domains, p=reject bounces, p=quarantine → the recipient’s Junk folder, and pct sampling — selectable via sender_auth = "dmarc". It also emits RFC 7489 §7.2.1 aggregate (rua) reports (gzip XML, one per policy domain per interval, with the §7.1 external-destination check enforced on every rua target) when aggregate reporting is enabled. It also emits RFC 7489 §7.3 failure/forensic (ruf) reports when forensic reporting is enabled — one RFC 5965 ARF message/feedback-report per DMARC failure, sent direct and best-effort to the domain’s ruf addresses, headers-only by default (text/rfc822-headers) with the full message opt-in, and the same §7.1 external-destination gate applied to every ruf target (a target that fails the live-DNS authorization check declines the report). The receiving side — ingesting other operators’ aggregate reports about your own domains — is an optional operator feature, not a conformance requirement; see RFC 7489 §7.2 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:

  1. 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.
  2. The reference implementation itself defaults to no swarm. sithbitd’s embedded IPFS node (ipfs_daemon/ipfs_swarm) ships with swarm = None — no libp2p, no Kademlia DHT, no bitswap — unless an operator explicitly configures [swarm] with public listen addresses and provide = true (see sithbit-ipfsd). Out of the box, sithbitd already 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 (and MAIL FROM) before STARTTLS with a 530 5.7.0 Must issue a STARTTLS command first, and hides the AUTH capability from the EHLO response so clients aren’t invited to authenticate in the clear. The gate is the require_tls && !tls_active check in smtp_session/src/session/ready.rs (the auth and mail handlers), and the server default is require_tls.unwrap_or(mode == Submission) in smtp_server/src/config.rs — on for the submission edge.
  • IMAP advertises LOGINDISABLED in the pre-TLS CAPABILITY and refuses LOGIN/AUTHENTICATE with NO [PRIVACYREQUIRED] (RFC 5530) until STARTTLS completes. The gate is credentials_refusal in imap_session/src/session/not_authenticated.rs; require_tls defaults to true in imap_server/src/config.rs.
  • POP3 refuses USER/PASS before STLS with -ERR Must issue STLS command first, and discards any pre-TLS USER after the upgrade (a STARTTLS injection defense). The gate is tls_gate in pop3_proto/src/session/authorization.rs; require_tls defaults to true in pop_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.

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 of https://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_enforced branch in mail_spooler/src/pipeline/relay.rs: only MX targets matching the policy’s mx patterns are dialed, each demanding STARTTLS with a certificate verified against the webpki roots for the MX hostname (TlsVerify::Strict in mail_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’s id for 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), serving GET /.well-known/mta-sts.txt for 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 unknown mode name, an enforce/testing policy with no mx pattern (§3.2 requires one), or a max_age above 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 fetch https://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 its id bump 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::Dane in mail_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 its mx pattern 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), or no-policy-found — and either a success tally or a §4.4 failure-details block. 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) records dnssec-invalid with a bare tlsa policy block, and an MX target outside an enforce-mode MTA-STS policy records sts-policy-invalid rendering the enforce policy body — the planner’s diagnostic rides failure-reason-code. Retries re-record, and identical rows aggregate by failed-session-count at 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: at each interval_hours tick it folds the pending rows into one RFC 8460 report per recipient domain, discovers the domain’s rua= 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 configured email must be a local, DKIM-signable address; its domain doubles as the report’s submitter identity), and https: targets receive the gzip-compressed JSON directly as an application/tlsrpt+gzip POST (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 no rua= 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 general validation-failure code, with the raw TLS error detail preserved in failure-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, with starttls-not-supported failure rows reserved for enforced postures that abort. Unreachable or timed-out hosts still record nothing, and MTA-STS testing-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 rua ingestion below). To request reports about a domain you operate, publish the TLSRPT record — see DNS setup.

RFC 7489 §7.2: ingesting DMARC aggregate reports

The emitting side of DMARC reporting is covered above; the reference server also implements the receiving side of RFC 7489 §7.2 — 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_wallet setting implements the RFC 5321 §4.5.1 postmaster exemption in the SMTP driver (smtp_server/src/driver.rs): RCPT to bare postmaster or postmaster@<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 7489 parser — MIME wrapping and gzip/zip report bodies handled — and stores the parsed report, serialized verbatim as JSON, at blob key dmarc_rua/<id>.json. The id is org_name!report_id!begin!end per the §7.2.1.1 filename convention, 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-reports list, GET /v1/admin/dmarc-reports/{id} fetch) read the stored JSON back — see account-api, and DNS setup for wiring the rua= 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 ruf ingestion. 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 SignedDelegation in a custom X.509 v3 extension under OID 1.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 delegation binds {domain, proto, node_pubkey, expiry} and chains to the domain’s on-chain MailDomain.authority.
  • The client verifies against the chain, not a CA or the hostname. A conforming client (SithBit’s NodeDelegationVerifier, a rustls ServerCertVerifier) 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 delegated node_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/AliasInstruction transactions (mail_model, solana_common).
  • Check and decrement frombox stamps before accepting mail (mail_api gRPC).
  • Seal bodies with crypto_box_seal to 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, IMAP LOGIN/AUTHENTICATE, POP USER/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 SRV records — 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, the sithbit discover resolver, 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 MailDomain account’s recorded authority on-chain.
  • Clients discover a domain’s nodes via a DHT lookup + on-chain verification, with no public DNS SRV query.
  • 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:

PrimitiveIn 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 onipfs_swarm runs libp2p Kademlia with put_record/get_record and a validation hook.
Fleet membership for one operatorThe IPFS clustering wave: shared-bucket membership roster + rendezvous-hash keyspace partition.
Shared state across nodesThe 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 58888 arc 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.

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

KeyDefaultMeaning
service_record_ttl_secs900 (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_secs300 (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.
  • A cross-process MailboxNotify seam — MailRepo::await_change, with a poll-backed default over change_seq (the DEFAULT_WATCH_POLL interval, 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 (Postgres LISTEN/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_change seam are shipped, but native cross-process IMAP-IDLE push per backend is still deferred — the change_seq poller 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 SRV is 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.1 extension 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.
  • Resolver, not client-native discovery. sithbit discover is 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 sithbitd existed 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 and sithbitd.

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

  1. A running account API (standalone account-api, or the one inside your sithbitd deployment) reachable from the console’s machine.
  2. Your wallet on the API’s admin allowlist. The console logs in with a Solana wallet keypair; the API only serves /v1/admin routes to wallets listed in its admin_wallets setting. An empty admin_wallets list disables the admin surface entirely — every admin call (and therefore every console pane) replies 403.
  3. The wallet’s keypair file on the console machine (a standard Solana 64-byte JSON keypair; default ~/.config/solana/id.json).
  4. 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:

  1. loads its configuration (next section),
  2. 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,
  3. 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
  4. 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:

ColumnMeaning
uidThe message’s IMAP UID in this mailbox
sizeStored size in bytes
dateThe message’s internal date (first 19 chars, YYYY-MM-DDTHH:MM:SS)
chainThe delivery-pipeline chain statelocal (dark gray) for a copy with no chain record
cidThe 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), where oldest is 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), where type is the payload’s job type tag.

Two actions work the dead-letter table, both requiring confirmation:

  • urequeue the selected job (put it back on its queue for a fresh attempt, e.g. after fixing the outage that killed it).
  • xdiscard 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

KeyContextAction
j / everywhereMove selection down
k / everywhereMove selection up
EnterAccounts tabDrill down (wallet → mailboxes → messages)
bAccounts tabBalances pane for the selected wallet
EscAccounts tabStep back up one level
TabeverywhereSwitch between the Accounts and Queues tabs
reverywhereReload the current pane
uQueues tabRequeue the selected dead job (asks y/n)
xQueues tabDiscard the selected dead job (asks y/n)
yconfirmationConfirm the armed requeue/discard
qeverywhereQuit

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/admin surface 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

SymptomLikely 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 errorThe 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 finegateway_endpoint / rpc_url not reachable — expected on an admin-only setup; the pane needs both
Requeue/discard fails after sitting on the paneThe 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 0 for now.
  • 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.
  • PATCH — documentation-only or otherwise non-behavioral changes.

A run of documentation-only edits between behavioral changes keeps the same version across several dated sections: the version tags the protocol state, the date tags when the docs moved.

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 ReclaimFromboxStamps instruction (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. CreateFrombox and AddStamps gained an additive max_price_lamports field; 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 stamp pins the ceiling to the price it just quoted by default, with --max-price to pre-authorize a rise and --no-max-price to 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), so postmaster reclaim reaps 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. PendingReclaim records the wallet that funded the request, and domain reclaim --finalize pins 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, 106 AdminCloseEscrowPresent and 107 PriceExceedsMax are 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.py guarded exactly one of mail_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-test proving 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.md and proving-behavior.md each 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.png was 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 #/settings hash (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.png and marketplace-listings.png re-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-in mail-key1 test 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/.key PEMs 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 SolanaMail RPC rosters are now gate-fenced. A new docs-gate leg, mail_docs/check_rpc_rosters.py, diffs mail_api/README.md‘s flat RPC list and the gateway topology appendix’s three role buckets against the service definition in mail_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’s GetPinLease, 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 shooting webmail-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 GetSenderReputation RPC on the SolanaMail service: wallet in, the recorded cumulative postage spend and the effective first-contact rate in bps out — the same two figures as the CLI’s sithbit 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 as UNAVAILABLE rather 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’s GetPinLease). 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-cert and 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 --out is 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>.p12 first, ahead of the PEM pair. The client pages’ certificate-login walkthroughs drop the manual openssl pkcs12 -export conversion 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 via SetPinLeaseFee / read-only postoffice fee pin-lease) split with the recipient’s domain authority at the operator share. New instructions CreatePinLease (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 GetPinLease RPC 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 attestation joins 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 the fee settlement getter it had omitted.
  • Reference de-staling: the mail-grpc topology appendix’s chain-read role now counts all eleven read RPCs (it omitted ListParticipants and GetSenderAttestation), 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 SenderReputation account (new mail-side sender_reputation PDA seed — see the PDA seeds table) records the wallet’s cumulative distinct-recipient postage spend at CreateFrombox time, 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 SetReputationFloor instruction (discriminant 48, sithbit postmaster fee reputation-floor <BPS>) tunes reputation_floor_bps (default DEFAULT_REPUTATION_FLOOR_BPS = 1,000 bps = 10% of the recipient’s default postage, capped at MAX_REPUTATION_FLOOR_BPS = 10,000; over-cap refuses with new custom error 102 ReputationFloorAboveCap, 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: CreateFrombox accepts 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-only sithbit 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 optional attestation parameter and emit the 9-account reputation tail on third-party creates; existing JS callers are unaffected.
  • The compute-unit table’s CreateFrombox row 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) — and SetReputationFloor lands 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 authorize rides — and mints a SenderAttestation record binding the domain to a wallet, the protocol’s trust mark for organizational senders. Attesting requires no MailDomain account 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) and RevokeSenderAttestation (18, holder-signed close with rent refund; the PDA re-derives from the signer, so no other key reaches the record), plus the new sender_attestation PDA seed — see the program reference and the new blake3 table row.
  • The attestation fee is delegate-tunable: new mail-side SetSenderAttestationFee instruction (discriminant 47, sithbit postmaster fee attestation <LAMPORTS>) tunes the one-time fee AttestSender pays the postoffice (default DEFAULT_SENDER_ATTESTATION_FEE_LAMPORTS = 0.01 SOL, capped at MAX_SENDER_ATTESTATION_FEE_LAMPORTS = 0.1 SOL; over-cap refuses with new custom error 101 SenderAttestationFeeAboveCap, 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 new GetSenderAttestation call — {domain, wallet}{attested, attested_at}, where a clean absence answers attested = false and a failed chain read is UNAVAILABLE, never a false. 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: AttestSender 322,474 CU measured / 345,000 fenced, RevokeSenderAttestation 11,224 / 34,000, SetSenderAttestationFee 6,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>.key pair in-extension — byte-identical to sithbit 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)

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/AddStamps accept 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 receives operator_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.
  • FromboxResponse can now say “fee unknown”. New bool stamp_fee_known (field 6) on the gRPC response: false means 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_tx and their unsigned twins) gained trailing optional operator_domain/operator_authority parameters (both-or-neither); existing callers are unaffected.

2026-07-21 — v0.36.0 (Core Concepts go GUI-first, with concept graphics)

2026-07-21 — v0.36.0 (glossary: sans-io)

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/CreateFrombox pay the greater of the flat per-stamp fee and a bps share of the escrowed postage (stamp_fee_bps, default DEFAULT_STAMP_FEE_BPS = 100 = 1%, capped at MAX_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_BPS split at DeleteMail settlements, reply-bounty claims, escrowed alias transfers, marketplace sales, and auction settlements now reads the postoffice’s operator_share_bps field (default 1 000 = today’s behavior, capped at MAX_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 SetSettlementBps instruction (discriminant 46, delegate-only) sets both rates in one instruction; over-cap rates refuse with new custom errors 99 OperatorShareBpsAboveCap / 100 StampFeeBpsAboveCap. 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 stamp prints both arms, the new sithbit 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 gRPC FromboxResponse gained stamp_fee_bps, and the webmail prepay card and onboarding funding page price quotes through a new wasm stamp_purchase_fee export.
  • 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 new SetAliasTierFees instruction (discriminant 45), each slot capped at 10× its default (MAX_ALIAS_TIER_FEES_LAMPORTS; over-cap refuses with new custom error 98 AliasTierFeeAboveCap). 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 create prints a fee preview (per premium name + run total, or the delegate waiver notice), sithbit postoffice fee alias prints the effective per-length schedule with its caps, and the new sithbit 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 (also Spam), 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; the LIST (SPECIAL-USE) selection filter and CREATE-SPECIAL-USE are 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 the AUTH= 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”).

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 to sithbit-ipfsd or 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.md placement 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 uninstall subcommands install the daemon under systemd (a generated unit file with restart-on-failure, network ordering, and commented unprivileged-user/low-port-capability lines; --print renders it without writing) or the Windows service control manager (an auto-start registration whose internal service run verb re-anchors the recorded working directory and config before the daemon boots). Neither install activates anything behind the operator’s back — the systemctl / Start-Service step 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 campaign reference 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 Mailbox account gains a tail funder field (shown by mailbox get and the wasm account decoder); mailbox close --finalize now 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 the CreateMailbox/FinalizeCloseMailbox rows. This retires the last TODO in 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-console admin 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 the admin_wallets allowlist), 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 GetMailDomain authority), not merely have the server serve it. The pages now state that nuance at end-user altitude and add the matching 553 5.7.1 refusal 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 setup wizard 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 in local_domains; the domain must also be one the wallet is the recorded on-chain authority for, checked via the gateway’s GetMailDomain lookup (exact base58 match). local_domains still 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 to local_domains alone, 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 → SendMail lifecycle 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 (default http://127.0.0.1:8182 — an unauthenticated loopback sithbit-ipfsd) and its optional ipfsPinToken (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 CID entry now links to it.
  • Docs-tooling: the mailbox-close timelock figure is now fenced. A new mail_docs/check_timelock.py gate leg parses MAILBOX_CLOSE_TIMELOCK_SECS from mail_model/src/constants.rs and 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.py gains a --self-test leg. A checked-in, build-free fixture tree under mail_docs/tests/anchor_fixtures/ (a clean/ root that must exit 0 and a broken/ root that must exit 1) proves the checker’s exit-code contract, mirroring check_links.py --self-test. Unlike the link fixtures, each anchor-fixture root ships both a src/ and a hand-authored book/ HTML tree, because the checker validates #fragment links against built-book anchors. It is a standalone dev command, not wired into the docs-gate chain; the default check_anchors.py run 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 CloseMailbox as an instant one-step close; the mailbox and key legs now carry their own timings.
  • local_domains on 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 for TransferAlias / UnilateralTransferDisabled (85). MAJOR stays 0: the protocol is pre-launch.
  • New errors 91–94: MailboxCloseAlreadyPending, NoPendingMailboxClose, MailboxCloseTimelockNotElapsed, InstantCloseDisabled. New PDA seed PENDING_MAILBOX_CLOSE_SEED (pending_mailbox_close) — the prefix is load-bearing twice over: the Mailbox PDA is bare-seeded on the address, and PendingMailboxClose, PendingDeactivation and PendingReclaim all 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.
  • CloseKey deliberately 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: Alice and alice decode 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_domains empty the check falls back to hostname, which the empty-config dev stack leaves as localhost, and the chain-disabled dev stack never discovers domains — so sending as <wallet>@sithbit.net there now returns 553 where 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 own local_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 (default false). 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 to sithbitd and the standalone smtp-server alike. 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):

  • RequestDeactivateDomain refuses while a listing is open. The instruction now takes the domain-listing PDA as a required read-only account and refuses with DomainHasPendingListing (code 64) when a marketplace listing stands — a deactivation can no longer be staged under a live listing. The sithbit domain deactivate builder passes the new account. See Program reference → Marketplace.
  • BuyDomain refuses an inactive domain. Settlement now re-checks is_active at purchase time and refuses with InactiveDomain (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: DeactivationAlreadyPending is code 29 (the page said 28), and BuyDomain’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 at mail_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 both cp staging 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.sh rebuilds before probing — the script now brings the compose stack up with docker compose up -d --build, so a standalone smoke run rebuilds the images instead of silently probing stale local ones (the already-exported chain profile covers the mail-grpc build 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_daemon and ipfs_gateway crate READMEs and a check_config_keys.py comment; 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) and awsconf/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 purposeful NotCompiled error naming the cargo feature to rebuild with.
  • mail_store’s key-source edge is feature-forwarded — its formerly unconditional dependency on all of key-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.sh now boots the chain profile live — it exports COMPOSE_PROFILES=chain for every compose call it makes (teardown included), so mail-grpc starts 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-level COMPOSE_PROFILES builds 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_tls switch — default false; 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-invalid with a bare tlsa policy block, an MX target outside an enforce-mode MTA-STS policy records sts-policy-invalid rendering the enforce policy body, with the planner’s diagnostic in failure-reason-code. Unreachable/timed-out hosts and STS testing-mode mismatches still record nothing — see the configuration reference.
  • New [spooler] report_retention_days setting — default 0 = 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 data GET /v1/admin/dmarc-reports serves, 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.

PATCH — documentation only (no code change):

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 dane enforcement 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 both mailto: (DKIM-signed via the outbound relay) and https: (application/tlsrpt+gzip POST). 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 fixed dmarc_rua/ blob prefix — additive to delivery, never a diversion), and the account API’s GET /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). No ruf ingestion, no blob pruning yet.
  • postmaster init claims the postmaster alias — the init transaction now atomically registers the global postmaster alias 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 legmail_docs/check_config_keys.py diffs 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 gatesithbitd.example.toml gained the documented-but-missing [store.cloudflare] backend block, client_cert_auth on the three authenticated listeners, [smtp.server] limits.*, and the [swarm] service-record freshness settings (the last also added to sithbit_ipfsd.example.toml); both IPFS binaries’ examples gained observability.otlp.metrics_interval_seconds; the reference gained store.aws.sqs_wait_time_seconds and the gateway’s public_host, and the [store.blobs] s3/azure key lists are now machine-checkable code spans.
  • Example-value fixdomain_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 readGET /v1/chain/account/{address} on the account API returns any raw account verbatim (owner base58, data base64; 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_at on the anonymous DND checkGET /v1/dnd/{wallet} now carries the RFC 3339 UTC instant the wallet accepts mail again, gated three ways: currently excluded, owner opted in via expose_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.

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.

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_url row in the [smtp]/[submission] table and a new one-setting-two-pages subsection, including the standalone-smtp-server scope note.
  • account-api: the DND schedule surface — the authenticated GET/PUT /v1/account/dnd routes, the expose_dnd_schedule flag on GET/PATCH /v1/account, and the changed public GET /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 DeleteMail settlement’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 id bump 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: the dane key 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 recommended 3 1 1 form, 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’s description tag (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; the appconfig-gen workspace 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 CHANGE dummies, 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.js hook, 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_trusted key 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 unless proxy_protocol is 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 beside proxy_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 .xpi stays 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-hook for 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 = true support 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/azconf features), 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 is app_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: the mta_sts key 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-sts TXT 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 existing s3 blob kind, no new backend); tables/leases/queue = kind = "postgres" against Cloud SQL; secrets = the v0.9.3 gsm key 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/gcp Terraform 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 the gsm keypair selector. iac/aws gains the matching mail_grpc_keypair_asm selector (task-role GetSecretValue) 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-vault now 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 akv line 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 campaign term 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 campaign CLI reference documents the whole tree — a participant’s create/update/close (opt in, rewrite, opt out; the advertised price is the mailbox default_postage, so a beacon needs a mailbox first) and a campaign wallet’s search/quote/send. search runs the trustless getProgramAccounts scan and recovers each match’s sendable wallet from the beacon’s on-chain owner field (the D-P1 append this session); quote prices the matched set term-for-term against the on-chain SendMail funding math; send executes 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 campaign bounty 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_tls enforcement guards — SMTP submission (530 with AUTH hidden from EHLO), IMAP (LOGINDISABLED + NO [PRIVACYREQUIRED]), and POP3 (-ERR Must issue STLS command first).
  • mail_spooler/sithbitd.example.toml grows the commented production implicit-TLS stack — [submission.server] / [imap.server] / [pop.server] on 465/993/995 with implicit_tls = true and matching [*.tls] — with the STARTTLS 587/143/110 listeners demoted to opt-in secondaries, and docker-compose.prod.example.yml publishes 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) across MailHostsConfig::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-TLS SRV records 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.

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.json no longer hashes webclients/shared wholesale for every client. Each of webmail, onboarding, and marketplace previously carried a blanket webclients/shared source 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 unrelated webclients/shared edit landed. Each client’s sources list now names only the shared files its app.js/index.html actually reach (traced via the import/fetch/ mount closure), while the client’s own directory stays wholesale (rglob’d) as before.
  • check_screenshots.py’s iter_source_files hashes an individual file directly when a sources entry names one rather than a directory, alongside the unchanged wholesale rglob. A new --selftest mode 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 campaign authoring surface, the advertised-price-is-default_postage identity, 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-to linkage, no bounty required); the bounty fields mirror --bounty/--bounty-window with 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 paritydomain-sithbit was the last service configured through a wall of env vars: docker-compose.prod.example.yml now mounts a real domain_sithbit.toml (via DOMAIN_SITHBIT_CONFIG; start from domain_sithbit/domain_sithbit.example.toml) plus a separate read-only delegate-keypair mount the TOML’s delegate_key_file names, 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_FILE var is retired from everything runnable: the production example’s crash-looping setting is gone, and docker-compose.yml’s stale comment telling operators to set it now points at the current contract (delegate_key_file in 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-grpc unit (deploy_mail_grpc in Terraform, deployMailGrpc in 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 and iac/README.md for the full parameter ↔ config mapping.
  • Keypair delivery splits per cloud, because the gateway’s keypair is 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 points MAIL_GRPC_KEYPAIR at 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 (the mailGrpcPrincipalId output 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 chain profile is operative again: docker-compose.yml wires mail-grpc through MAIL_GRPC_* overrides (bind, RPC URL, alias-index path), and the signing keypair is a file mounted read-onlySITHBIT_CHAIN_KEYPAIR names the host path (default: the checked-in devnet test key the retired .env flow held as JSON content) — keypair content in an env var is gone for good. SITHBIT_CHAIN_RPC still retargets the cluster; the OTLP overlay now sets MAIL_GRPC_OBSERVABILITY__OTLP__ENDPOINT.
  • The production example gains sithbitd/account-api parity: docker-compose.prod.example.yml mounts a real mail_grpc.toml (via MAIL_GRPC_CONFIG) plus a separate read-only keypair mount, with a minimal-TOML sketch and the Azure Key Vault alternative inline.
  • docker/smoke.sh now 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.sh and webclients/README.md — the last live dead-name consumers — swept onto the MAIL_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: sithbit CLI (Operations & infrastructure section), disambiguated from the external Solana CLI it’s a substitute for in the sithbit config/sithbit wallet create workflow. CLI Quickstart’s first mention of “CLI” now links into it, picking up the sitewide hover/focus definition preview js/glossary-tooltip.js already 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’s README.md to index.html regardless of SUMMARY.md order, so the new landing content took over the README.md filename and the Prelude moved to its own prelude.md file (rendering as prelude.html) to make room; SUMMARY.md still 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 new css/landing.css, scoped under .sb-hero/.sb-grid/.sb-card so 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-grpc moved 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_NAME companions), ALIAS_INDEX_DB, ALIAS_INDEX_POLL_SECONDS, ALIAS_CACHE_SECONDS, and HEALTH_BIND are no longer read. Config now comes from mail_grpc.toml (or MAIL_GRPC_CONFIG) with MAIL_GRPC_* env overrides; every key has a dev-friendly default, so an empty file runs a loopback gateway on 127.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 the sithbit CLI. The keypair-content-in-an-env-var shape is gone with it: the TOML keypair is 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 stay received; 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’s delegate_key_file is 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 every POST /domain (for akv, 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 means POST /domain replies 503.
  • HARD BREAK — the deprecated postmaster_key_file alias is removed (user decision 2026-07-15). The config struct rejects unknown keys, so a config still naming postmaster_key_file now fails startup loudly instead of being accepted with a warning. Operators must act: rename the key to delegate_key_file before 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-grpc stays 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 not domain-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 answers NO [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-migrate carries 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 prints accounts: 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 appends Reply-to-hash:, Bounty:, and Bounty-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 sithbitd or 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_seal is 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 AUTH whose 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.

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.

  • TransferAlias is 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. OfferTransferAlias no longer requires a positive fee: alias transfer init stages a free hand-off by default (--fee defaults to 0, and --expires-in no 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 AcceptTransferAlias charges the delegate-tunable ALIAS_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 init is one offer-staging code path (the accept surfaces the flat fee or its waiver before signing); mail_wasm retires transfer_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, ID DmaiNcmXsPw2juV9JoZSC47V5epAysQi3DJVk3fiBuUv (devnet twin DmaiNHGvprK2op7xqZHXp8UVXXmPUtkas96Goh5sCJQn), carries the domain lifecycle, the DNSSEC-proof authorize/reclaim flows, the domain marketplace, and its own AdminCloseAccount (discriminant 16) as DomainInstruction discriminants 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) and SetRootKsk (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-proof Cargo feature — and the sol_big_mod_exp deployability 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 describes domain_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’s POST /domain all follow the domain program transparently — account-meta lists are byte-identical pre/post split. sithbit postmaster reclaim now 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, and no_ipfs alongside mail_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.js bundle (web3.esm.js, ~682 KiB) is replaced by a codec-only bundle built from @solana/kit 7.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 the SendMail transaction itself. The recipient’s no_ipfs opt-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_secret on 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_key arguments; 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_id under [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; the no_ipfs opt-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, and Original-Envelope-Id identifiers. 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*_blake3 to 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 new chrome-store.js (chrome.storage) adapter; build.sh produces a loadable staging/ tree and a packaged sithbit-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 MailInstruction table is now complete (all 39 variants); the glossary blake3 entry is corrected to its five current uses; and hand-written target="_blank" was retired from the remaining pages (the global external-links hook governs them).
  • Docs landing page. The Prelude (README.md at the time; moved to prelude.md 2026-07-15 when the marketing Welcome page took over the README.md/index.html slot) 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 + sithbit wordmark, 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 in webclients/shared/brand.css (consumed by the shells and plugins) and mirrored onto mdBook’s per-theme variables in mail_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 inbound RCPT TO:<user@domain> for a verified domain: the recipient domain threads through the gRPC gateway’s domain-scoped DomainAlias lookup (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.
  • 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 the cert keyserver.
  • Autocrypt-style key headers on plugin mail. The lockbox plugins now advertise the sender’s published key in an opportunistic X-SithBit-Key header 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 map [email protected], [email protected], … 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.
  • 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 to user@verified-domain seals to the domain-designated wallet.
  • Trust model. The domain authority alone controls its user@domain mappings (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 --derive publishes 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-domain namespace is planned but not yet shipped — v1 aliases are domain-blind, so [email protected] and [email protected] resolve to the same global alice. 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 by account_api like 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.js is 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 and mail-auth pinned 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/listings and /v1/chain/sales endpoints. See The name marketplace.
  • A modexp-free deploy build for AdminCloseAccount. mail_program gained a dnssec-proof Cargo feature (default on); a --no-default-features build drops the sol_big_mod_exp syscall 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 (a BrowseListings scan of every alias and domain listed for sale) and GET /v1/chain/sales (ListSales — full alias + domain sale history, parsed from program logs). See the account API.
  • Wallet-adapter (unsigned) transaction builders. mail_wasm gained 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 AdminCloseAccount instruction (in both programs) lets the standing postmaster delegate close program-owned accounts and refund their rent — the basis of a new sithbit postmaster reclaim tool (behind a compile-time reclaim feature, 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-commitment becomes postmaster commitment, and postmaster rotate-delegate becomes postmaster delegate (command labels only — the InstallCommitment / RotateDelegate instructions 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, and SettleAuction instructions, with an anti-snipe extension and a 90/10 fee split. Drive it with sithbit 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 setup flow.

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 witness domain authorize / domain reclaim stage, re-walking it locally before it is ever submitted (behind the opt-in gather build feature). See Building the witness with gather-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-ksk becomes postmaster ksk set, postmaster root-ksk-from-iana becomes postmaster ksk iana, and a new read-only postmaster ksk get prints the fingerprint currently anchored on the PostOffice (or unset). See Prerequisite: publish the root KSK.
  • Derive the root-KSK fingerprint from IANA. sithbit postmaster ksk iana turns IANA’s published root trust anchor (root-anchors.xml) into the base58 value ksk set wants — a pure offline conversion. Its new --fetch-anchors <DIR> downloads the anchor, its detached S/MIME signature, and ICANN’s CA bundle, then prints the openssl verification 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 by validFrom instead 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 alias replace the flat fee / domain-fee / alias-fee, and the delegate setters are now sithbit postmaster fee stamp / fee domain / fee alias (was set-stamp-fee / set-domain-fee / set-alias-fee). See The Postmaster → Checking status.
  • Alias transfer grouped. sithbit alias transfer init / transfer accept / transfer cancel replace transfer / accept-transfer / cancel-transfer. See Transfer an alias.
  • Alias listing cancel folded in. sithbit alias sell <alias> --cancel replaces alias cancel-sell. See List an alias for sale → Cancelling a listing.
  • Alias create is variadic. sithbit alias create now takes one or more aliases (reading stdin when none are given), absorbing alias create-bulk, which is removed. See Bulk reservation.
  • mailbox credentials. sithbit mailbox derive-password is renamed sithbit mailbox credentials (it prints the mail username + password pair).

2026-07-11 — v0.2.0 (Set 7)