Skip to content

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

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.

Terminal window
go get github.com/stephanfeb/go-ricochet@v1.6.0
go get github.com/stephanfeb/go-libp2p-udx-transport@v0.1.8
import (
"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+.

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 yours

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 reachable
if 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.

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.

newMail := make(chan struct{}, 1)
cl.RegisterNotificationHandler(func(n *wire.Notification) {
select { case newMail <- struct{}{}: default: } // coalesce bursts
})
_ = inbox(ctx, cl) // catch up first
tick := 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.

// 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 MaxMessages
Terminal window
cd go-ricochet
(cd examples/mailbox/docker && ./demo.sh) # server + alice + bob behind two NATs
go build -o mailbox ./examples/mailbox
export 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 server
  • client.New(h, client.Config) / (*Client).Close() / PeerID().
  • client.ServerPreference{PeerID, Priority, Weight} - lower Priority first, higher Weight breaks ties.
  • Send: SendMessage, SendMessages, ForwardMessage (trusted forwarders only); options WithFolderPath, WithPriority, WithExpiry, WithPersistent, WithCompression(threshold...) (LZ4, default 1024 B), WithEncryption.
  • Read: RetrieveMessages, RetrievePage (Messages, HasMore); options WithRetrieveFolderPath, 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; helpers Status(err), RetryAfter(err), IsRetryable(err).
  • Crypto (exported for other layers): EncryptBoundPayload, DecryptBoundPayload, BindingFor, PeerIDToX25519PublicKey.
  • 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 gets ErrMailboxFull. 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 SenderPeerID is not the connecting peer (unless it is a trusted forwarder with forwarding on), so SenderPeerID on retrieved mail is trustworthy for the server’s view of who sent it.
  • One undecryptable message fails the whole Go retrieve. RetrievePage returns decrypt 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); otherwise WithEncryption fails.
  • 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 listen does. 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 /.
  • GetDocument is the one call that reports its status in the result; every other failure is a typed error. Delete calls return false, nil only for “already absent”.
Terminal window
go test $(go list ./... | grep -v test/integration) # pkg/client against loopback servers
RICOCHET_TEST_POSTGRES_DSN=postgresql://u:p@localhost:5432/ricochet_test?sslmode=disable \
go test ./test/integration/... -run Mailbox
cd examples/mailbox/docker && ./demo.sh && docker compose down -v

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

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

Built by Stephan Februarygithub.com/stephanfebLinkedIn