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.
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.
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.
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.
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.
Getting Started
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 workspace: clone the repo and build it with cargo build -p mail-client -r, which produces the sithbit binary at target/release/sithbit.
Talking to the chain also needs an RPC endpoint. Configure one with sithbit config set --url <cluster> (e.g. https://api.devnet.solana.com), or point a JSON_RPC_URL environment variable at one directly.1
Creating a wallet
You’ll need a cryptographic keypair / wallet; its public key becomes your first email address. sithbit can generate one directly:
sithbit wallet create
# Wallet keypair written to '/home/you/.config/solana/id.json'
# Address: 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q03
By default this writes to the path sithbit config get reports as your
keypair path.2 Pass --outfile <path> to write elsewhere; sithbit wallet create refuses to overwrite an existing keypair file unless you
also pass --force.
Even though there is no domain portion of the address, i.e., 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q03@sithbit.com, your standalone public key address is already a legitimate email address in the system, although you will need to create a mailbox for it and at least one frombox before emails can be routed to your address. A frombox is keyed by a pair: your wallet address as the recipient, and a sender’s “from” address — but only the recipient side needs to be an on-chain wallet. The sender’s “from” address is just a plain, off-chain RFC822-compliant address string (e.g. jane_doe@sithbit.com, or any other domain) — it never has to resolve to a Solana keypair.
Neither step above needs the Solana CLI/SDK installed at all — sithbit config and sithbit wallet create are a complete substitute for it in this workflow.
-
If you already have the Solana CLI installed,
solana config set --url <cluster>writes the same config file. ↩ -
If you already have the Solana CLI/SDK installed,
solana-keygen newfollowed bysolana-keygen pubkeygets you the same keypair file and address. ↩
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.
You can easily create key pairs (also known as “wallets”) with sithbit wallet create (see Getting Started).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, or update its existing mailbox. Once you associate the public key with a domain, it becomes the case-sensitive “local” portion of the RFC822-compliant email address (i.e., the part of the address before the ‘@’ symbol, which separates the domain portion of the address).
For example: 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q03@sithbit.com
A domain is only required when email is routed through SMTP, but without a domain, emails can only be sent directly through the program, where both the “from” and “to” addresses are public keys without a domain portion of the email.
Trusting a “from” address
The “to” side of an email is always an on-chain wallet address, but the
“from” side doesn’t have to be — it’s an arbitrary off-chain
RFC822-compliant
string (e.g. jane_doe@sithbit.com), which raises the obvious question:
what stops anyone from sending mail claiming to be any “from” address they
like?
- Only a recipient’s own domain authority can deliver into their
mailbox. Every
SendMailinstruction requires the transaction signer to be the active, registered authority for the recipient’s (“to”) domain — the same wallet named in that domain’s on-chainMailDomainaccount (mail_program’ssend.rs,is_domain_authority). A signer that doesn’t match is rejected (IllegalAuthority). 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 postmaster directly, or (self-service)
by the
domain_sithbitservice after it verifies a_solana.authority.<domain>DNS TXT record matches the claimed key — see Create a domain. Controlling a domain on-chain requires controlling that domain’s DNS, the same trust root traditional email anti-spoofing (SPF4/DKIM5) relies on. - A domain-less from address must match the signer’s own wallet. If
the recipient address has no
@domainat all (a direct, unrouted on-chain send), the program instead requires the signer to be the exact address named in the “from” field — so a bare public-key “from” can only ever be sent by its own keypair. There is no relaying at all in this case. - One MX relay signs as one set of domains. The gRPC gateway that
submits
SendMailon behalf of an MX server signs every transaction with a single configured keypair, so a given mail server deployment can only deliver into mailboxes on the domain(s) it actually holds the authority key for — it can’t inject mail into a domain’s mailboxes that it doesn’t operate. - The “from” domain itself is not independently re-checked on-chain.
Once a signer clears the checks above, the “from” string it submits is
otherwise free-form (format-validated for length only). The chain
trusts that the recipient’s own domain operator already verified the
claim — which is exactly what
SPF/DKIM/DMARC6 do, off-chain, before that
trusted operator ever submits
SendMail. A misconfigured orsender_auth = "none"operator 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
(recipient wallet, from string), 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 the
sender_authsetting in Configuration. This is the layer that actually authenticates a “from” domain’s claim; the chain only enforces who may deliver to a given recipient. - Casing can’t be used to dodge any of the above. Domain names are
lowercased both when a
MailDomainaccount is created (mail_model::postoffice::domain) and every time one is looked up (send.rs’s authority check re-lowercases independently, as does domain creation/transfer/deactivation/closing) —SithBit.Comandsithbit.comare the same account, so there’s no shadow-domain trick via casing. The frombox “from” key is normalized the same way (Frombox::normalize_from_addresslowercases the domain-qualified form), so pricing set forjane@sithbit.comalso applies toJane@SithBit.Com. Bare wallet addresses are deliberately left case-sensitive, since a base58 pubkey’s case is part of its identity, not a stylistic variation.
What’s not enforced on-chain is the authenticity of the “from” domain itself, and the local part under any authorized recipient domain: a domain’s authority can write any “from” string it likes for mail delivered to its own mailboxes, the same trust a real domain’s mail server holds for traditional email — the protocol’s guarantee is that only a recipient’s own trusted operator can deliver to them, not that every “from” claim has been independently re-verified.
Since public key addresses usually appear as long random strings that are hard to remember, it is also possible to create one or more friendly aliases for an address via the alias program. Aliases are globally-unique identifiers for public key addresses. Once created, they represent public keys across all domains.
Instead of using 85FZrun1Eb5bdkbFCDjaFSTLnBfnx6sUFHa5BiYH2Q03@sithbit.com, you might create the alias myalias, and use myalias@sithbit.com as your public email address.
Unlike public key addresses, aliases are case-insensitive. So myalias@sithbit.com, MYALIAS@SITHBIT.COM, and MyAlias@Sithbit.com are all aliases for the same public key address.
-
A keypair (or “wallet”) in the Solana blockchain is specifically a public key and its associated private key derived from the ED 25519 eliptical curve. ↩
-
The 32 bytes of the key are base58 encoded to produce the string. ↩
-
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. ↩ -
For the formal specification, see RFC 7208 (Sender Policy Framework); dmarc.org’s overview explains how SPF, DKIM, and DMARC work together in practice. ↩
-
For the formal specification, see RFC 6376 (DomainKeys Identified Mail). ↩
-
For the formal specification, see RFC 7489 (Domain-based Message Authentication, Reporting, and Conformance); dmarc.org also has practical deployment guidance. ↩
Mailboxes
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.
Looking up a mailbox
sithbit mailbox get [owner_address]
owner_address is optional and defaults to the address of your own configured keypair, so sithbit mailbox get with no arguments looks up your own mailbox.
A mailbox has 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.
See Appendix: Closing accounts for how to close a mailbox you no longer need.
Create a mailbox
To create a mailbox, you must first create a Solana wallet — run
sithbit wallet create (see Getting Started).1
You should also have identified a mail / mx domain (e.g., “sithbit.com”) with which you want to associate you wallet address, but this can be done later. Note that the mail domain you choose might require additional configuration before the mail servers will relay mail for you. For example, the domain might require you to sign up first before it offers POP or IMAP services for your email.
The domain must already be registered and active on-chain —
creating or updating a mailbox verifies the domain account and refuses
unregistered or deactivated names. A wallet has exactly one mailbox,
and a mailbox names exactly one domain (switch it later with
sithbit mailbox update --domain); receiving under two domains at once
means using two wallets.
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.
sithbit mailbox create \
--default-postage 100000 \
--domain sithbit.com \
--keypair <path to wallet keypair>
-
If you already have the Solana CLI/SDK installed, its own wallet setup works just as well. ↩
Update a mailbox
sithbit mailbox update \
[--keypair <keypair>] \
[--default-postage <lamports>] \
[--domain <name or address>] \
[--skip-preflight]
Both --default-postage and --domain 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.
When a mailbox is no longer needed, see
Appendix: Closing accounts for mailbox close and mailbox close-key.
Mailbox Keys
Mail bodies are encrypted before they are pinned to IPFS, so that only the mailbox owner can read them. Encryption needs no setup at all by default, and there is one optional on-chain key account for wallets that can’t use the default.
Sealed to the wallet (the 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 crypto_box_seal 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. Decrypt with the wallet keypair file:
sithbit mail decrypt <file> --keypair <path to wallet keypair>
(--keypair defaults to your configured default keypair — see
Getting Started.)
Delegated keys (signing-only wallets)
Hardware and browser wallets only sign; they never reveal the secret needed to derive the wallet’s decryption key. Such users generate a delegated X25519 keypair client-side and publish its 32-byte public key on-chain:
sithbit mailbox create-key my_delegated_key.json
sithbit mailbox set-key my_delegated_key.json --keypair <path to wallet keypair>
Senders seal to the published key instead of the wallet address; the secret key file opens the mail:
sithbit mail decrypt <file> --delegated-key-path my_delegated_key.json
Re-running set-key with a fresh keypair rotates the key; mailbox close-key removes the account (and reclaims its rent), returning the
mailbox to wallet-sealed mail.
Reading the published key
sithbit mailbox get-key <address>
Prints the published key when one exists: the delegated X25519 public key, as base58.
Fromboxes
A frombox is an on-chain account for one (recipient wallet, sender “from” address) 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.
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.
Looking up a frombox
sithbit frombox get <from> [to_address]
to_address is optional and defaults to your own address — the recipient’s
point of view. For example, to check the frombox a recipient has set up for
jane_doe@sithbit.com:
sithbit frombox get jane_doe@sithbit.com
See Appendix: Closing accounts for how to close a frombox and reclaim its rent and remaining stamp value.
Create a frombox
sithbit frombox create <from> [to_address] \
[--keypair <keypair>] \
[--stamps <count>] \
[--skip-preflight]
to_address defaults to your own address (the recipient creating the
frombox). --stamps defaults to 0 and prepays that many stamps at
creation time. For example, a recipient prefunding a known sender with 5
stamps right away:
sithbit frombox create jane_doe@sithbit.com --stamps 5
Note: creating a frombox with
--stamps 0(the default) just registers a custom stamp price for that sender — it doesn’t grant any free sends yet. Use Add stamps afterward, or--stampsat creation time, to actually fund it.
Once created, the frombox’s stamp price can be changed later — see Update a frombox.
Update a frombox
sithbit frombox update <from> [required_postage] \
[--keypair <keypair>] \
[--skip-preflight]
required_postage is the price (in lamports) of a single stamp — since
each email burns exactly one stamp, this is what one send from <from> to
you costs. It defaults to 1 SOL if omitted. Use this to lower the stamp
price for a sender you’ve come to trust:
sithbit frombox update jane_doe@sithbit.com 100000000
…or to raise it — even above the mailbox’s own default — for a sender who has become a nuisance.
Add stamps
sithbit frombox stamp <from> [to_address] \
[--keypair <keypair>] \
[--stamps <count>] \
[--skip-preflight]
to_address defaults to your own address; --stamps defaults to 1. For
example, topping up a sender’s frombox by 10 stamps:
sithbit frombox stamp jane_doe@sithbit.com --stamps 10
Each stamp purchase pays the protocol’s flat per-stamp fee (waived when the payer is the mailbox owner themselves — see Economics for the exact amount). Sending one email always burns exactly one stamp, regardless of the frombox’s stamp price; the lamports transferred to the recipient when a stamp is burned are the frombox’s remaining balance divided by its remaining stamp count, so the per-stamp price you set is really “the value backing each stamp,” not a per-send fee charged separately.
When a frombox is no longer needed, see Appendix: Closing accounts to close it and reclaim its rent and remaining stamp value.
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, so most users never need to create one by hand. Use the commands in this section to register additional, friendlier aliases, look one up, or transfer one to a new address.
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 (
ALIAS_INDEX_DB, defaultalias_index.db; set it empty to disable the indexer) and is fed by polling the chain everyALIAS_INDEX_POLL_SECONDS(default 5). - On first start the gateway backfills the program’s full history;
ListAliasesanswersUNAVAILABLEuntil that backfill completes, and restarts resume from a stored cursor instead of re-scanning. - The index reads at finalized commitment, so a freshly created alias appears after finalization plus one poll interval.
Create an alias
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
Getting Started).
Note:
sithbit mailbox createalready registers your wallet’s own address as a self-alias automatically. Usealias createfor additional friendly names beyond that automatic one.
To register many names at once (e.g. a postmaster 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
sithbit alias create-bulk [<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-bulk 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-bulk --keypair my_wallet.json < aliases.txt
Every created alias points at the signing wallet, exactly as
alias create would — same validation, same
lowercasing, same per-alias fee (see Economics) —
and duplicates in the list are collapsed before submission.
Bulk-created names carry no special state: like any alias, they can
later be handed to another wallet with
alias transfer or closed at any time to reclaim
their rent.
Get an alias
sithbit alias get <alias>
Resolves an alias to the wallet address it currently points at:
sithbit alias get john_doe
Note: lookups are case-insensitive by construction — the alias is lowercased before it’s hashed into the account’s address, the same as at creation, so
John_Doeandjohn_doeresolve identically.
See Appendix: Closing accounts for how to close an alias and reclaim its rent.
Transfer an alias
sithbit alias transfer <alias> <new_address> \
[--keypair <keypair>] \
[--skip-preflight]
Moves an existing alias to point at a new wallet address. The alias’s current holder must sign — an alias can’t be taken from its owner without their key:
sithbit alias transfer john_doe maiLtdkxym8CCmo9TwDuXywqd9DXaK3tB6toKFVeBFR
See Appendix: Closing accounts for how to close an alias instead of transferring it.
Domains
A domain is a claimed, postmaster-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 postmaster authorize it before mailboxes on that domain
can send or receive mail through the normal MX/SMTP path.
Looking up a domain
sithbit domain get <domain>
This is always available — unlike the admin subcommands below, it needs no special CLI feature. For example:
sithbit domain get sithbit.com
Note:
domain create,domain transfer, anddomain deactivaterequire the CLI’sdomainfeature (enabled by default) and always require the postmaster’s signature — domain administration is not something an ordinary mailbox owner can do unilaterally.
Deactivating a domain (see Deactivate a domain) blocks sending and deleting mail for mailboxes that reference it, and also blocks creating or updating a mailbox to reference it: a mailbox’s domain must be a registered, active domain account (or no domain at all), so deactivation locks a domain down from new registrations too.
Create a domain
sithbit domain create <domain> \
[--authority-keypair <path>] \
[--keypair <postmaster keypair>] \
[--payer-keypair <path>] \
[--skip-preflight]
Creating a domain involves up to three distinct roles:
- Authority (
--authority-keypair) — the mail server’s signing key for this domain:SendMailinto a mailbox is only accepted when the transaction signer is the recipient domain’s recorded authority, and the authority collects the operator share of DeleteMail settlement. (Transferring, deactivating, and closing a domain are postmaster operations, not authority ones.) - Postmaster (
--keypair) — must always sign; only the postmaster can authorize a new domain. - Payer (
--payer-keypair) — funds the account’s rent; defaults to the postmaster if not given separately.
sithbit domain create sithbit.com --authority-keypair ./authority.json
One 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 DEFAULT_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 postmaster to run this command by hand. See DNS setup and the
domain-sithbitservice for a self-service flow: prove ownership of a domain via a DNS TXT record, and the service submits this authorization on your behalf.
Transfer a domain
sithbit domain transfer <domain> <new_authority> \
[--keypair <postmaster keypair>] \
[--skip-preflight]
Reassigns administrative authority over one domain to a new wallet. For example:
sithbit domain transfer sithbit.net maiLtdkxym8CCmo9TwDuXywqd9DXaK3tB6toKFVeBFR
Note: don’t confuse this with transferring the postmaster itself:
domain transferchanges who administers one domain;postmaster transferhands over the entire postoffice — the authority to authorize every domain in the deployment.
Deactivate a domain
sithbit domain deactivate <domain> \
[--active] \
[--keypair <postmaster keypair>] \
[--skip-preflight]
By default (no --active flag) this deactivates the domain. Deactivating a domain
blocks sending and deleting mail for any mailbox that references it, and
also blocks new registrations: creating a mailbox against the domain (or
updating an existing mailbox to reference it) is refused while the domain
is inactive, so deactivation both stops mail flow for a problem domain and
retires it from new registrations until it is reactivated or closed.
Reactivating
Pass --active to flip a domain back to active:
sithbit domain deactivate sithbit.com --active
When a domain is retired for good rather than temporarily suspended, see
Appendix: Closing accounts for domain close.
The Postmaster
The postmaster is a singleton on-chain account (the PostOffice) that
administers the whole SithBit deployment: it authorizes and deactivates
domains, sets the protocol’s per-stamp fee, and collects
that fee’s revenue. There is exactly one postmaster per deployment, held by
whichever wallet the network operator designates. (The postmaster is the
only singleton: domains are many, and one authority key may hold several —
see Create a domain.)
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.
Checking status
Anyone can check the current protocol fee and the postmaster account without signing anything:
sithbit postoffice fee
sithbit postmaster get
Initializing the postoffice
sithbit postmaster init [--keypair <keypair>] [--skip-preflight]
Note: this is a one-time step run once per network deployment — right after the on-chain programs are first deployed — not something an end user or even a domain operator ever runs.
Transferring postmaster authority
sithbit postmaster transfer <new_address> \
[--keypair <keypair>] \
[--skip-preflight]
Hands the entire postoffice — and with it, the ability to authorize domains and collect protocol fees — to a new wallet. This is different from transferring a single domain, which only changes who administers one domain.
Withdrawing protocol fees
sithbit postmaster withdraw [destination] \
[--keypair <keypair>] \
[--skip-preflight]
Sweeps the postoffice’s accumulated fee revenue to destination (defaults to
the postmaster’s own address).
Tuning the per-stamp protocol fee
sithbit postmaster set-stamp-fee <lamports> \
[--keypair <keypair>] \
[--skip-preflight]
See Economics for what this fee is and when it’s charged. The on-chain program enforces a hard cap on this value, so a compromised or malicious postmaster key can’t set it arbitrarily high — the CLI checks the same cap client-side and refuses to submit an over-limit value before spending a transaction on it.
Note: every
postmastersubcommand is gated behind the CLI’spostmasterfeature (enabled by default). A build with that feature disabled won’t have this command group at all.
At a high level, sending an email in SithBit works like this: the message
body is encrypted and pinned to IPFS off-chain; only
the resulting content identifier (CID) and a small envelope (to, from,
timestamp) go on-chain as part of the SendMail instruction. Postage —
whether the frombox price or the mailbox
default — gates whether the send is allowed at all. See
Addresses for how addresses work and
Economics for the money side of a send; this section
covers the mechanics of the three mail operations themselves.
Note: in normal operation, MX/SMTP servers handle sending and receiving mail for you — see Running a mail server. The
sithbit mailcommands 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
sithbit mail send <to> \
[--from <address>] \
(--cid <cid> | --path <file>) \
[--keypair <keypair>] \
[--skip-preflight]
--from defaults to the sending keypair’s own address. Exactly one of
--cid or --path is required:
sithbit mail send jane_doe@sithbit.com \
--from john_doe@sithbit.com \
--path ./message.eml
Important:
--pathonly computes the local file’s IPFS content identifier (CID) from its bytes — it does not upload or pin the content anywhere. Before the recipient can actually fetch and decrypt the message, the file must be separately pinned to IPFS (for example, through the embedded node in your ownsithbitd, or a sharedsithbit-ipfsd).--cidis for content that’s already pinned somewhere — you supply its known CID directly instead of a local file.
The --from address determines which frombox
is charged: sending burns exactly one stamp from that frombox (see
Add stamps for what a stamp is worth), so the
frombox for <from> → <to> must exist and have at least one stamp before the
send will succeed.
Neither address string reaches the chain: the instruction carries only
the 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
sithbit mail get [to_address] \
[--message-id <id>]... \
[--range] \
[--directory <path>] \
[--keypair <path>] \
[--delegated-key-path <path>] \
[--gateway <url>]
to_address defaults to your own address. --message-id can be repeated
to fetch specific message ids; --range instead treats the given ids (or
all known ids, if none given) as a range. --directory writes fetched
messages to a directory instead of printing them.
The on-chain listing for each message shows a From-hash: line — the
chain stores only the blake3 hash of the from address, never the string.
The readable From:/Subject: headers appear once the sealed body is
fetched and decrypted (they live inside it).
Note: the decryption flags below (
--keypair,--delegated-key-path) require the CLI’srandfeature (enabled by default).
There are two ways to supply the key that decrypts a fetched message:
--keypair— a Solana wallet keypair file (the default: your own configured wallet decrypts mail sealed straight to it).--delegated-key-path— a delegated X25519 key file, for mail sealed to a delegated key instead of your wallet directly.
If you’ve already fetched a message but need to decrypt it separately, use
sithbit mail decrypt <file> with the same key flags as a standalone step.
Deleting mail
sithbit mail delete <message_id> [to_address] \
[--keypair <keypair>] \
[--skip-preflight]
to_address defaults to your own address (the recipient). For example:
sithbit mail delete 3
Note: deleting a message is also the settlement trigger — it’s when postage is actually paid out to the recipient and domain operator. See Economics for the exact split. Deleting also reclaims the message account’s rent, on top of settling postage.
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.
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.
The wallet and its passphrase
First open (the SithBit button in the spaces toolbar), the dashboard asks for your Solana keypair file — the JSON array of 64 numbers — and a passphrase. The keypair is encrypted with the passphrase (PBKDF2 + AES-GCM) before it is stored in the Thunderbird profile; the decrypted key lives only in memory and is gone when Thunderbird exits, so each restart asks for the passphrase again.
Login is a wallet challenge: the API issues a nonce, the extension signs it, and a day-long session token comes back. No password ever exists for login — your wallet is the account.
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.
- 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 -xreads; mail sealed to an old key still needs that old key’s secret, so keep every export. - Settings — the mail password your IMAP/POP/SMTP client uses (username = wallet address) and your IANA timezone.
- Do not disturb — weekly or date-range windows during which senders are asked to retry later, evaluated in your timezone.
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.
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.
Using it
Select any message and open Apps → SithBit Account (the pane is
pinnable, so it stays open as you move around). First run: paste your
Solana keypair file and choose a passphrase — the key is encrypted
with that passphrase before it is stored in the browser’s storage, and
the passphrase is needed again each time the taskpane opens. From
there the panes match the Thunderbird extension: settings, DND,
aliases, balances, and delegated-key publish/rotate/close, each
on-chain action signed client-side in wasm and relayed through
/v1/chain.
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.
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.
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 anduser@your-domainaddresses resolve through the gateway, with the frombox postage precheck applied before the message is accepted.[mail](local_domains,[mail.dkim]) — mirrors sithbitd’s submission settings: which domains are yours (everything else is relayed) and the DKIM keys relayed mail is signed with.
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). First run: paste your Solana keypair file and choose a
passphrase — the key is encrypted with that passphrase before it lands
in the browser’s localStorage, and the passphrase is asked for again
each time you open the app.
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. 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, timezone, do-not-disturb, aliases, balances, and delegated encryption-key management, exactly as documented for the Thunderbird extension.
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).
Economics
Where the SOL goes: every lamport transfer in the protocol, traced from the
on-chain programs (mail_program/src/processor/), and what each participant
class pays and collects. Amounts marked ≈ are rent-exemption minimums —
a refundable, one-time deposit every Solana account needs to exist, sized to
the account’s byte length rather than to any price or value it represents
(see Closing accounts for what rent is and
how to reclaim it) — and vary with account size at current rent parameters.
This is a completely separate quantity from postage: postage is a price
the recipient chooses; rent is a storage deposit nobody chooses and
everybody gets back.
The stamp lifecycle
A stamp is prepaid postage for one email from one sender (“from” address) to one recipient wallet. Its value cycles through three accounts, then settles across four destinations:
- Buying (
AddStamps/CreateFrombox): anyone may fund the frombox for a (recipient, from) pair. Each stamp costs the frombox’srequired_postageplus a two-signature fee surcharge (STAMP_FEE_SURCHARGE_LAMPORTS= 10 000 = 2 × the 5 000-lamport base fee); the surcharge prefunds both settlement signature refunds (SendMail and DeleteMail). The whole amount transfers into the frombox account. A new frombox inheritsrequired_postagefrom the recipient mailbox’sdefault_postage; only the recipient may change it afterwards (UpdateFromboxrequires the recipient’s signature). Price changes do not revalue stamps already bought — value amortizes over the remaining stamp count. Third-party purchases additionally pay the per-stamp protocol fee straight to the postoffice (see “The per-stamp protocol fee” below); purchases paid by the recipient wallet itself are fee-free. - Sending (
SendMail): the signer must be the sender named in the email (or the active domain authority for the recipient’s domain — the MX operator’s wallet, for relayed mail). The signer pays the transaction fee and fronts the message account’s rent-exemption (≈0.003–0.006 SOL depending on address/cid sizes — a deposit sized to the account’s byte length, not to the postage price; see the note above) as a new deposit, separate from anything paid at the Buying step. One proportional share of the frombox’s value —(balance − frombox rent) / stamps— moves onto the message account alongside that rent, and one stamp burns. A frombox whose share rounds to zero still sends (the stamp count is the gate; the value is what settles later). - Settling (
DeleteMail): only the sender or the recipient may delete. The message balance is split, in order: the sender recovers the message rent + one signature fee, the delete signer recovers this transaction’s signature fee (both prefunded by the surcharge), the recipient’s domain authority collects the operator share (OPERATOR_SHARE_BPS= 10% of the remaining postage), and the recipient collects the rest.1 Postage only settles on delete; until then it sits parked on message accounts.
Try it yourself: a worked example
The script below re-derives the three steps above as plain arithmetic —
paste it into a browser console or run it with node (no dependencies,
no network access, nothing on-chain). Change postageLamports,
messageRentLamports, or thirdPartyBuyer at the top to try a different
scenario.
Money enters this example at two different points, not one: the buyer’s
purchase at Buying, and the sender’s rent deposit at Sending (the same
wallet, if the buyer is also the sender — but still two separate
transactions). That’s why the settlement totals add up to more than
buyerPays alone: the conservation check sums both inputs
(buyerPays + messageRentLamports) against everything paid out, and only
that combined total should balance.
Note: the default
postageLamportsbelow is priced at roughly what a single US first-class postage stamp costs (about $0.82 as of this writing), converted to lamports at the SOL/USD rate on the date this page was last updated ($81.16/SOL, 2026-07-06) — a deliberate nod to the stamp analogy, not a protocol default. Change it to see how the split scales.
// SithBit stamp economics calculator -- a hypothetical worked example.
// Pure JavaScript, no dependencies: paste into a browser console, or run
// with `node stamp-calculator.js`. All amounts are in lamports
// (1 SOL = 1,000,000,000 lamports).
// Protocol constants (from mail_model::constants)
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; // waived if the buyer is the recipient
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
const stampValue = postageLamports + STAMP_FEE_SURCHARGE_LAMPORTS;
const protocolFee = thirdPartyBuyer ? POSTOFFICE_STAMP_FEE_LAMPORTS : 0;
const buyerPays = stampValue + protocolFee;
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 ? " to the postoffice" : " (waived)"}`);
// 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) to the postoffice
== Sending: SendMail ==
Sender fronts message rent: 4000000 lamports (0.004000000 SOL)
// a new deposit -- separate from what the buyer paid above
One stamp's value moves onto the message account: 10010000 lamports (0.010010000 SOL)
Message account now holds: 14010000 lamports (0.014010000 SOL)
== Settling: DeleteMail ==
Sender recovers: 4005000 lamports (0.004005000 SOL) [rent + 1 signature fee]
// ≈ $0.33
Delete signer recovers: 5000 lamports (0.000005000 SOL) [1 signature fee]
// ≈ $0.00
Domain authority earns: 1000000 lamports (0.001000000 SOL) [10% operator share]
// ≈ $0.08
Recipient collects: 9000000 lamports (0.009000000 SOL) [the rest, rounded down]
// ≈ $0.73
== Conservation check ==
// totalIn = buyerPays + messageRentLamports: money entered at TWO steps, not one
Total in: 14110000 lamports
Total out: 14110000 lamports
Every lamport is accounted for.
Per-participant flows
Recipient (mailbox owner)
| Pays | Collects |
|---|---|
Mailbox rent ≈0.0012 SOL (once, at CreateMailbox) | Stamp value of every received-then-deleted mail |
| Encryption-key account rent ≈0.005 SOL (once) | |
| Frombox funding for correspondents they allow-list | That same funding back, as mail arrives and is deleted |
The recipient is the protocol’s revenue side: strangers pay
required_postage per message, and pricing is the recipient’s spam
control (the CLI defaults new mailboxes to 1 SOL per stamp — unknown
senders are priced out until the recipient lowers the price for them).
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
10% of the postage (OPERATOR_SHARE_BPS). 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 flat fee per purchased stamp, collected at purchase time and transferred directly to the postoffice PDA.
- Amount:
stamp_fee_lamportsfrom the postoffice account (defaultPOSTOFFICE_STAMP_FEE_LAMPORTS= 100 000 = 0.0001 SOL), charged per stamp in the purchase. - Waiver: the fee is skipped iff the purchase’s fee payer is the
recipient wallet (
fee_payer == to_account). 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 postmaster tunes the fee with
SetStampFee(sithbit postmaster set-stamp-fee <LAMPORTS>), hard-capped on-chain atMAX_POSTOFFICE_STAMP_FEE_LAMPORTS(1 000 000) so a compromised postmaster key cannot price-gouge purchasers. There is no on-chain read instruction — account state is world-readable; anyone can query the current fee with the ungatedsithbit postoffice fee. - 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
(
SetStampFee,TransferPostmaster) grow the account in place (resize- rent top-up from the signing postmaster) on their next run.
Domain authorities
CreateDomain takes an explicit payer — either the postmaster or the
domain authority may fund the domain account’s rent plus the flat
DOMAIN_AUTHORIZATION_FEE_LAMPORTS (0.01 SOL) paid to the postoffice.
Either way the postmaster must sign: domain ownership is proven
off-chain (the authority’s pubkey in the domain’s DNS TXT record, checked
by the postmaster’s trusted agent), so on-chain authorization is always
the postmaster’s — authority-pays is a co-signed two-signature
transaction, never authority-alone. The domain records its rent_payer,
and CloseDomain (postmaster-signed) refunds the rent to whoever paid.
When the postmaster 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 10%
operator share on every settled message for its domain.
Alias holders
An alias costs its own account rent plus the flat ALIAS_FEE_LAMPORTS
(0.01 SOL) paid to the mail program’s postoffice — squatting a name now
has a price. 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.
Where SOL parks or strands
- Message accounts hold rent + stamp value until someone deletes. Both sides have an incentive to delete (sender: rent; recipient: postage), but nothing forces it.
- Every other account class now has a close path:
CloseFrombox(the recipient reclaims rent plus any residual stamp value — stamps never used),CloseMailboxandCloseKey(owner reclaims rent),CloseAlias(holder reclaims rent),CloseDomain(rent back to the recorded payer), andWithdrawPostoffice(the postmaster sweeps accumulated revenue). A closed mailbox’s undelivered message accounts stay open and settle individually viaDeleteMail; recreating the mailbox restarts message ids at zero, so new sends fail until those old message accounts are deleted — an availability nuisance, never a loss of funds.
Constants worth knowing
Protocol economics are consensus constants in mail_model::constants:
SIGNATURE_FEE_LAMPORTS (5 000, the real base fee — replacing the
deprecated 10 000-lamport fee-calculator default that overcharged buyers
~2×), STAMP_FEE_SURCHARGE_LAMPORTS (2 signatures),
DEFAULT_POSTAGE_LAMPORTS (1 SOL), OPERATOR_SHARE_BPS (1 000 = 10%),
DOMAIN_AUTHORIZATION_FEE_LAMPORTS (10 000 000 — charged by
CreateDomain), ALIAS_FEE_LAMPORTS (10 000 000 — charged by
CreateAlias), POSTOFFICE_STAMP_FEE_LAMPORTS (100 000 — the default
per-stamp protocol fee charged by AddStamps/CreateFrombox on
third-party purchases), and MAX_POSTOFFICE_STAMP_FEE_LAMPORTS
(1 000 000 — the hard cap on the postmaster-tunable fee).
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 economics —
CreateDomaintakes an explicit payer (postmaster or authority; postmaster always signs), chargesDOMAIN_AUTHORIZATION_FEE_LAMPORTSto the postoffice, and records therent_payersoCloseDomaincan refund the rent to whoever funded it. - Close instructions —
CloseFrombox(recipient reclaims residue + rent),CloseMailbox/CloseKey(owner reclaims rent),CloseAlias(holder reclaims rent), andWithdrawPostoffice(the postmaster sweeps the postoffice balance above its rent-exempt minimum — without which the accumulated protocol fee revenue would be unspendable). - Alias fee — a flat
ALIAS_FEE_LAMPORTScharge on alias creation, CPI-transferred to the mail program’s postoffice, pricing out squatting. Waived when the payer is the postmaster (the fee would round-trip into the postoffice they sweep) — see bulk alias reservation.
Money is only half the picture: for what each participant must trust —
the MX operator’s sender authentication, the singleton postmaster, public
message metadata, and frombox custody (including that CloseFrombox
returns third-party-funded stamps to the recipient, not the buyer) — see
Trust assumptions and threat model.
-
The recipient’s payout is rounded down to the nearest
POSTAGE_ROUNDING_LAMPORTS(1 000 lamports); the sub-quantum remainder — at most 999 lamports per message — accrues to the postoffice. This is below the smallest price increment anyone quotes and isn’t something senders, recipients, or operators need to think about. ↩
Running a mail server
A SithBit deployment is at most six services, all built from this workspace:
| Service | Binary | Role | Needed when |
|---|---|---|---|
sithbitd | mail-spooler | SMTP MX + submission, IMAP, POP, and the spooler workers in one process | always |
account-api | account-api | wallet-challenge login → JWT; mail passwords, timezone, DND schedules | users manage their accounts |
mail-grpc | mail-grpc | gRPC gateway to the Solana programs (postage checks, SendMail, aliases) | mail should reach the chain |
domain-sithbit | domain-sithbit | DNS-based domain verification and on-chain domain authorization | you operate the domain registry |
sithbit-ipfsd | sithbit-ipfsd | the self-hosted IPFS node as a standalone daemon: HTTP pin API + optional public swarm | a fleet shares one node via [ipfs] kind = "remote" (a single sithbitd can embed the node instead) |
sithbit-gateway | sithbit-gateway | read-only IPFS HTTP path gateway over the same block/pin bucket (deserialized, raw, CAR) | pinned mail blobs should be fetchable/verifiable over plain HTTP |
Everything else is an external dependency you point config at: a Solana
RPC endpoint, TLS certificates, and DNS records (see
DNS setup). Mail bodies pin to IPFS through the embedded
node, the shared sithbit-ipfsd, or a third-party service
(Filebase/Pinata) — the [ipfs] reference
covers the selection.
The sections below climb from a zero-config dev run to the production compose topology. Each rung is runnable on its own; pick the highest one you need.
Note: if you notice
smtp-server,imap-server, orpop-serverbinaries elsewhere in the workspace, see Appendix: Development and pilot servers — they’re dev/pilot artifacts, not part of this deployment.
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 :2143, POP :2110
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
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. Two properties of the
runtime image (distroless cc-debian12) matter to an operator:
- There is no shell in the image.
docker execinto a running service is impossible; usedocker logs,docker cp, and the monitoring surfaces instead. When copying a live SQLite database out withdocker cp, take the-waland-shmsidecar files too, or the copy will read as empty. - CA certificates are baked in, so outbound TLS (RPC providers, Filebase, smarthosts) works without extra mounts.
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 — and
tears the stack down unless KEEP=1. State lives in named volumes and
survives down; docker compose down -v resets it.
The chain pipeline is disabled in this stack, exactly like the bare zero-config run.
Adding the chain: the chain profile
docker compose --profile chain up -d
The profile adds mail-grpc pointed at a surfpool validator running
on the host (boot one by running the mail_client integration suite,
which deploys and seeds the programs). Two things to know:
- The service uses
network_mode: hostdeliberately: a loopback-bound surfpool is not reachable through Docker’shost-gatewayfrom a bridge network, somail-grpcshares the host network and serves on127.0.0.1:50051exactly like a native run. - Its signing keypair comes from
DEFAULT_KEYPAIRin./.env— the keypair JSON array itself, not a path to a file.
Set SITHBIT_CHAIN_RPC to point the profile at a remote cluster
instead of the host surfpool.
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.
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, GCP, 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.
-
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.
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.
Production
docker-compose.prod.example.yml is the annotated production shape:
copy it, search for CHANGE, and fill in your registry, config files,
and keys. Sanity-check with docker compose -f <file> config before
up -d. The structural decisions it encodes:
- Real config lives in mounted TOML files, not a wall of env vars.
Start from
mail_spooler/sithbitd.example.tomlandaccount_api/account_api.toml; containers find the file viaSITHBITD_CONFIG/ACCOUNT_API_CONFIG. - Standard mail ports map onto the unprivileged in-container binds:
host 25→2525 (MX), 587→2587 (submission), 143→2143 (IMAP),
110→2110 (POP). The binds move to
0.0.0.0in the TOML; the images never need root or privileged ports. - TLS for the mail protocols comes from the
[smtp.tls]/[imap.tls]/[pop.tls]sections over a mounted/certsdirectory. The two HTTP services (account-api,domain-sithbit) stay loopback-published and belong behind a TLS-terminating reverse proxy. - SQLite allows exactly one
sithbitd. Never scale the service while[store] kind = "sqlite". For replicas, switch the TOML to theaws/azurestore and work through the Scaling out checklist. - The
storevolume is precious — it holdssithbit.db,credential.key,jwt.key, and the blob directory. Back it up; see Monitoring and backups for what is unrecoverable. Thealias-indexvolume is not precious: it re-syncs from chain history. - Secrets stay out of the compose file.
DEFAULT_KEYPAIR(the mail-grpc fee-payer/signing keypair JSON) comes from./.envnext to the compose file or your secret manager. Thedomain-sithbitpostmaster keypair authorizes domains on-chain — guard it like a wallet.
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 two exceptions are called out below (mail-grpc’s
chain credentials, and TLS certificate paths, which have no sensible
default).
The annotated example files are the canonical per-key documentation and ship with every default shown commented out:
mail_spooler/sithbitd.example.tomlaccount_api/account_api.tomldomain_sithbit/domain_sithbit.example.tomlipfs_daemon/sithbit_ipfsd.example.tomlipfs_gateway/ipfs_gateway.example.toml
How a setting resolves
The TOML-config binaries (sithbitd, account-api, domain-sithbit)
layer each setting, lowest to highest precedence:
- the in-code default,
- the TOML file (
sithbitd.toml/account_api.toml/domain_sithbit.tomlin the working directory, or the path inSITHBITD_CONFIG/ACCOUNT_API_CONFIG/DOMAIN_SITHBIT_CONFIG), ./.env,./.env.$APP_ENV(APP_ENVcomes from the environment or./.env),- the real environment.
Environment variables address individual settings as
{PREFIX}_{PATH}, with __ descending one TOML nesting level:
SITHBITD_STORE__KIND=aws # [store] kind
SITHBITD_SMTP__SERVER__BIND_ADDR=0.0.0.0:2525 # [smtp.server] bind_addr
SITHBITD_STORE__BLOBS__KIND=azure # [store.blobs] kind (tagged enum)
The prefixes are SITHBITD, ACCOUNT_API, and DOMAIN_SITHBIT. 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.
[health] and [observability] — every binary
Two sections shared by every TOML binary (mail-grpc’s equivalents are
env vars — see its table below):
| Key | Default | Meaning |
|---|---|---|
health.bind_addr | 127.0.0.1:<per-binary port> | The /healthz + /readyz listener; each binary defaults its own port (8190–8198, table in Monitoring) |
health.enabled | true | Disable to serve no health endpoints (--health-probe then exits 1) |
observability.otlp | (absent — no export) | Presence of the section enables OTLP push of traces + metrics |
observability.otlp.endpoint | "http://127.0.0.1:4317" | Collector gRPC endpoint. The standard OTEL_EXPORTER_OTLP_*ENDPOINT env vars silently override this — leave them unset |
observability.otlp.metrics_interval_seconds | 60 | Metric push cadence |
Every binary also accepts a --health-probe argument: load the same
config, GET the health listener’s /readyz, exit 0/1 — this is what
the compose healthcheck: entries run inside the distroless images.
See Monitoring and backups for the endpoints, the
metric list, and the docker-compose.otel.yml collector overlay.
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
| Key | Default | Meaning |
|---|---|---|
kind | "sqlite" | Tables/queues/leases backend: "sqlite", "postgres" (PostgreSQL), "aws" (DynamoDB + SQS), or "azure" (Tables + Queue Storage) |
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 |
[store.blobs] selects the mail-body blob store: kind = "local"
(default, path = "blobs"), "s3", or "azure" (endpoint, container,
account, access_key).
[store.postgres] (for kind = "postgres"): url (default
"postgres://postgres:postgres@127.0.0.1:5432/sithbit"). The schema is
migrated idempotently at startup; the database itself must already
exist. The URL’s password is redacted from logs and Debug output.
[store.aws] (for kind = "aws"): region, table (one DynamoDB
table, default "sithbit"), queue_prefix (SQS queues named
<prefix>-*, default "sithbit"), endpoint_url /
sqs_endpoint_url (emulators), access_key / secret_key — omit the
keys to use the ambient AWS credential chain (env, profile, IAM role).
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.
[grpc] and [ipfs] — the chain pipeline
Both sections are required for mail to reach the chain; with either
absent, the chain pipeline is disabled and delivered copies stay in
state received (the dev default).
| Key | Default | Meaning |
|---|---|---|
grpc.endpoint | (unset) | The mail-grpc gateway, e.g. "http://127.0.0.1:50051" |
ipfs.kind | (inferred) | "embedded", "remote", "filebase", or "pinata". Unset: a configured [ipfs.remote]/[ipfs.filebase]/[ipfs.pinata] section implies its kind (pre-selector configs keep working), otherwise the embedded node |
ipfs.blobs | local ipfs/ dir | Embedded-node block/pin storage; same shape as [store.blobs] (local, s3, azure). Use one shared S3 bucket for the cluster model |
ipfs.remote.endpoint | "http://127.0.0.1:8182" | 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.listen | loopback TCP + QUIC, ephemeral ports | Multiaddrs to listen on; public participation needs e.g. "/ip4/0.0.0.0/tcp/4001", "/ip4/0.0.0.0/udp/4001/quic-v1" |
ipfs.swarm.bootstrap | [] | Bootstrap peers (/…/p2p/<PeerId> multiaddrs) that seed the DHT routing table |
ipfs.swarm.provide | false | Announce 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_secs | 15 | Membership renewal + roster check cadence; a roster change triggers an immediate reprovide sweep, so this bounds how fast a dead node’s share reassigns |
ipfs.cluster.member_ttl_secs | 60 | Missed renewals this long mark a member dead; its share of the keyspace reassigns to the survivors |
ipfs.cluster.gc_interval_secs | 3600 | Cadence of the shared-bucket GC sweep (delete blocks no pin manifest references); 0 disables it. Concurrent sweeps from several nodes are safe, just redundant |
ipfs.cluster.gc_grace_secs | 3600 | Minimum 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:
| Key | Default | Meaning |
|---|---|---|
enabled | true / false | MX on, submission off by default |
hostname | "localhost" | EHLO greeting name |
mode | "mx" / "submission" | Listener role |
sender_auth | "spf" | MX only: "spf" (reject on hardfail), "dmarc-lite" (DMARC alignment, reject only on p=reject — kinder to forwarded mail), or "none" |
local_domains | [] | Domains accepted for local delivery (empty: the hostname itself). One deployment may list several — see the startup check below |
dnsbl_zone | (unset) | DNSBL zone to check peers against, e.g. "zen.spamhaus.org" |
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.
[imap], [pop]
| Key | Default | Meaning |
|---|---|---|
enabled | true | Per-protocol toggle |
imap.watch_poll_seconds | 0 | Split deployments only: poll for mailbox changes every N seconds to feed IDLE when deliveries happen in another process. 0 trusts in-process push |
[*.server] — the shared listener section
Every listener ([smtp.server], [submission.server],
[imap.server], [pop.server]) takes the same fields:
| Key | Default | Meaning |
|---|---|---|
bind_addr | 127.0.0.1:2525 / :2587 / :2143 / :2110 | Listen address (the only per-listener default that differs) |
implicit_tls | false | Wrap the socket in TLS at accept instead of STARTTLS/STLS |
proxy_protocol | false | Expect a PROXY protocol preamble and attribute sessions to the client it names. Only behind an L4 balancer — never on a directly reachable listener (the preamble is spoofable), and clients that don’t send one are dropped |
limits.max_connections | 1024 | Concurrent connections across the listener |
limits.max_per_peer | 16 | Concurrent connections per peer IP |
limits.idle_timeout_secs | 600 | Session idle timeout |
[smtp.tls] / [submission.tls] / [imap.tls] / [pop.tls] each
take certs and key (PEM file paths, e.g. fullchain.pem /
privkey.pem). With no TLS section the listener runs plaintext —
fine on loopback, not on the internet.
[spooler] — outbound workers
| Key | Default | Meaning |
|---|---|---|
hostname | "localhost" | EHLO name, Reporting-MTA, and the MAILER-DAEMON domain |
local_domains | [] | Domains the DSN builder treats as locally deliverable |
delay_dsn | false | Emit “delayed” DSNs on retry schedules |
[spooler.smarthost] routes all outbound mail through a fixed relay
instead of MX resolution: host, port, user, password,
require_tls. [spooler.dkim] signs authenticated submissions:
domain, selector, key_file (see DNS setup for the
matching DNS record). 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.
account-api
| Key | Default | Meaning |
|---|---|---|
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) |
jwt.ttl_hours | 24 | Token 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 |
[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 |
tls.key_file | (required) | PEM private key |
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). 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.
| Key | Default | Meaning |
|---|---|---|
api_url | "http://127.0.0.1:8180" | Base URL of the account API |
keypair_file | ~/.config/solana/id.json | Solana keypair that signs the wallet-challenge login — its wallet must be on the API’s admin_wallets allowlist |
domain-sithbit
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8181" | HTTP listen address |
wwwroot | "wwwroot" | Static files (the verification front-end) |
postmaster_key_file | (unset) | Solana keypair that signs on-chain domain authorizations. Unset, POST /domain replies 503 (DNS lookups still work). Guard this key like a wallet |
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.
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_.
| Key | Default | Meaning |
|---|---|---|
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_bytes | 33554432 (32 MiB) | POST body limit; larger uploads are refused with 413 |
[blobs] | local ipfs/ dir | Block/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 knobs): 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 |
sithbit-gateway
The read-only IPFS HTTP path gateway: GET/HEAD /ipfs/{cid}
(deserialized, plus trustless ?format=raw|car) over the same
block/pin bucket the node writes; locally-absent CIDs answer 404 — it
never fetches from the IPFS network. Config file ipfs_gateway.toml
(or IPFS_GATEWAY_CONFIG), env prefix IPFS_GATEWAY_.
| Key | Default | Meaning |
|---|---|---|
bind_addr | "127.0.0.1:8183" | HTTP listen address. The surface is read-only, so no auth gate exists; bind it wherever readers live |
[blobs] | local ipfs/ dir | Block/pin storage — the same shape as sithbit-ipfsd’s [blobs]; point it at the shared bucket the node/cluster pins into. The gateway only reads it |
mail-grpc
mail-grpc is configured by environment only (it reads ./.env first):
| Variable | Default | Meaning |
|---|---|---|
GRPC_SERVER_ADDRESS | required | Listen address, e.g. 0.0.0.0:50051 |
JSON_RPC_URL | required | Solana RPC endpoint |
DEFAULT_KEYPAIR | required | The fee-payer/signing keypair JSON array itself, not a path. Keep it in .env or a secret manager |
ALIAS_INDEX_DB | alias_index.db | SQLite path for the alias-enumeration index. Set explicitly empty to disable the indexer (ListAliases then answers UNAVAILABLE) |
ALIAS_INDEX_POLL_SECONDS | 5 | How often the indexer polls for new alias transactions |
ALIAS_CACHE_SECONDS | 300 | TTL for the ResolveAlias cache (0 disables caching) |
HEALTH_BIND | 127.0.0.1:8193 | The /healthz + /readyz listener; explicitly empty disables it |
OTLP_ENDPOINT | (unset — no export) | Set to a collector endpoint (e.g. http://127.0.0.1:4317) to push traces + metrics |
OTLP_METRICS_INTERVAL_SECONDS | 60 | Metric push cadence when export is on |
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-grpcloads its configuration through a plaindotenvy::dotenv()call (which walks up from the working directory, unlike the layered.env/.env.$APP_ENVmechanism the four binaries above use) and has no.env.$APP_ENVoverlay concept at all. It’s already served by the workspace-root.env, which deliberately lives there holding devnet secrets (see “Important: secrets in the tree” in the workspace’sCLAUDE.md) — a secondmail_grpc/.envwould just duplicate or shadow it.- The
sithbitCLI (mail_client) reads no.envat all; its only configuration inputs are the config filesithbit configmanages1 and theJSON_RPC_URLenvironment variable. mail_program/alias_programare 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.
-
This is the same file (
~/.config/solana/cli/config.yml, same location on Windows too) the Solana CLI’s ownsolana configcommand reads and writes, if you already have it installed — see Getting Started. ↩ ↩2
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 the SithBit-specific record that proves you control a domain before it is authorized on-chain.
Throughout, example.com is the mail domain (the part after @) and
mail.example.com is the host running sithbitd.
Receiving mail: MX
example.com. MX 10 mail.example.com.
mail.example.com. A 203.0.113.25
The MX target must be an A/AAAA name, not a CNAME. Add
example.com to [smtp] local_domains in sithbitd.toml so the MX
listener accepts mail for it, and set [smtp] hostname (and
[spooler] hostname) to mail.example.com so the EHLO greeting
matches DNS — many receivers score a mismatch as spam.
One server may host several mail domains: point each domain’s MX at the
same host and list them all —
local_domains = ["example.com", "example.net"]. Each domain needs its
own on-chain authorization (below) under the same authority key,
its own DKIM record and [[spooler.dkim]] entry, and the daemon warns
at startup about any listed domain whose on-chain authority doesn’t
match (see the
configuration reference).
Inbound sender checks (sender_auth = "spf", the default, or
"dmarc-lite") need nothing from your DNS; they query the sender’s
records.
Sending mail: SPF, DKIM, DMARC, PTR
Receivers judge your outbound mail by these records; without them, expect the spam folder.
SPF — authorize your relay host to send for the domain:
example.com. TXT "v=spf1 mx -all"
mx authorizes whatever your MX records point at; add ip4:/ip6:
mechanisms if outbound mail leaves from other addresses, or
include: your smarthost provider when [spooler.smarthost] routes
mail through one.
DKIM — sithbitd signs authenticated submissions (rsa-sha256,
RFC 6376) when [spooler.dkim] is configured. Generate a key and
publish its public half at {selector}._domainkey.{domain}:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out dkim.pem
openssl rsa -in dkim.pem -pubout -outform DER | openssl base64 -A
[spooler.dkim]
domain = "example.com"
selector = "mail"
key_file = "dkim.pem" # PEM, PKCS#8 or PKCS#1
mail._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=<the base64 output>"
Only authenticated submission is signed — mail arriving at the MX from other servers relays unsigned, which is correct: it isn’t yours.
DMARC — tell receivers what to do when SPF/DKIM fail, and where to send reports:
_dmarc.example.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
Start with p=none while you confirm SPF and DKIM pass, then tighten.
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.
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 postmaster for a claimed authority key. The
domain-sithbit service automates the claim with a DNS proof:
-
The domain owner generates (or picks) an ed25519 keypair and publishes its public key, base58-encoded, as a TXT record:
_solana.authority.example.com. TXT "<base58 ed25519 public key>" -
POST /domainon thedomain-sithbitservice with the domain name as the body. The service resolves that TXT record, validates the key, and — signing with its configured postmaster key — submitsCreateDomainnaming the key as the domain’s authority. -
GETon the same service answers what the TXT record currently claims, which is useful for checking propagation before posting.
Operational notes:
- Without a configured
postmaster_key_file,POST /domainreplies 503 — DNS lookups still work. See the configuration reference. - The TXT lookup goes to a public resolver, so freshly published records may take a propagation delay to become visible.
- The authority key is a real signing key — the mail server submits
SendMailwith it — so store it like a wallet, not like a DNS token. The TXT record can be removed after authorization; the on-chain account is what matters from then on. - The same authority key can claim any number of domains: publish the same public key in each domain’s TXT record and POST each claim. A multi-domain server authorizes every domain it serves under its one gateway key this way.
See Domains for what a domain account contains and the CLI flows the postmaster can drive by hand.
Monitoring and backups
What to back up
The store — the store volume in the compose files, or wherever
[store] points — is the only state that matters, and its pieces have
very different values:
| File | Loss means | Back up? |
|---|---|---|
credential.key | every sealed mail credential is orphaned — users must set new mail passwords | yes, first |
jwt.key (account-api) | every login session invalidated; auto-regenerates, users just log in again | yes |
sithbit.db (+ -wal, -shm) | accounts, mailboxes, message metadata, queued jobs | yes |
blobs/ (local blob store) | message bodies not yet pinned to IPFS | yes |
DKIM key, TLS keys, postmaster keypair, DEFAULT_KEYPAIR | re-issuable with DNS/CA churn — except the postmaster key, which is an on-chain authority: guard and back it up like a wallet | yes |
alias_index.db (mail-grpc) | nothing — it re-syncs from chain history on an empty file | no |
Two SQLite copy rules: a live database is only complete with its
-wal and -shm sidecar files, and the distroless images have no
shell — so from a container, docker cp all three files out (a copy
missing the WAL reads as empty or stale). For a consistent snapshot
prefer stopping the service first, or run sqlite3 sithbit.db ".backup ..." from the host against a mounted volume.
Cloud stores (kind = "aws" / "azure") move this problem to the
provider: durability comes from DynamoDB/S3/Azure Storage, and only the
key files above still need your own backups.
Logs
Every binary — sithbitd, account-api, domain-sithbit, and
mail-grpc — logs structured tracing lines to stdout — docker logs <service> in the compose stacks. The default level is info; filter
with RUST_LOG using target=level directives:
RUST_LOG=info,mail_spooler=debug,sqlx=warn
The same RUST_LOG filter also shapes what the OTLP export (below)
sends — it sits in front of both the console and the exporter.
Telemetry export (OTLP)
Every binary can push traces and metrics to an OpenTelemetry collector
over OTLP/gRPC. Export is off by default and enabled per service by
the presence of the [observability.otlp] config section (defaults
shown commented in every example config):
[observability.otlp]
# endpoint = "http://127.0.0.1:4317"
# metrics_interval_seconds = 60
mail-grpc is env-configured like the rest of its settings:
OTLP_ENDPOINT (absent or empty = no export) and
OTLP_METRICS_INTERVAL_SECONDS.
The design is push only — no Prometheus scrape endpoint. Prometheus
users run an OTel collector with a Prometheus exporter and point the
services at it. For a working dev example, the
docker-compose.otel.yml overlay boots a collector with the debug
exporter and turns every service’s export on:
docker compose -f docker-compose.yml -f docker-compose.otel.yml up -d
docker compose logs -f otel-collector # spans + metrics print here
Two gotchas:
- The standard
OTEL_EXPORTER_OTLP_ENDPOINT/OTEL_EXPORTER_OTLP_TRACES_ENDPOINT/…_METRICS_ENDPOINTenv vars override the configured endpoint inside the exporter — leave them unset for the config file to be authoritative. - Traces ride the
RUST_LOGfilter: a target silenced for logging is also not exported.
The metrics (all under the sithbit. prefix, labeled as noted):
| Metric | Kind | Labels | Meaning |
|---|---|---|---|
sithbit.sessions | counter | port | accepted SMTP/IMAP/POP sessions |
sithbit.sessions.active | up/down | port | sessions currently served |
sithbit.rcpt.refusals | counter | reason | RCPT TO refusals (unknown_mailbox, relay_denied, temporary_failure, custom_<code>) |
sithbit.jobs | counter | queue, outcome | job dispositions (done / retry / bury) |
sithbit.queue.depth | gauge | queue | backlog incl. delayed + claimed jobs |
sithbit.queue.oldest_age_seconds | gauge | queue | age of the oldest queued job (SQLite store only; cloud queues don’t expose it — use CloudWatch/Azure metrics there) |
sithbit.chain.stuck | gauge | — | non-terminal chain copies past the sweep horizon, per reconciler pass |
sithbit.repin.outcomes | counter | kind | repin-and-verify migration outcomes (migrated / already / mismatch / source_missing / skipped); mismatch stabilizing means the migration is done — see the [ipfs.repin] reference |
sithbit.alias_index.staleness_seconds | gauge | — | seconds since the alias index last synced (mail-grpc) |
The job queues
All background work rides durable queues: chain (encrypt → IPFS
pin → on-chain SendMail), relay (outbound SMTP), chain_delete
(expunge teardown), 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
Configuration); 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).
Chain states
Every delivered message copy tracks its progress to the chain in
messages.chain_state:
| State | Meaning | Terminal? |
|---|---|---|
local | local-only copy (e.g. a sent-folder copy); the pipeline ignores it | yes |
received | delivered, waiting for the chain worker — the resting state when the chain pipeline is disabled (dev stacks) | no |
pinned | body encrypted and pinned to IPFS; SendMail pending | no |
sent | on chain | yes |
no_key | the recipient cannot receive encrypted mail (e.g. off-curve address); the local copy stays readable, a warning is logged, no bounce | yes |
chain_failed | gave up permanently; the reason is in the logs and usually a buried chain job | yes |
SELECT chain_state, COUNT(*) FROM messages GROUP BY chain_state;
A copy sitting in a non-terminal state (received, pinned) for more
than 15 minutes is picked up by the reconciler, which sweeps every
5 minutes and re-enqueues a chain job for it — duplicates are
harmless by design. A growing received count on a
chain-enabled deployment therefore means the pipeline itself is
unhealthy: check the chain queue depth, the dead-letter queue, and
connectivity to mail-grpc and IPFS.
Liveness
Every binary serves two HTTP health endpoints on a loopback health
listener ([health] in the TOML configs; HEALTH_BIND for
mail-grpc, empty disables). Default ports, one per binary so a dev
host can run them all:
| Binary | Health listener |
|---|---|
sithbitd | 127.0.0.1:8190 |
account-api | 127.0.0.1:8191 |
domain-sithbit | 127.0.0.1:8192 |
mail-grpc | 127.0.0.1:8193 |
pop-server | 127.0.0.1:8194 |
smtp-server | 127.0.0.1:8195 |
imap-server | 127.0.0.1:8196 |
sithbit-ipfsd | 127.0.0.1:8197 |
sithbit-gateway | 127.0.0.1:8198 |
GET /healthz— 200 while the process serves (liveness).GET /readyz— 200 once startup finished (listeners bound); 503 lists what’s still waiting (readiness).
Because the runtime images are distroless (no shell, no curl), every
binary also takes a --health-probe flag: it loads the same
config, GETs its own /readyz, and exits 0/1. The compose files use
exactly that as their healthcheck: (see docker-compose.yml), and
docker/smoke.sh waits for the services to report healthy. One
caveat: mail-grpc runs host networking in the chain profile, so
its probe port 8193 lives on the host — move it with HEALTH_BIND
if something else holds it.
Also useful:
- SMTP/IMAP/POP answer with a protocol banner on connect —
docker/smoke.shscripts exactly this for the dev stack. mail-grpc’sListAliasesRPC answersUNAVAILABLE(“still backfilling”) until the alias index has completed its first sync — a finer-grained readiness signal for the index than/readyz, which only tracks the gRPC listener (thesithbit.alias_index.staleness_secondsmetric covers ongoing sync health).
sithbitd: the mail daemon
Standard port(s): SMTP 2525, submission 2587 (listener disabled by default), IMAP 2143, POP 2110, health 8190.
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.
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.
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.
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. Reads always answer for the JWT’s wallet; the relay never signs
anything. 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. It needs no
configuration beyond [store].
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.
Two semantics worth knowing when these routes mutate mail:
- Move settles the source’s chain copy. Exactly like IMAP MOVE, a move is copy + expunge: the surviving copy is server-local, and the original’s on-chain message and IPFS pin are torn down in the background. Deleting does the same teardown directly.
- IMAP IDLE sees API changes with a delay. The API and
sithbitdare separate processes, so a webmail mutation reaches an idling IMAP client viasithbitd’s change poller ([imap] watch_poll_seconds), not instantly. Flag-only changes on a SQLite store don’t bump the change sequence at all — they surface with the next real mailbox event.
With a [static] section configured, the API also serves a static
directory (default route /addin) on its own origin — this is how the
Outlook add-in’s built bundle (webclients/outlook/staging) is hosted,
so the add-in’s pages call /v1/… same-origin with no CORS
configuration. Office requires the add-in over https: either front
the API with a TLS-terminating reverse proxy (the production
recommendation) or enable the built-in [tls] listener. A dev
certificate for sideloading is one command —
openssl req -x509 -newkey ed25519 -keyout key.pem -out cert.pem \
-days 365 -nodes -subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost"
— then trust cert.pem in the OS store (Office rejects untrusted
certificates even on localhost).
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): conventionally 50051 (there’s no in-code default —
see below).
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.
Quickstart:
cargo run -p mail-grpc
Unlike the other four services, mail-grpc is configured entirely through
environment variables read from .env — there’s no TOML file. GRPC_SERVER_ADDRESS,
JSON_RPC_URL, and DEFAULT_KEYPAIR are all required, with no
in-code default; 0.0.0.0:50051 is the conventional address used throughout
this documentation, not a fallback the code enforces.
Important:
DEFAULT_KEYPAIRtakes the raw JSON keypair array content itself, not a path to a keypair file — this is the single most common setup mistake. Keep it in.envor a secret manager, never in a shell history.
mail-grpc also runs the alias indexer that backs Aliases’s
ListAliases lookups. See the
Configuration reference for the full
environment-variable 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
a postmaster 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 postmaster
run sithbit domain create by hand for every domain operator. One
deployment (one postmaster 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
postmaster_key_fileconfigured,POST /domainreplies 503 — DNS verification lookups still work, but on-chain authorization is disabled until a postmaster key is set.
This service’s DNS flow is covered in depth in DNS setup; see the Configuration reference for the full key/default table.
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.
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.
See the Configuration reference for the full key/default table.
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 checklist
- A shared store.
[store] kind = "postgres","aws", or"azure". 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. - A shared blob store.
[store.blobs]must point at S3 or Azure Blob. The local-directory backend only works multi-instance on a shared volume, which is discouraged. - The same key files everywhere.
credential.key(the mail-password seal), the account API’s JWT key, and any DKIM signing key must be identical on every replica — distribute them as secrets, and back them up: losingcredential.keyorphans every stored password. - IDLE polling on IMAP instances.
[imap] watch_poll_seconds = N(e.g. 2–5). Delivery push is in-process; an instance that didn’t do the delivering only learns of new mail by polling. Instances that combine the spooler and IMAP still push their own deliveries instantly. - PROXY protocol or source-IP preservation at the balancer. DNSBL
checks and per-client connection limits key on the peer address. Behind
an L4 balancer that rewrites sources, either preserve client IPs (e.g.
Kubernetes
externalTrafficPolicy: Local) or enableproxy_protocol = truein each listener’sserversection and the balancer. Never enable it on a listener that clients can reach directly — the preamble is trivially spoofable. - Replica-aware limits.
max_connections/max_per_peerare enforced per process; the fleet-wide effective cap is the per-instance value times the replica count.
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-apireplicas share nonces and credentials through the store; any replica can answer any request.
Known seams
\Recent(IMAP) is best-effort across instances: two concurrent SELECTs of one mailbox on different instances may both see a message as recent.- IDLE latency on a poll-fed instance is bounded by
watch_poll_seconds, not instant.
Adding a storage backend
Four backends (SQLite, Postgres, DynamoDB+SQS, Azure Tables/Queues)
share one behavioral contract, and the plumbing is deliberately small.
A fifth backend touches exactly five places, all in mail_store:
config.rs— aStoreKindvariant plus its[store.<kind>]settings struct;- a backend module implementing the four repo traits (
AccountRepo,MailRepo,JobQueue,KeyedLease) — blobs are orthogonal and stay behindAnyBlobStore; stores.rs— a<Kind>Storesalias with anopenconstructor and one arm in thewith_backend!macro (the workspace’s single backend dispatch point;sithbitdandaccount-apiboth route through it, so no binary changes);lib.rsexports;tests/mod.rs— an env-gated conformance registration deriving from the canonical test list (skips must be named, with a reason).
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.
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/Azure bucket
([ipfs.blobs] / ipfsd’s [blobs]) and enable [ipfs.cluster] /
[cluster] — that’s the whole join procedure: membership heartbeats
live in the bucket next to the blocks and pin manifests, so a node
needs nothing but the bucket credentials. There is no gossip transport,
no bootstrap list, no consensus.
What the cluster coordinates:
- Any-node pin/unpin. Pin manifests are last-write-wins objects in
the bucket; every node sees every pin (the reprovide sweep re-reads
them), and any node can serve any pinned block over bitswap or
GET /ipfs/{cid}— the data has exactly one billed copy, in the bucket. - Partitioned DHT announces. With
[swarm] provide = true, live members split the reprovide keyspace by rendezvous hashing — each root is announced by exactly one member. A member that misses heartbeats formember_ttl_secsis dead; survivors notice at their next heartbeat tick and immediately resweep, taking over its share (remote DHT records carry a ~24 h TTL, so a dead node’s announces stay resolvable while the takeover lands). - GC. The sweep deletes blocks no manifest references, but only
once they are
gc_grace_secsold — 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.
Program & PDA reference
This is a reference page — for behavior and pricing, see the topic pages and Economics. It’s here for readers who want to see exactly what’s on-chain.
Program IDs
| Program | Address |
|---|---|
| Mail program | MaiLyqjRuHp8SSQHjiLMPmhBcuLitSta4YdoTiibXu4 |
| Alias program | ALiasg6qDnwcY8HfyeC1AjXRFjyqpXxW4omtwF1i125q |
PDA seeds
| Seed constant | Bytes |
|---|---|
POSTOFFICE_SEED | postoffice |
MAIL_DOMAIN_SEED | maildomain |
MAIL_MESSAGE_SEED | emailmessage |
PUB_ENCRYPTION_KEY_SEED | encryption_key |
FROMBOX_SEED | frombox |
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).
MailInstruction variants
Each variant is the on-chain instruction a sithbit command ultimately
submits:
| Instruction | Emitted by |
|---|---|
SendMail | mail send |
DeleteMail | mail delete |
CreateMailbox | mailbox create |
UpdateMailbox | mailbox update |
CreateFrombox | frombox create |
UpdateFrombox | frombox update |
AddStamps | frombox stamp |
InitPostoffice | postmaster init |
TransferPostmaster | postmaster transfer |
CreateDomain | domain create |
DeactivateDomain | domain deactivate |
CloseDomain | domain close |
TransferDomainAuthority | domain transfer |
SetMailboxKey | mailbox set-key |
WithdrawPostoffice | postmaster withdraw |
CloseFrombox | frombox close |
CloseMailbox | mailbox close |
CloseKey | mailbox close-key |
SetStampFee | postmaster set-stamp-fee |
AliasInstruction variants
| Instruction | Emitted by |
|---|---|
CreateAlias | alias create (and automatically by mailbox create) |
TransferAlias | alias transfer |
CloseAlias | alias close |
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 postmaster 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.
The domain authority is fully trusted for relayed mail
On-chain, SendMail accepts a message when the signer is the sender named
in the email or the active authority of the recipient’s domain — the MX
operator’s wallet, for mail relayed in from traditional SMTP. The chain
cannot verify that the from address on relayed mail is genuine; it trusts
the authority’s signature entirely. Verifying the sender is the operator’s
job, off-chain, via SPF/DKIM/DMARC (the sender_auth setting in
sithbitd’s configuration).
The consequence of a lax or compromised MX is worse than ordinary spam:
because fromboxes are keyed by the from string, a relay that accepts
a forged from lets the forger consume a trusted correspondent’s prepaid
stamps and arrive at that correspondent’s discounted price. Spoofing
through a careless operator is simultaneously stamp theft from the
impersonated sender and a bypass of the recipient’s stranger pricing.
What this means in practice:
- Operators: run
sender_authatspfor stricter on any internet-facing MX. An authority that relays forgeries is spending its own users’ stamps. - 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 postmaster’s ability to deactivate a domain. Per-authority accountability (reputation or stake) is a recognized open design question, not current behavior.
The postmaster is a singleton
One key initializes the postoffice, authorizes and deactivates every domain, tunes the stamp fee (hard-capped on-chain), and sweeps postoffice revenue. Domain ownership itself is proven off-chain: the domain-sithbit service checks a DNS TXT record and co-signs with the postmaster key — so both the verifying agent and the authorizing key are, today, single points of trust.
The on-chain design already bounds the worst outcomes: the stamp fee is
capped (MAX_POSTOFFICE_STAMP_FEE_LAMPORTS), 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 postmaster
key can do is deactivate domains — halting relayed mail for their
mailboxes until reactivation — and refuse to authorize new ones.
Wallet-to-wallet mail between bare pubkeys needs no domain and keeps
working regardless.
Operationally, the postmaster key should be held in a multisig, and deactivation is deliberately a reversible toggle rather than a close.
Message metadata is hashed, not hidden
No SithBit instruction or account carries an address string. A
message account stores the sender wallet, a blake3 hash of the
normalized from address (the frombox seed), the timestamp, and the
IPFS CID; the recipient appears only as the wallet the account’s PDA
seeds on, and the frombox instructions likewise carry the hash. The
human-readable From:/To: headers exist solely inside the sealed
body, readable only by the recipient’s key.
What a chain observer still learns — and should be treated as public:
- wallet-level flow: which wallet received mail, when, and which
sender wallet paid for it (accounts, signatures, and timestamps are
inherent to the chain, and history outlives
DeleteMail); - hash linkage: the same
fromaddress always hashes to the same value, so an observer can correlate “this sender identity again” and can confirm a guess of a known address by hashing it — the hash hides the string, it is not resistant to a dictionary of candidate addresses; - the CID of the sealed body (fetching it yields ciphertext).
What the observer no longer gets is the address book itself: reading
who-mails-whom as alice@corp.com → bob@example.org now requires
already knowing both strings.
Frombox custody favors the recipient
Two behaviors follow from the frombox being the recipient’s account (see Closing accounts):
CloseFromboxreturns the entire balance — rent and any unused prepaid stamps — to the recipient, not to whoever funded them. This is the recipient’s remediation against a sender who stockpiled cheap stamps before a price hike (raising the price never revalues stamps already bought; closing the frombox seizes them). The flip side: buying stamps for someone else’s frombox is a gift with no refund path. Fund a frombox only as generously as you trust its owner.- Anyone can transfer extra lamports into a frombox PDA directly. The
per-send value moved onto a message is
(balance − rent) / stamps, so a topped-up frombox inflates each remaining stamp’s settlement value — at the topper’s expense, to the recipient’s (and operator’s) benefit. Not an attack on anyone else’s funds; just don’t send lamports to a frombox except throughAddStamps.
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.
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.
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.
Further reading
- IPFS official website
- What is IPFS? (official docs)
- Content addressing (official docs)
- InterPlanetary File System (Wikipedia)
Closing accounts and reclaiming rent
Every SithBit account is a Solana account, and creating any Solana account requires a one-time SOL deposit — rent-exemption — that scales with how many bytes the account stores. It isn’t a fee: it’s a refundable deposit that sits in the account for as long as it exists, and it has nothing to do with any price or value the account might represent (a mailbox’s rent, for example, is the same regardless of how much postage it charges). Closing an account you no longer need reclaims that rent back to you. See Solana’s account model docs for the full mechanics of how the minimum balance is calculated.
| Command | Signer | What’s reclaimed |
|---|---|---|
mailbox close | Mailbox owner | The mailbox account’s rent |
mailbox close-key | Mailbox owner | The delegated encryption key account’s rent |
frombox close | Recipient (mailbox owner) | The frombox’s rent plus its remaining stamp value |
alias close | Current alias holder | The alias account’s rent |
domain close | Postmaster | The domain account’s rent, refunded to whoever paid it originally |
Note: closing drains an account’s lamports to zero, at which point Solana’s runtime deallocates it — so a closed account’s address can generally be recreated later with the matching
createcommand. The one caveat: recreating a mailbox restarts its message-id counter at zero, so any of its old, still-open message accounts (which persist independently of the mailbox itself, settled separately bymail delete) can collide with new sends until they’re cleared out. This is an availability nuisance, not a loss of funds — closing a mailbox that still has undelivered mail is worth avoiding rather than treating as harmless.
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-keygenorsithbit walletproduce, never published.
An address that isn’t a real Ed25519 point at all — notably a Program Derived Address, which has no private key — has no valid conversion and so can never receive sealed mail. This is also why delegated keys exist: hardware and browser wallets can sign with their Ed25519 key but never export the seed the conversion needs, so they publish a self-generated X25519 keypair instead and skip the conversion step entirely.
Sealing a message
Every time a message is sealed — regardless of whether the destination public key came from a wallet conversion or a published delegated key — the same steps run:
- Generate a brand-new X25519 keypair, used for this one message only.
- Run Elliptic-Curve Diffie-Hellman (ECDH) between that ephemeral secret key and the recipient’s X25519 public key. Both sides of an ECDH exchange land on the same point without either one ever transmitting its secret key — that shared point becomes the encryption key.
- Encrypt the plaintext with that shared secret using the XSalsa20-Poly1305 stream cipher, producing ciphertext plus a 16-byte authentication tag.
- Discard the ephemeral secret key. It is never stored or reused.
Throwing away the ephemeral key after one use means the exact same plaintext seals to different ciphertext every time, and nobody — not even the sender, moments later — can reconstruct that message’s shared secret again. It also means the sealed box carries no reusable identity: two messages from the same sender to the same recipient share nothing an observer could link together.
Opening a sealed box
The recipient runs the mirror image of sealing:
- Read the ephemeral public key from the front of the sealed box (see the wire format below — it’s always the first 32 bytes after the header).
- Run ECDH between their own X25519 secret key and that ephemeral public key. This lands on exactly the same point the sender computed in step 2 above — that’s the whole point of Diffie-Hellman: both sides derive an identical shared secret from different halves of the same exchange.
- Use the shared secret to verify the Poly1305 tag and decrypt.
If the tag doesn’t verify — wrong key, or the bytes were altered in transit — decryption fails outright rather than returning corrupted plaintext.
sithbit mail decrypt <file> --keypair <path to wallet keypair>
The wire format
A sealed envelope is a flat, self-describing byte string — no separate key exchange step, no round trip, nothing beyond the message itself needs to reach the recipient:
The 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.
Further reading
- libsodium: Sealed boxes
- libsodium: Ed25519 to Curve25519 conversion
- Elliptic-curve Diffie-Hellman (Wikipedia)
Development and pilot servers
The workspace contains three additional server binaries you may notice
alongside the production ones: smtp-server, imap-server, and
pop-server (from the smtp_server, imap_server, and pop_server
crates).
Not for deployment. These are memory-backed dev/pilot binaries used to prove out the underlying sans-io protocol session logic before
sithbitdexisted to host it against real storage. They have no chain pipeline, no real persistent storage, and no postage enforcement — accepted mail is logged, not delivered. For running an actual mail server, see Running a mail server andsithbitd.
Migration history
SithBit’s server-side implementation was originally written in .NET
(sithbitnet, plus a few smaller related projects). That implementation was
fully migrated into this Rust workspace, completed 2026-07-05; the .NET
projects are now deprecated and receive no new feature work.
For the full record of that migration — standing decisions, adopted and
rejected approaches, and deliberate divergences from the legacy behavior —
see the workspace’s CLAUDE.md and rust/HANDOFF.md, which are the
authoritative living documents for this history rather than this page.