Skip to content

Getting started ​

A Mailbees bot is an ordinary Mailbees account with a "bot" label. Mailbees is end-to-end encrypted (the Signal protocol), so the server cannot run your bot for you: the bot's keys must live somewhere only you control. That place is bot-gateway, a Docker image that contains the Mailbees core and exposes a simple HTTP/JSON API.

 your code ──HTTP/JSON──▶ bot-gateway ──Signal protocol──▶ Mailbees relay ──▶ apps
              (Bearer)     /data: keys, messages
  • One container is one bot. Its keys and history are in the /data volume.
  • Your code never touches cryptography: it sends and receives plain JSON.
  • The relay operator sees the bot as any other account; encryption is not weakened.
  • In the apps the bot has a "bot" label, and calls to it are declined.

Run a bot ​

You need Docker and a token you make up yourself: every API request must carry it.

yaml
# compose.yml
services:
  bot:
    image: registry.gitlab.com/pentabion.bumblebee/bumblebee-bot-gateway:latest
    restart: unless-stopped
    environment:
      RELAY_URL: https://mailbees.online
      BOT_NAME: Echo
      BOT_TOKEN: ${BOT_TOKEN:?set BOT_TOKEN}
    volumes:
      - bot-data:/data
    ports:
      - "127.0.0.1:8080:8080"
volumes:
  bot-data:
sh
export BOT_TOKEN=$(openssl rand -hex 24)
docker compose up -d
curl -s -H "Authorization: Bearer $BOT_TOKEN" localhost:8080/v1/me
json
{"id": "…", "name": "Echo", "invite_url": "https://mailbees.online/i#…", "commands": []}

Open invite_url on a phone with Mailbees installed: the chat with the bot opens. Write something to it.

Keep the volume

The /data volume is the bot. Without it the keys are gone, a new account is registered on the next start, and everyone has to add the bot again. Back it up.

Receive and answer ​

Ask for updates. timeout makes the request wait up to that many seconds for something to arrive (long polling):

sh
curl -s -H "Authorization: Bearer $BOT_TOKEN" "localhost:8080/v1/updates?timeout=30"
json
[
  {
    "id": 1,
    "type": "message",
    "chat_id": "u7f…",
    "message": {"id": "m1…", "chat_id": "u7f…", "from": {"id": "u7f…", "name": "Anna"},
                "ts": 1791370000000, "text": "hello"}
  }
]

Answer in the same chat:

sh
curl -s -H "Authorization: Bearer $BOT_TOKEN" -H 'Content-Type: application/json' \
  -d '{"text": "you said: hello"}' localhost:8080/v1/chats/u7f…/messages

The next GET /v1/updates?offset=2 confirms update 1 and returns the ones after it.

An echo bot ​

python
import os, requests

API = "http://localhost:8080/v1"
s = requests.Session()
s.headers["Authorization"] = "Bearer " + os.environ["BOT_TOKEN"]

offset = 0
while True:
    updates = s.get(f"{API}/updates", params={"offset": offset, "timeout": 30}, timeout=40).json()
    for u in updates:
        offset = u["id"] + 1
        m = u.get("message")
        if u["type"] == "message" and m.get("text"):
            s.post(f"{API}/chats/{u['chat_id']}/messages",
                   json={"text": m["text"], "reply_to": m["id"]})
js
const API = 'http://localhost:8080/v1'
const headers = { Authorization: `Bearer ${process.env.BOT_TOKEN}`, 'Content-Type': 'application/json' }

let offset = 0
for (;;) {
  const res = await fetch(`${API}/updates?offset=${offset}&timeout=30`, { headers })
  for (const u of await res.json()) {
    offset = u.id + 1
    if (u.type === 'message' && u.message.text) {
      await fetch(`${API}/chats/${u.chat_id}/messages`, {
        method: 'POST', headers,
        body: JSON.stringify({ text: u.message.text, reply_to: u.message.id }),
      })
    }
  }
}
go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const api = "http://localhost:8080/v1"

func call(method, path string, body any, out any) error {
	var b bytes.Buffer
	if body != nil {
		json.NewEncoder(&b).Encode(body)
	}
	req, _ := http.NewRequest(method, api+path, &b)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("BOT_TOKEN"))
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if out != nil {
		return json.NewDecoder(resp.Body).Decode(out)
	}
	return nil
}

func main() {
	var offset int64
	for {
		var updates []struct {
			ID      int64  `json:"id"`
			Type    string `json:"type"`
			ChatID  string `json:"chat_id"`
			Message struct {
				ID   string `json:"id"`
				Text string `json:"text"`
			} `json:"message"`
		}
		if err := call("GET", fmt.Sprintf("/updates?offset=%d&timeout=30", offset), nil, &updates); err != nil {
			continue
		}
		for _, u := range updates {
			offset = u.ID + 1
			if u.Type == "message" && u.Message.Text != "" {
				call("POST", "/chats/"+u.ChatID+"/messages",
					map[string]string{"text": u.Message.Text, "reply_to": u.Message.ID}, nil)
			}
		}
	}
}

Next ​