ricochet-mailbox
Skill
ricochet-mailbox. Use when building a store-and-forward mailbox app on Ricochet (offline delivery, E2E encrypted messages, push notifications) or writing Go code against github.com/stephanfeb/go-ricochet/pkg/client and pkg/wire; based on go-ricochet/examples/mailbox. Covers server preferences and failover, acknowledging and draining, notifications, mailbox types and ACLs.SKILL.md (Claude Code, Codex, Cursor) · ricochet-mailbox.md (Boubou) · install:
curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- ricochet-mailbox
What it is
Section titled “What it is”The pattern for messaging peers that are often offline: the sender stores an
end-to-end encrypted message on a Ricochet server, the recipient collects it
when it next connects, and a recipient that stays connected gets each new
message pushed. The reference program is go-ricochet/examples/mailbox
(about 250 lines, commands id, send, inbox, listen).
This skill covers the Go client (pkg/client, pkg/wire) and the app-level
rules that hold for any client. For Dart/Flutter use the same rules with
ricochet-dart-client; for running the server see ricochet-server.
Install
Section titled “Install”go get github.com/stephanfeb/go-ricochet@v1.6.0go get github.com/stephanfeb/go-libp2p-udx-transport@v0.1.8import ( "github.com/libp2p/go-libp2p" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/network" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/p2p/muxer/yamux" "github.com/libp2p/go-libp2p/p2p/security/noise" udxtransport "github.com/stephanfeb/go-libp2p-udx-transport"
"github.com/stephanfeb/go-ricochet/pkg/client" "github.com/stephanfeb/go-ricochet/pkg/wire")Both pkg/client and pkg/wire are importable from any module (the server’s
internal/ packages are not). go.mod needs Go 1.26.10+.
Core workflow
Section titled “Core workflow”1. Identity and host
Section titled “1. Identity and host”The identity must be Ed25519: E2E keys are derived from it, and it is the peer’s address. Persist it.
func loadOrCreateKey(path string) (crypto.PrivKey, error) { if data, err := os.ReadFile(path); err == nil { return crypto.UnmarshalPrivateKey(data) } else if !errors.Is(err, os.ErrNotExist) { return nil, err } priv, _, err := crypto.GenerateEd25519Key(nil) if err != nil { return nil, err } data, err := crypto.MarshalPrivateKey(priv) if err != nil { return nil, err } return priv, os.WriteFile(path, data, 0o600)}
priv, err := loadOrCreateKey("peer.key")if err != nil { return err }
// Speak what the server speaks: UDX, Noise, yamux. Dialling out is enough;// the server pushes back over the connection the client opened.h, err := libp2p.New( libp2p.Identity(priv), libp2p.NoTransports, libp2p.Transport(udxtransport.NewTransport), libp2p.ListenAddrStrings("/ip4/0.0.0.0/udp/0/udx"), libp2p.Security(noise.ID, noise.New), libp2p.Muxer("/yamux/1.0.0", yamux.DefaultTransport),)if err != nil { return err }defer h.Close()
srv, _ := peer.AddrInfoFromString(os.Getenv("RICOCHET_SERVER")) // .../udx/p2p/12D3KooW...if err := h.Connect(ctx, *srv); err != nil { return err }
cl := client.New(h, client.Config{ PreferredServers: []client.ServerPreference{{PeerID: srv.ID, Priority: 1}}, ConnectionTimeout: 10 * time.Second, // defaults MessageTimeout: 30 * time.Second,})defer cl.Close() // removes the client's handlers; the host stays yours2. Send
Section titled “2. Send”A rejected message is a normal outcome and comes back in the result, not as the error:
res, err := cl.SendMessage(ctx, recipient, []byte(text), client.WithEncryption(), // NaCl box, bound to recipient+folder+id client.WithFolderPath("chat"), // default inbox client.WithPriority(wire.PriorityHigh), client.WithExpiry(7*24*time.Hour), // clamped to the mailbox retention)if err != nil { return err } // transport / no server reachableif err := res.Err(); err != nil { // server refused switch { case errors.Is(err, client.ErrMailboxFull): // 507: only the owner can fix it case errors.Is(err, client.ErrForbidden): // 403: no write grant on a shared mailbox case client.IsRetryable(err): d, _ := client.RetryAfter(err) _ = d }}Many at once: cl.SendMessages(ctx, []client.BatchMessage{...}) returns one
BatchSendResult per message.
3. Collect, then acknowledge
Section titled “3. Collect, then acknowledge”Retrieval decrypts and decompresses but never removes anything:
func inbox(ctx context.Context, cl *client.Client) error { page, err := cl.RetrievePage(ctx, client.WithMaxMessages(100)) for err == nil { ids := make([]string, 0, len(page.Messages)) for _, m := range page.Messages { if seen(m.MessageID) { ids = append(ids, m.MessageID); continue } // replay guard apply(m.SenderPeerID, m.Payload) // durably ids = append(ids, m.MessageID) } if len(ids) > 0 { if _, err := cl.MarkDelivered(ctx, ids); err != nil { return err } } if !page.HasMore { return nil } last := page.Messages[len(page.Messages)-1] page, err = cl.RetrievePage(ctx, client.WithMaxMessages(100), client.WithFromSequence(last.SequenceNumber+1)) } return err}MarkDelivered deletes non-persistent messages and marks persistent ones
seen. For other folders use MarkDeliveredInFolder(ctx, folder, ids) and
WithRetrieveFolderPath(folder). DeleteMessages(ctx, ids) removes
immediately; UpdateFlags(ctx, id, uint32(wire.MsgFlagDeleted), 0) then
Expunge(ctx) is the two-step form.
4. Stay online and receive pushes
Section titled “4. Stay online and receive pushes”newMail := make(chan struct{}, 1)cl.RegisterNotificationHandler(func(n *wire.Notification) { select { case newMail <- struct{}{}: default: } // coalesce bursts})_ = inbox(ctx, cl) // catch up firsttick := time.NewTicker(10 * time.Second)for { select { case <-ctx.Done(): return nil case <-newMail: _ = inbox(ctx, cl) case <-tick.C: if h.Network().Connectedness(srv.ID) != network.Connected { if err := h.Connect(ctx, *srv); err == nil { _ = inbox(ctx, cl) // anything that arrived while disconnected } } }}The server counts a peer as online while it holds a connection and pushes
over /ricochet/mailbox-notify/1.0.0; wire.Notification carries
MailboxPath, MailboxType, OwnerPeerID, MessageCount, Timestamp
(Unix seconds) and ServerPeerID.
5. Lock the inbox down (optional)
Section titled “5. Lock the inbox down (optional)”// Before the peer's first send: create the folder shared and grant the writer._ = cl.CreateMailbox(ctx, "from-bob", wire.MailboxShared, client.WithMailboxMaxMessages(500), client.WithRetentionDays(14))_ = cl.GrantAccess(ctx, "from-bob", bobID, wire.AccessWriteOnly)detail, _ := cl.GetMailboxInfo(ctx, "from-bob") // MessageCount vs MaxMessages6. Run it
Section titled “6. Run it”cd go-ricochet(cd examples/mailbox/docker && ./demo.sh) # server + alice + bob behind two NATsgo build -o mailbox ./examples/mailboxexport RICOCHET_SERVER=/ip4/203.0.113.7/udp/55223/udx/p2p/12D3KooW..../mailbox -key alice.key send 12D3KooWbob... "hello bob"./mailbox -key bob.key listen # -keep inbox (flags first) leaves messages on the serverKey concepts / API map
Section titled “Key concepts / API map”client.New(h, client.Config)/(*Client).Close()/PeerID().client.ServerPreference{PeerID, Priority, Weight}- lowerPriorityfirst, higherWeightbreaks ties.- Send:
SendMessage,SendMessages,ForwardMessage(trusted forwarders only); optionsWithFolderPath,WithPriority,WithExpiry,WithPersistent,WithCompression(threshold...)(LZ4, default 1024 B),WithEncryption. - Read:
RetrieveMessages,RetrievePage(Messages,HasMore); optionsWithRetrieveFolderPath,WithFromSequence,WithMaxMessages(default 100, cap 1000),WithMinPriority,WithTargetPeer(another owner’s shared/public mailbox). - Ack:
MarkDelivered,MarkDeliveredInFolder,UpdateFlags,Expunge(WithExpungeFolderPath),DeleteMessages. - Mailboxes:
CreateMailbox,UpdateMailboxConfig,DeleteMailbox,GrantAccess,RevokeAccess,ListACL,ListMailboxes,GetMailboxInfo,QueryCapacity(UsagePercent()). wire.MailboxPrivate|Shared|Public|Claim;wire.AccessReadOnly|WriteOnly|ReadWrite;wire.PriorityLow|Normal|High|Urgent;wire.MsgFlagSeen|Flagged|Deleted|Draft.- Errors:
ErrMailboxFull,ErrForbidden,ErrNotFound,ErrConflict,ErrRateLimited,ErrOverloaded,ErrClosed,*ProtocolError; helpersStatus(err),RetryAfter(err),IsRetryable(err). - Crypto (exported for other layers):
EncryptBoundPayload,DecryptBoundPayload,BindingFor,PeerIDToX25519PublicKey.
Gotchas
Section titled “Gotchas”- Drain what you consume. Retrieval is not consumption. A client that
only advances a local cursor fills the mailbox to
max_messages_per_mailbox(1000); every sender then getsErrMailboxFull. When both peers do it, sync deadlocks in both directions. - Sender and recipient must use the same server. A client delivers to its own preferred server; there is no lookup of which server holds another peer’s mailbox. Exchange server addresses with the peer ID (QR code, contact card, a public document) and put the recipient’s server first when sending to it, or run one shared server.
- Failover covers dialling only. Servers are tried in priority order and one that cannot be dialled is skipped; a server that answers with a refusal is final. Mail stored on server A is not visible on server B, so a client with several preferred servers must retrieve from each.
- The first delivery decides the folder’s type. A send to a folder that does not exist creates a private mailbox, open to any sender, and the type never changes. To restrict writers, the owner creates the folder shared and grants access before the peer’s first send. Never delete and recreate to “convert” it: undelivered messages are lost and the delivery cache can keep the old one.
- “mailbox full” can mask “no write access”. Shared mailboxes check the ACL before capacity; draining a full one can reveal the missing grant.
- Private inboxes are open by design. Anyone who knows your peer ID can deposit, up to the caps, and identities are free. Use a shared mailbox with an ACL where it matters.
- The envelope is not encrypted. The server sees sender, recipient, folder, timestamps, size, priority and flags; only the payload is sealed. Put sensitive routing data (thread names, subjects) in the payload, not the folder path.
- No forward secrecy, no signatures. Keys are the long-term identity keys; a stolen key opens past mail. NaCl box is deniable. Run a ratchet (Signal) above this layer if you need either.
- Replays are not detected. The binding stops a ciphertext moving to another folder, id or recipient, not a verbatim replay into the same folder. Keep a set of seen message IDs.
- Identity binding is the connection’s, not the message’s. The server
refuses a submission whose
SenderPeerIDis not the connecting peer (unless it is a trusted forwarder with forwarding on), soSenderPeerIDon retrieved mail is trustworthy for the server’s view of who sent it. - One undecryptable message fails the whole Go retrieve.
RetrievePagereturnsdecrypt message <id>: ...and no messages. The Dart client instead returns that message sealed. Make sure every sender encrypts with its real host identity. - Encryption needs the private key in the host peerstore
(
libp2p.Identity(priv)does this); otherwiseWithEncryptionfails. - Push is best effort. The server drops notifications past its worker limit and only pushes while connected. Always poll on reconnect and on a timer; treat a notification as “go and retrieve”, not as the message.
- NAT mappings expire. A client behind a NAT that drops idle UDP mappings
loses its connection silently; check connectedness every few seconds and
redial, as
listendoes. Clients behind NATs reach each other through the server’s relay and hole punch from there (dart-libp2p does by default). - Expiry is clamped to the mailbox’s retention; retention (default 30 days)
is swept every
cleanup_min, not instantly. - Folder paths are validated: at most 256 bytes, no control characters,
no leading, trailing or doubled
/. GetDocumentis the one call that reports its status in the result; every other failure is a typed error. Delete calls returnfalse, nilonly for “already absent”.
Testing
Section titled “Testing”go test $(go list ./... | grep -v test/integration) # pkg/client against loopback serversRICOCHET_TEST_POSTGRES_DSN=postgresql://u:p@localhost:5432/ricochet_test?sslmode=disable \ go test ./test/integration/... -run Mailboxcd examples/mailbox/docker && ./demo.sh && docker compose down -vFor your own app, start a real server against a scratch database (the
integration helpers in test/integration/helpers_test.go show how), give each
test peer a fresh crypto.GenerateEd25519Key, and assert on
RetrievePage after MarkDelivered to prove the inbox drains. Test the
offline path explicitly: send while the recipient host is closed, then open
a new host with the same key and retrieve. Bound every call with a
context.WithTimeout.
Red lines
Section titled “Red lines”- Never send user content without
WithEncryption()(or your own E2E layer). - Never put secrets in the folder path, MIME type or any envelope field.
- Never log or commit identity key files; mode 0600.
- Never acknowledge a message before it is durably applied, or it is gone.
- Never let an inbox grow unbounded; drain it.
- Never close the host from inside the client’s lifecycle;
Close()leaves it to you on purpose.
Reference
Section titled “Reference”- Mailbox example: https://cryptopeer.net/ricochet/mailbox/
- Server, security model and Go client: https://cryptopeer.net/ricochet/server/
- Dart client: https://cryptopeer.net/ricochet/dart-client/
- Repository: https://github.com/stephanfeb/go-ricochet (
examples/mailbox,pkg/client,doc/MAILBOX_LIFECYCLE_AND_ACLS.md) - Related skills:
ricochet-server,ricochet-dart-client,udx-go,dart-libp2p-host