---
name: ricochet-mailbox
description: 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.
triggers: examples/mailbox, go-ricochet/pkg/client, client.New, client.ServerPreference, SendMessage, RetrieveMessages, RetrievePage, MarkDelivered, RegisterNotificationHandler, WithEncryption, ErrMailboxFull, store-and-forward mailbox
---

# ricochet-mailbox

## 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

```bash
go get github.com/stephanfeb/go-ricochet@v1.6.0
go get github.com/stephanfeb/go-libp2p-udx-transport@v0.1.8
```

```go
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+.

## Core workflow

### 1. Identity and host

The identity must be Ed25519: E2E keys are derived from it, and it is the
peer's address. Persist it.

```go
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
```

### 2. Send

A rejected message is a normal outcome and comes back in the result, not as
the error:

```go
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.

### 3. Collect, then acknowledge

Retrieval decrypts and decompresses but never removes anything:

```go
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

```go
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`.

### 5. Lock the inbox down (optional)

```go
// 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
```

### 6. Run it

```bash
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
```

## Key concepts / API map

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

## 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 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".

## Testing

```bash
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`.

## 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

- 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`
