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
/datavolume. - 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.
# 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:export BOT_TOKEN=$(openssl rand -hex 24)
docker compose up -d
curl -s -H "Authorization: Bearer $BOT_TOKEN" localhost:8080/v1/me{"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):
curl -s -H "Authorization: Bearer $BOT_TOKEN" "localhost:8080/v1/updates?timeout=30"[
{
"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:
curl -s -H "Authorization: Bearer $BOT_TOKEN" -H 'Content-Type: application/json' \
-d '{"text": "you said: hello"}' localhost:8080/v1/chats/u7f…/messagesThe next GET /v1/updates?offset=2 confirms update 1 and returns the ones after it.
An echo bot
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"]})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 }),
})
}
}
}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
- Configuration: all environment variables, accepting chats only from people who came by the bot's link.
- Receiving updates: every update type and the signed webhook.
- Commands and buttons: the "/" menu and inline buttons.
- HTTP API: the full list of requests.