Skip to content

Offline chat

This recipe builds the messaging core of a one-to-one chat app: send encrypted messages to a contact who may be offline, receive them promptly when online, and never lose or double-show one. It composes messages, encryption, notifications and acknowledgement.

  • Each user’s identity key is stored on the device. Their peer ID is their chat address.
  • Chat messages go to the folder chat in the recipient’s mailbox, sealed with WithEncryption. The folder keeps chat apart from other mail the app may use, and it is created as a private mailbox on first delivery.
  • The receiver retrieves on start, on reconnect, on each notification and on a timer, because notifications can be dropped.
  • The receiver stores each message locally first, then acknowledges it. A crash between the two shows the message again on the next retrieve, and the local store removes the duplicate by message ID.
  • Both users list the same server first in PreferredServers, because servers do not forward mail to each other.
// Sketch: the chat send path.
type ChatMessage struct {
Text string `json:"text"`
Sent time.Time `json:"sent"`
}
func Send(ctx context.Context, cl *client.Client, to peer.ID, text string) (string, error) {
body, _ := json.Marshal(ChatMessage{Text: text, Sent: time.Now()})
res, err := cl.SendMessage(ctx, to, body,
client.WithFolderPath("chat"),
client.WithEncryption(),
client.WithCompression(), // only payloads of 1 KiB or more are compressed
)
if err != nil {
return "", err // not sent: show "retrying" and try again later
}
if err := res.Err(); err != nil {
switch {
case errors.Is(err, client.ErrMailboxFull):
return "", fmt.Errorf("%s's inbox is full", to) // retrying will not help
case client.IsRetryable(err):
return "", err // queue and retry after client.RetryAfter(err)
default:
return "", err
}
}
return res.MessageID, nil // show as "delivered to server"
}

The server stores the message for seven days by default. Use WithExpiry for a shorter life; the server clamps a longer one to the mailbox’s retention.

// Sketch: the chat receive loop. Store is your app's local database.
type Store interface {
Has(id string) bool
Save(from string, msg ChatMessage, id string) error
}
func Receive(ctx context.Context, h host.Host, cl *client.Client, server peer.AddrInfo, db Store) error {
wake := make(chan struct{}, 1)
signal := func() {
select {
case wake <- struct{}{}:
default:
}
}
cl.RegisterNotificationHandler(func(n *wire.Notification) {
if n.MailboxPath == "chat" {
signal()
}
})
poll := time.NewTicker(time.Minute)
defer poll.Stop()
signal() // retrieve once at start
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-wake:
case <-poll.C:
// Reconnect if the connection dropped; push needs a live connection.
if h.Network().Connectedness(server.ID) != network.Connected {
if err := h.Connect(ctx, server); err != nil {
continue
}
}
}
if err := drain(ctx, cl, db); err != nil {
log.Println("retrieve:", err)
}
}
}
func drain(ctx context.Context, cl *client.Client, db Store) error {
page, err := cl.RetrievePage(ctx, client.WithRetrieveFolderPath("chat"))
for err == nil {
var handled []string
for _, m := range page.Messages {
var cm ChatMessage
if json.Unmarshal(m.Payload, &cm) == nil && !db.Has(m.MessageID) {
if err := db.Save(m.SenderPeerID, cm, m.MessageID); err != nil {
return err // do not acknowledge what was not saved
}
}
handled = append(handled, m.MessageID)
}
if len(handled) > 0 {
if _, err := cl.MarkDelivered(ctx, handled); err != nil {
return err
}
}
if !page.HasMore {
return nil
}
page, err = cl.RetrievePage(ctx, client.WithRetrieveFolderPath("chat"))
}
return err
}

Because the acknowledged messages are deleted, the next RetrievePage returns the following page; no sequence bookkeeping is needed on a private mailbox.

  • Unencrypted senders. A private mailbox accepts mail from any peer, and the client clears the encrypted flag after decrypting, so your app cannot tell from a retrieved message whether it arrived encrypted. Reject payloads that do not parse, and keep a contact list to filter senders by SenderPeerID.
  • Unknown senders. Anyone who knows a user’s peer ID can write to their chat folder. To accept only contacts, create chat as a shared mailbox and grant each contact wire.AccessWriteOnly; see Mailboxes & ACLs.
  • Forward secrecy. WithEncryption uses long-term keys. For a chat app that needs forward secrecy, run a ratchet such as Signal’s over these messages and send its ciphertext as the payload.
  • Read receipts. Send them as ordinary messages in another folder, such as chat-receipts, so they never mix with chat text.