Skip to content

The mailbox example

examples/mailbox is a complete program built on pkg/client: a command-line mail client that sends end-to-end encrypted messages to peers who may be offline, collects them later, and gets new messages pushed while it stays online. Its Docker setup runs a server and two clients behind separate NAT routers on one machine. Source: examples/mailbox.

Command What it does
mailbox id Prints this peer’s ID. Needs no server.
mailbox send <peer-id> <text> Stores an encrypted message for a peer, online or not.
mailbox inbox Prints the waiting messages, decrypted, and acknowledges them. -keep leaves them on the server.
mailbox listen Prints what is waiting, then stays online and prints each new message as it is pushed.

Flags: -key is the identity file (default mailbox.key, created on first use), and -server is the server’s multiaddr ending in /p2p/<peer ID> (default $RICOCHET_SERVER).

The docker/ directory runs PostgreSQL, the server, two NAT routers and two clients, alice and bob. The server sits on a network that stands in for the internet (100.70.0.0/16); each client sits on its own home network (192.168.1.0/24, 192.168.2.0/24) behind its own router. The clients only dial out, so neither needs to be reachable. The images build the server and the client from the repository’s source.

demo.sh plays the whole scenario:

Terminal window
cd examples/mailbox/docker
./demo.sh
alice is 12D3KooWSTfHD9f5o3pz8RWko8PKzENtHumiQRcNR1KFgqjSTyog
bob is 12D3KooWNgThKhH4Fye45QFtAm3rdsDkA4jaRBXTc8dzYyvwBwCQ
== bob is offline. alice sends him a message.
Stored for …vwBwCQ as message 43b299b9-c16c-449f-92e5-937e0c20ff1c
== bob comes online and collects it.
[04:17:38] …jSTyog: Hi bob, this waited for you on the server.
== bob stays online. alice sends again; the server pushes it to him.
Stored for …vwBwCQ as message fb928e90-8ce2-43bc-bc72-4b6aeba10767
Listening as 12D3KooWNgThKhH4Fye45QFtAm3rdsDkA4jaRBXTc8dzYyvwBwCQ. Ctrl-C to stop.
[04:17:42] …jSTyog: And this one was pushed to you.

To run the steps by hand:

Terminal window
docker compose up -d --build server nat-a nat-b
BOB=$(docker compose run --rm bob id)
docker compose run --rm alice send "$BOB" "hello bob"
docker compose run --rm bob inbox

How the pieces fit:

  • The server’s entrypoint starts ricochet --development with --external-addrs set to its address on the simulated internet, and writes the dialable address (/ip4/100.70.3.10/udp/55223/udx/p2p/<id>) to a shared volume once the server prints its peer ID.
  • Each client’s entrypoint routes the simulated internet through its NAT router, waits for that file, and runs mailbox -key /data/peer.key with the command you gave. The key lives in a volume, so the peer ID survives --rm.
  • The routers are port-restricted cone NATs by default. Set NAT_TYPE=symmetric to try a symmetric NAT; the clients still work, because they only dial out.

docker compose down -v deletes everything, identities and stored messages included.

You need a host with a public IP address for the server; set it up as in Install and pass the public address with --external-addrs. Then on each client machine:

Terminal window
go build -o mailbox ./examples/mailbox
export RICOCHET_SERVER=/ip4/203.0.113.7/udp/55223/udx/p2p/12D3KooWBy8U...
./mailbox -key alice.key id
./mailbox -key alice.key send 12D3KooWNgTh... "hello bob"
./mailbox -key bob.key listen

The whole program is about 250 lines. These are the parts that use Ricochet.

The host speaks what the server speaks, and only dials out:

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 := h.Connect(ctx, *serverInfo); err != nil { /* ... */ }
cl := client.New(h, client.Config{
PreferredServers: []client.ServerPreference{{PeerID: serverInfo.ID, Priority: 1}},
})
defer cl.Close()

send encrypts for the recipient and treats a refusal as an error:

res, err := cl.SendMessage(ctx, recipient, []byte(text), client.WithEncryption())
if err != nil {
return err
}
if err := res.Err(); err != nil {
return err // for example client.ErrMailboxFull
}

inbox retrieves, prints and acknowledges. Without the acknowledgement the messages would come back on every run:

msgs, err := cl.RetrieveMessages(ctx)
// ...
for _, m := range msgs {
at := time.UnixMilli(m.CreatedTimestamp).Format("15:04:05")
fmt.Printf("[%s] %s: %s\n", at, short(m.SenderPeerID), m.Payload)
ids = append(ids, m.MessageID)
}
if len(ids) > 0 && !keep {
if _, err := cl.MarkDelivered(ctx, ids); err != nil { /* ... */ }
}

listen turns each push into a retrieve, and reconnects if the connection drops, because push needs a live connection (error handling trimmed):

newMail := make(chan struct{}, 1)
cl.RegisterNotificationHandler(func(n *wire.Notification) {
select {
case newMail <- struct{}{}:
default:
}
})
reconnect := time.NewTicker(10 * time.Second)
for {
select {
case <-ctx.Done():
return nil
case <-newMail:
inbox(ctx, cl, false)
case <-reconnect.C:
if h.Network().Connectedness(server.ID) == network.Connected {
continue
}
if err := h.Connect(ctx, server); err == nil {
inbox(ctx, cl, false) // collect what arrived while disconnected
}
}
}

The channel has room for one signal, so a burst of notifications becomes one retrieve.

  • Sender and recipient use the same server. The client delivers to its own preferred server; there is no lookup of which server holds another peer’s mailbox.
  • The envelope (sender, recipient, time, size) is visible to the server. Only the payload is encrypted.
  • No forward secrecy: the keys are the long-term identity keys. See Encryption.
  • Push needs a live connection. A NAT that drops idle UDP mappings can cut it; listen checks every 10 seconds and reconnects.