Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RelaySMS Publisher

Publishes to online platforms, such as Gmail, from content people send by SMS, so they can post without an internet connection.

How it works

---
config:
  flowchart:
    curve: linear
---
flowchart TB
    app["RelaySMS app"]
    gateway["Gateway client"]
    mailbox["Mailbox"]
    platform["Online platforms"]

    subgraph publisher["Publisher"]
        grpc["gRPC API"]
        rest["REST API"]
        smtp["Email listener"]
        db[("Database")]
        worker["Celery worker"]
        adapter["Platform adapter"]
        %% Keeps the title clear of the arrows entering the box.
        spacer[" "]:::hidden
        spacer ~~~ worker
    end

    app -- "link an account, online" --> grpc
    app -- "encrypted SMS" --> gateway
    gateway -- "HTTPS" --> rest
    gateway -- "email" --> mailbox
    mailbox -- "IMAP" --> smtp
    grpc -- "token, keys" --> db
    db --> worker
    rest --> worker
    smtp --> worker
    grpc --> adapter
    worker --> adapter
    adapter --> platform

    classDef hidden fill:none,stroke:none
  1. While online, a user links an account through the gRPC API. The Publisher stores its token encrypted.
  2. Offline, the app encrypts the content and sends it by SMS to a gateway client, a phone number that relays SMS.
  3. The gateway client posts the payload to the REST API, or emails it to a mailbox the Publisher reads.
  4. A Celery worker decrypts the payload and publishes it through the platform’s adapter.

Key terms

  • Gateway client: a phone number that relays SMS to the Publisher, such as an Android phone running Deku SMS. See Connecting a relay.
  • Adapter (platform adapter): the plugin that publishes to one platform, such as Gmail, installed from its own repository. See Managing adapters.
  • Linked account: a user’s account on a platform, authorized once while online. The Publisher stores its token encrypted.
  • OAuth2 and PNBA: how accounts link. OAuth2 goes through the platform’s consent page, as for Gmail. Phone number-based authentication (PNBA) uses a code sent to the user’s phone, as for Telegram. The provider guides give each platform’s protocol.
  • Offline payload: content sent without a linked account, published through RelaySMS Mail. See Offline publishing.

The glossary defines the rest, and Architecture shows every component.

Where to start

ToRead
Try the Publisher on your machineQuick start
Run it in productionRequirements, then Install on a server
Offer a platformManaging adapters
Relay SMS to the PublisherManaging gateway clients
Build a clientgRPC API and REST API
Understand the protocolSecurity model and the RelaySMS whitepaper

Getting help

Quick start

Runs the Publisher on your machine with Docker, to try it before installing it on a server.

Requirements

  • Docker with the Compose plugin: Docker Engine on Linux, or Docker Desktop, which includes Compose, on macOS and Windows. On Linux, add yourself to the docker group or prefix the docker commands with sudo.

  • Git, make and openssl: on macOS, xcode-select --install provides Git and make, and openssl ships with the system. On Windows, run the steps in WSL. On Ubuntu or Debian:

    sudo apt-get install -y git make openssl
    

Check them:

docker compose version
git --version
make --version
openssl version

Steps

  1. Clone the repository with its submodule:

    git clone --recurse-submodules https://github.com/smswithoutborders/RelaySMS-Publisher.git
    cd RelaySMS-Publisher
    
  2. Create .env with fresh encryption keys:

    make .env
    
  3. Build and start. The first build compiles the payload library in Rust and takes several minutes:

    docker compose up -d --build
    
  4. Check that it’s up:

    curl http://localhost:16000/health
    

    You should see {"status":"ok"}. If the connection fails, the container is still applying migrations; docker compose logs -f shows its progress.

  5. Create an administrator credential:

    docker compose exec publisher \
      python -m publisher creds create --username admin --administrator
    

    Important

    The password is printed only once.

  6. Use it on the REST API:

    curl -u admin:<PASSWORD> http://localhost:16000/v1/auth/me
    

    You should see "username":"admin" and "administrator":true.

To stop the Publisher, run docker compose down. To start again from scratch, also run sudo rm -rf data .env.

Next steps

The pages below run the admin CLI as sudo ./publisher.sh; with Docker, use docker compose exec publisher python -m publisher instead.

Requirements

  • Operating system: Ubuntu 24.04 or Debian 13, or any Linux host with Docker.
  • Python: 3.12 or newer. The installer installs it, with the other system packages and Rust.
  • Database: SQLite (the default) or a database server. See Database for the supported ones.
  • Domain: a DNS name pointing at the server, for the TLS certificate. See Reverse proxy and TLS.
  • Inbound ports (firewall): 80 and 443 for nginx. On a server install, the services listen on 127.0.0.1 only. Docker publishes PORT (16000) and GRPC_PORT (6000).
  • Outbound access: github.com, pypi.org, the Rust installer and the system’s package mirrors to install the Publisher and its adapters; Let’s Encrypt for certificates; each platform’s API to publish.
  • Platform credentials: from each platform you offer, such as a Google Cloud OAuth client for Gmail. See Provider guides.
  • Gateway client: at least one phone number that relays SMS, such as an Android phone running Deku SMS. See Connecting a relay.

Install on a server

The installer sets up the Publisher on Ubuntu 24.04 or Debian 13 under systemd, in /opt/relaysms/relaysms-publisher. Check the requirements first.

Run the installer

Tip

For a dedicated service user with no login, run the installer from a root shell (sudo -i). The service user can’t be changed later without reinstalling.

curl -fsSL https://raw.githubusercontent.com/smswithoutborders/RelaySMS-Publisher/main/install.sh \
  | sudo bash

To read the script before running it:

curl -fsSLO https://raw.githubusercontent.com/smswithoutborders/RelaySMS-Publisher/main/install.sh
less install.sh
sudo bash install.sh

It asks about each option you don’t pass as a flag. To choose up front, pass flags after bash -s --:

curl -fsSL https://raw.githubusercontent.com/smswithoutborders/RelaySMS-Publisher/main/install.sh \
  | sudo bash -s -- --site-name publisher.example.com --setup-db postgres
FlagDoes
--site-name <DOMAIN>Puts nginx with a Let’s Encrypt certificate in front (details)
--letsencrypt-email <EMAIL>Email for Let’s Encrypt renewal notices
--docs-site-name <DOMAIN>Serves this manual on its own domain (details)
--skip-nginxSkips nginx and certificates entirely
--setup-db mysql|postgresUses MySQL or PostgreSQL instead of SQLite (details)
--setup-broker rabbitmqUses RabbitMQ instead of SQLite as the Celery broker (details)
--setup-observabilityAdds SigNoz and Uptime Kuma (details)
--install-dir <PATH>, --instance-name <NAME>Installs elsewhere, or a second copy on the same host (details)
--branch <BRANCH>Installs another branch than main

install.sh --help lists every flag, including those for an existing database or broker.

The installer:

  1. installs the system packages and Rust;
  2. picks the service user: the user who ran sudo, or a new relaysms user when run from a root shell;
  3. clones the repository, builds it and creates .env with fresh encryption keys;
  4. sets up the database and broker you chose, and applies the database migrations;
  5. installs and starts the systemd services;
  6. sets up nginx and the certificate, if you gave a domain.

Running it again updates the install in place and keeps its .env. Run it as the same user as the first time; it refuses another service user.

After installing

  1. Go to the install directory and check that the services are up:

    cd /opt/relaysms/relaysms-publisher
    sudo ./manage.sh status
    curl http://127.0.0.1:16000/health
    

    You should see {"status":"ok"}, also at https://<DOMAIN>/health once nginx is set up.

  2. The defaults suit one server behind nginx. Change .env only for what they don’t cover, such as a MySQL or PostgreSQL database (settings reference), then check it and restart:

    sudo ./manage.sh check
    sudo ./manage.sh restart
    
  3. Create an administrator credential:

    sudo ./publisher.sh creds create --username ops --administrator
    

    Important

    The password is printed only once.

  4. Back up .env: its encryption keys are the only way to read the data.

Serving the manual

--docs-site-name builds this manual into book/ and serves it with nginx on its own domain and certificate. manage.sh update rebuilds it.

curl -fsSL https://raw.githubusercontent.com/smswithoutborders/RelaySMS-Publisher/main/install.sh \
  | sudo bash -s -- --site-name publisher.example.com --docs-site-name docs.publisher.example.com

On an install that’s already running, add it from the install directory:

sudo ./manage.sh docs docs.publisher.example.com

Next steps

Install with Docker

docker-compose.yml runs every service in one container, with its data in ./data and its settings in .env (see the settings reference). It needs the tools in the quick start’s requirements.

git clone --recurse-submodules https://github.com/smswithoutborders/RelaySMS-Publisher.git
cd RelaySMS-Publisher
make .env
docker compose up -d --build

The container applies pending migrations, then starts every service. It publishes PORT (16000) and GRPC_PORT (6000) on every interface. To serve it publicly, bind them to 127.0.0.1 in docker-compose.yml and put nginx with TLS in front.

Run the admin CLI with docker compose exec publisher python -m publisher <COMMAND>.

Back up .env and ./data: its encryption keys are the only way to read the data.

To update, see Upgrading.

Next steps

Continue with After installing from step 3, running the CLI as docker compose exec publisher python -m publisher.

Install by hand

These are the steps the installer runs, for when you need to change one. Run them in a root shell (sudo -i).

  1. Install the system packages:

    apt-get update
    apt-get install -y python3 python3-pip python3-venv python3-dev \
        build-essential pkg-config libsqlcipher-dev libmagic1 openssl git make curl
    
  2. Install Rust, which builds the payload library:

    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
    source "$HOME/.cargo/env"
    
  3. Create the service user:

    useradd --system --no-create-home --shell /usr/sbin/nologin relaysms
    
  4. Clone the repository with its submodule:

    git clone --recurse-submodules \
        https://github.com/smswithoutborders/RelaySMS-Publisher.git \
        /opt/relaysms/relaysms-publisher
    cd /opt/relaysms/relaysms-publisher
    
  5. Create the virtualenv and build the gRPC code and payload library:

    python3 -m venv venv
    venv/bin/pip install --upgrade pip
    venv/bin/pip install -r requirements.txt
    make build
    
  6. Create .env with fresh encryption keys, then set what you need (see the settings reference):

    make .env
    chown root:relaysms .env
    chmod 640 .env
    
  7. Create the data directories. By default the SQLite database, Celery files and adapters all live under data/:

    mkdir -p data/platforms/{adapters,venvs,config,state}
    chown -R relaysms: data
    chmod -R 750 data
    

    If .env moves any of these paths out of data/ (SQLITE_DATABASE_PATH, CELERY_BROKER_DB_PATH, CELERY_RESULT_DB_PATH, CELERY_BEAT_SCHEDULE_PATH and the four PLATFORMS_ADAPTERS_* directories), create its parent directory and add it to RW_PATHS in step 9. Everything else is read-only to the services.

    For MySQL, PostgreSQL or RabbitMQ, run its setup script now (Database, Celery broker).

  8. Check the configuration and apply the database migrations, as the service user:

    sudo -u relaysms venv/bin/python -m publisher config check
    sudo -u relaysms venv/bin/python -m alembic upgrade head
    
  9. Install and start the systemd units. RW_PATHS lists the directories the services may write to:

    RW_PATHS="$(pwd)/data"
    for unit in deploy/systemd/*; do
        sed -e "s|/opt/relaysms/relaysms-publisher|$(pwd)|g" \
            -e "s|__RW_PATHS__|$RW_PATHS|" \
            "$unit" > "/etc/systemd/system/$(basename "$unit")"
    done
    systemctl daemon-reload
    systemctl enable --now relaysms-publisher.target
    

Next steps

Set up nginx and TLS, then continue with After installing.

Database

DialectUse it forEncryption at rest
SQLite (default)One server with light loadSQLCipher, with DATABASE_ENCRYPTION_KEY
MySQL 8.0.28+ or MariaDBHeavier load, or a database on another hostThe server’s own (TDE)
PostgreSQL 12+Heavier load, or a database on another hostThe disk’s

In every dialect, the server’s private keys are encrypted, and so are platform tokens while field encryption is on; see Encryption keys.

Database servers

A setup script installs the server, creates the database and user, and writes the connection details to .env. Running it again without --db-password gives the user a new password. On Debian, install MariaDB yourself and pass --db-existing.

During install:

sudo ./install.sh --setup-db postgres

On an existing install, from the install directory:

sudo ./scripts/setup-postgres.sh --db-name relaysms --db-user relaysms

For MySQL or MariaDB, use --setup-db mysql or scripts/setup-mysql.sh the same way.

--db-password <PASSWORD> sets the password instead of generating one.

To use a server you already have, pass its details. The script only checks the connection and writes .env; it never changes the server:

sudo ./install.sh --setup-db postgres --db-existing \
    --db-host db.example.com --db-port 5432 \
    --db-name relaysms --db-user relaysms --db-password '<PASSWORD>'

On an existing install:

sudo ./scripts/setup-postgres.sh --existing \
    --db-host db.example.com --db-name relaysms --db-user relaysms --db-password '<PASSWORD>'

Or set it by hand in .env. For PostgreSQL:

DATABASE_DIALECT=postgres
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_USER=relaysms
POSTGRES_PASSWORD=<PASSWORD>
POSTGRES_DATABASE=relaysms

For MySQL or MariaDB:

DATABASE_DIALECT=mysql
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=relaysms
MYSQL_PASSWORD=<PASSWORD>
MYSQL_DATABASE=relaysms

Then apply the migrations and restart:

sudo ./manage.sh migrate
sudo ./manage.sh restart

The new database starts empty: switching doesn’t copy the data from SQLite.

Celery broker

The REST API and the email listener queue publications for the Celery worker through a broker.

CELERY_BROKER_TYPEUse it forWorker concurrency by default
sqlite (default)Light load. Needs no server.1
rabbitmqHeavier load4
redisHeavier load, with a Redis server you already run4

CELERY_WORKER_CONCURRENCY overrides the concurrency.

RabbitMQ

A setup script installs RabbitMQ, creates a vhost and user, and writes the broker settings to .env.

During install:

sudo ./install.sh --setup-broker rabbitmq

On an existing install, from the install directory:

sudo ./scripts/setup-rabbitmq.sh --broker-vhost relaysms --broker-user relaysms

--broker-password <PASSWORD> sets the password instead of generating one. To use a broker you already have, add --existing (--broker-existing on install.sh) with --broker-host. The script checks the credentials against the management API (port 15672, or --broker-mgmt-port) and changes nothing on the broker.

By hand

For RabbitMQ, in .env:

CELERY_BROKER_TYPE=rabbitmq
CELERY_RABBITMQ_URL=amqp://relaysms:<PASSWORD>@localhost:5672/relaysms

For Redis:

CELERY_BROKER_TYPE=redis
CELERY_REDIS_URL=redis://localhost:6379/0

Then restart the services with sudo ./manage.sh restart.

Reverse proxy and TLS

The services listen on 127.0.0.1. nginx serves them on port 443 with a Let’s Encrypt certificate, from the site template. It sends /publisher.v3.Publisher and /grpc.health.v1.Health to gRPC on GRPC_PORT, and everything else to REST on PORT. It rate-limits logins and HTTP Basic requests (limits).

With the installer

Pass --site-name <DOMAIN> to the installer. Running it again leaves an existing site file alone, and skips Certbot when the domain already has a certificate; use manage.sh nginx to render the site again.

On an existing install

manage.sh nginx renders the site from the template, obtains or reattaches its certificate, and turns on HTTP/2, which gRPC needs. Run it to add nginx later, or after an update that changes the template. It keeps the previous file as <SITE>.conf.bak and restores it if the new one fails.

sudo apt-get install -y nginx certbot python3-certbot-nginx
sudo ./manage.sh nginx publisher.example.com

By hand

With nginx and Certbot installed as above:

sudo sed -e "s/__SERVER_NAME__/publisher.example.com/g" \
    -e "s/__REST_PORT__/16000/g" -e "s/__GRPC_PORT__/6000/g" \
    deploy/nginx/relaysms-publisher-nginx.conf.template |
    sudo tee /etc/nginx/sites-available/publisher.example.com.conf >/dev/null
sudo ln -s /etc/nginx/sites-available/publisher.example.com.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d publisher.example.com --redirect

Then turn on HTTP/2 in the HTTPS block Certbot added: add http2 on; after listen 443 ssl; (nginx 1.25.1 or newer), or change it to listen 443 ssl http2;. Reload nginx.

gRPC TLS without nginx

The gRPC server can serve TLS itself. The REST API can’t, so it still needs a reverse proxy.

GRPC_HOST=0.0.0.0
GRPC_TLS_ENABLED=true
GRPC_TLS_CERT_FILE=/path/to/fullchain.pem
GRPC_TLS_KEY_FILE=/path/to/privkey.pem

Multiple instances

To run a second Publisher on the same host, give it its own install directory, instance name and ports:

  1. Install it without nginx. When it shares a database or RabbitMQ server with the first instance, give it its own user: pass --db-name acme --db-user acme or --broker-vhost acme --broker-user acme.

    sudo ./install.sh --install-dir /srv/relaysms-acme --instance-name acme --skip-nginx
    
  2. Set PORT and GRPC_PORT in /srv/relaysms-acme/.env to ports no other instance uses.

  3. Restart it, then put nginx in front:

    cd /srv/relaysms-acme
    sudo ./manage.sh restart
    sudo ./manage.sh nginx acme.example.com
    

Its units are named after the instance (relaysms-publisher-acme.target, relaysms-publisher-acme-rest.service, …), and the manage.sh in each install directory manages only its own instance. Running install.sh again on that directory reuses the name; a different --instance-name is refused.

Settings reference

Settings live in .env in the install directory. template.env lists each one with its default.

  • sudo ./manage.sh check reports missing or invalid settings; each service refuses to start on an error in the settings it uses.
  • Restart the services after a change: sudo ./manage.sh restart.
  • After an update, sudo ./sync-env.sh adds the settings from template.env that .env is missing, and shows the change before writing it.
  • On Docker, check with docker compose exec publisher python -m publisher config check and apply a change with docker compose up -d.

Server

SettingDefaultDescription
HOST127.0.0.1REST API address. Docker always binds 0.0.0.0.
PORT16000REST API port
WORKERSNoneREST API worker processes: 1 by default under scripts/run.sh, 4 on Docker and server installs
GRPC_HOST127.0.0.1gRPC address. Docker always binds 0.0.0.0.
GRPC_PORT6000gRPC port
GRPC_MAX_WORKERS10gRPC worker threads
GRPC_TLS_ENABLEDfalseServes TLS on GRPC_PORT itself; leave off behind nginx (details)
GRPC_TLS_CERT_FILE, GRPC_TLS_KEY_FILENoneCertificate and key. Required when GRPC_TLS_ENABLED=true.
NONCE_TTL_SECONDS600How far a gRPC request’s timestamp may be from the server’s clock; nonces are remembered this long to refuse replays

Database

See Database and Encryption keys.

SettingDefaultDescription
DATABASE_DIALECTsqlitesqlite, mysql (MySQL and MariaDB) or postgres
SQLITE_DATABASE_PATHdata/relaysms.dbSQLite database file
MYSQL_HOST127.0.0.1MySQL server
MYSQL_PORT3306MySQL port
MYSQL_USER, MYSQL_PASSWORDNoneRequired for mysql
MYSQL_DATABASErelaysmsRequired for mysql; created if it doesn’t exist
POSTGRES_HOST127.0.0.1PostgreSQL server
POSTGRES_PORT5432PostgreSQL port
POSTGRES_USER, POSTGRES_PASSWORDNoneRequired for postgres
POSTGRES_DATABASErelaysmsRequired for postgres; created if it doesn’t exist
DATABASE_ENCRYPTION_ENABLEDtrue (false if unset)Encrypts the SQLite file with SQLCipher. No effect on MySQL or PostgreSQL.
DATABASE_ENCRYPTION_KEYNoneRequired for SQLite when encryption is on
DATABASE_FIELD_ENCRYPTION_ENABLEDtrue (false if unset)Encrypts linked accounts’ platform tokens
DATABASE_FIELD_ENCRYPTION_KEYNoneRequired when field encryption is on
DATA_ENCRYPTION_KEYNoneRequired. Encrypts the server’s private keys, and keys the hash that replaces each sender’s address.

Celery

See Celery broker.

SettingDefaultDescription
CELERY_BROKER_TYPEsqlitesqlite, redis or rabbitmq
CELERY_WORKER_CONCURRENCY1 for sqlite, else 4Tasks the worker runs at once
CELERY_BROKER_DB_PATHdata/celery_broker.dbBroker file when CELERY_BROKER_TYPE=sqlite
CELERY_RESULT_DB_PATHdata/celery_results.dbResult file when CELERY_BROKER_TYPE=sqlite
CELERY_REDIS_URLredis://localhost:6379/0Broker and result backend when CELERY_BROKER_TYPE=redis
CELERY_RABBITMQ_URLamqp://guest:guest@localhost:5672//Broker when CELERY_BROKER_TYPE=rabbitmq
CELERY_BEAT_SCHEDULE_PATHdata/celerybeat-scheduleBeat’s schedule file
CELERY_CLEANUP_CRON0 */3 * * *When to clean up expired data (below)
CELERY_TOKEN_CLEANUP_CRON0 3 * * *When to remove idle tokens (below)

Cleanup jobs

Both schedules are 5-field cron expressions in UTC.

  • CELERY_CLEANUP_CRON deletes incomplete payloads, ended web sessions, and audit events and adapter jobs past retention, and fails abandoned adapter jobs.
  • CELERY_TOKEN_CLEANUP_CRON deletes tokens idle past TOKEN_IDLE_MAX_AGE_DAYS and revokes them on their platform.

Retention

SettingDefaultDescription
PAYLOAD_SESSION_MAX_AGE_HOURS3Hours before an incomplete multi-part payload is discarded
TOKEN_IDLE_MAX_AGE_DAYS90Days without use before a linked account’s token is deleted and revoked on its platform
AUDIT_RETENTION_DAYS365Days to keep audit events and adapter install and update jobs

Offline publishing

See Offline publishing.

SettingDefaultDescription
OFFLINE_PUBLISH_ALLOWED_PROTOCOLSsmtp (empty if unset)Comma-separated protocols whose offline payloads publish: https, smtp. Empty allows both.
OFFLINE_PUBLISH_SHARED_SECRETNone64 hex characters. Required when https is allowed.

SMTP transport

See SMTP transport.

SettingDefaultDescription
SMTP_TRANSPORT_ENABLEDfalseRuns the email listener
SMTP_IMAP_SERVER, SMTP_IMAP_USERNAME, SMTP_IMAP_PASSWORDNoneIMAP host and login. Required when the transport is enabled.
SMTP_IMAP_PORT993IMAP port
SMTP_IMAP_MAIL_FOLDERINBOXComma-separated folders to watch, such as INBOX,Spam
SMTP_TLS_CLIENT_CERTIFICATE, SMTP_TLS_CLIENT_KEYNonemTLS client certificate and key, if the provider requires one. Set both or neither.
SMTP_ALLOWED_SENDERSNoneComma-separated addresses and domains allowed to send. Empty allows nothing.
SMTP_TRUSTED_AUTHSERV_IDNoneauthserv-id whose Authentication-Results verdicts are trusted, such as mx.google.com. Required when the transport is enabled.
SMTP_REQUIRE_DKIM, SMTP_REQUIRE_SPFtrueRequire dkim=pass and spf=pass
SMTP_VERIFY_DKIM_INDEPENDENTLYfalseAlso re-verifies the DKIM signature against DNS

Platform adapters

See Managing adapters. Relative paths are from the install directory.

SettingDefaultDescription
PLATFORMS_ADAPTERS_DIRdata/platforms/adaptersDirectory for adapters’ code
PLATFORMS_ADAPTERS_VENV_DIRdata/platforms/venvsDirectory for adapters’ virtualenvs
PLATFORMS_ADAPTERS_CONFIG_DIRdata/platforms/configDirectory for adapters’ credentials.json files
PLATFORMS_ADAPTERS_STATE_DIRdata/platforms/stateDirectory for files adapters write, such as databases
PLATFORMS_GITHUB_ORGSNoneComma-separated GitHub orgs that administrators may install and update adapters from over the REST API. Empty refuses REST installs; publisher.sh platforms is unaffected.

REST API access

See Credentials and access.

SettingDefaultDescription
AUTH_WEB_ORIGINSNoneComma-separated web client origins allowed to use a session cookie, such as https://console.example.com
AUTH_SESSION_COOKIE_SECUREtrueSends the session cookie over HTTPS only. Set false only for local HTTP development.
AUTH_SESSION_IDLE_MINUTES30Minutes of inactivity that end a web session
AUTH_SESSION_MAX_HOURS12Hours after login when a web session ends, even if it’s active
API_DOCS_ENABLEDfalseServes interactive API docs at /docs and the schema at /openapi.json. For development.

AUTH_WEB_ORIGINS origins must be on the same site as the API. Leave it empty when the web client is served from the API’s own origin.

Logging and monitoring

See Observability.

SettingDefaultDescription
LOG_LEVELinfodebug, info, warning, error or critical
OTEL_EXPORTER_OTLP_ENDPOINTNoneOTLP collector URL for traces, metrics and logs, such as http://localhost:4317. Needs requirements-observability.txt. Blank turns all three off.
OTEL_EXPORTER_OTLP_PROTOCOLgrpcgrpc or http/protobuf
OTEL_TRACES_SAMPLERparentbased_traceidratioWhich traces to keep
OTEL_TRACES_SAMPLER_ARG1.0Fraction of traces kept, 0 to 1
OTEL_LOGS_EXPORTERotlpotlp sends logs to the collector; none doesn’t
OTEL_PYTHON_LOG_CORRELATIONtrueAdds trace and span IDs to log records
UPTIME_KUMA_WORKER_PUSH_URL, UPTIME_KUMA_SMTP_PUSH_URLNoneUptime Kuma push URLs for the worker and email listener heartbeats. Blank turns them off.

Encryption keys

Three keys in .env protect the data. The installer and make .env generate them. Each is 64 hex characters; openssl rand -hex 32 makes one, and manage.sh check reports a missing or malformed key.

SettingProtectsUsed
DATA_ENCRYPTION_KEYThe server’s private keys, and the keyed hash that replaces each sender’s addressAlways
DATABASE_FIELD_ENCRYPTION_KEYLinked accounts’ platform tokensWhen field encryption is on
DATABASE_ENCRYPTION_KEYThe whole SQLite database file (SQLCipher)When encryption is on and the database is SQLite

Caution

Back up .env whenever you back up the database, store it apart (Backup and restore), and never change a key once the install holds data. Data encrypted with a lost or changed key can’t be read again, and a new DATA_ENCRYPTION_KEY also gives every sender a new hash.

Offline publishing

Offline payloads let people send email through RelaySMS Mail without linking an account; linked accounts need none of this (settings). The app encrypts an offline payload to the server’s identity key, and the Publisher publishes it through the rmail adapter, which must be installed. Anyone who can reach an allowed protocol can send one.

Each payload is tagged with the protocol it arrived on:

  • https: REST POST /v1/publications

  • smtp: the SMTP transport

  • An offline payload from a protocol not in OFFLINE_PUBLISH_ALLOWED_PROTOCOLS is discarded.

  • An offline payload over https must carry OFFLINE_PUBLISH_SHARED_SECRET in the request’s tag field, or it’s discarded.

  • Leaving OFFLINE_PUBLISH_ALLOWED_PROTOCOLS empty, or listing https, without the secret fails manage.sh check, and the services refuse to start.

  • A discarded payload is logged and recorded as failed in the publication stats, with reason protocol_not_allowed, offline_tag_missing or offline_tag_invalid. POST /v1/publications still returns success, since the check runs in the queue.

Allowing https

  1. Generate a secret:

    openssl rand -hex 32
    
  2. Set OFFLINE_PUBLISH_SHARED_SECRET to it, and add https to OFFLINE_PUBLISH_ALLOWED_PROTOCOLS, such as smtp,https.

  3. Check and restart:

    sudo ./manage.sh check
    sudo ./manage.sh restart
    

Next steps

Connect each relay that posts over https with the secret as its tag (FORWARD_TAG for Twilio).

SMTP transport

Use this when gateway clients relay payloads by email instead of HTTPS. The listener watches a mailbox over IMAP, checks each sender, and queues the payload for publication.

Set it up

  1. Create a mailbox that the gateway clients email, with IMAP access.

  2. In .env, set SMTP_IMAP_SERVER, SMTP_IMAP_USERNAME and SMTP_IMAP_PASSWORD. To also watch a spam folder, set SMTP_IMAP_MAIL_FOLDER, such as INBOX,Spam.

  3. Set SMTP_ALLOWED_SENDERS to the addresses the gateway clients send from (allowlist).

  4. Set SMTP_TRUSTED_AUTHSERV_ID to the authserv-id the mailbox’s server writes: the first word of the Authentication-Results header in a received email, such as mx.google.com (authentication).

  5. Set SMTP_TRANSPORT_ENABLED=true, then check and restart:

    sudo ./manage.sh check
    sudo ./manage.sh restart
    

Settings reference lists every SMTP_* setting.

Check it

sudo ./manage.sh logs --unit smtp --no-follow --lines 20

You should see Connected to mailbox <SMTP_IMAP_SERVER>.

How it works

  1. Waits for unseen mail in SMTP_IMAP_MAIL_FOLDER with IMAP IDLE.
  2. Rejects each email unless the sender is allowlisted and authenticated.
  3. Parses the body as JSON and validates the payload.
  4. Queues the payload for publication, tagged with protocol smtp. Whether offline payloads from email publish depends on offline publishing.
  5. Deletes each handled email. An email that hits an unexpected error stays in the folder and is retried on the next cycle.

Allowlist

SMTP_ALLOWED_SENDERS lists exact addresses and domains, comma-separated. Empty allows nothing.

Warning

A domain entry trusts every authenticated sender on that domain, since DKIM vouches for the domain, not the mailbox. List an exact address when only one sender should be trusted.

Authentication

The listener can’t re-check SPF, which needs the sender’s IP. It reads the Authentication-Results header (RFC 8601) that the mailbox’s own server added, and only from the authserv-id in SMTP_TRUSTED_AUTHSERV_ID, since a sender can forge such a header from anyone else.

Message format

The body of each email must be a JSON object:

{
  "address": "5447c1f50558292bd9df723f9fdc0b06b892199c7dcfaad5164f2d94dfd3470a",
  "dialing_code": "237",
  "text": "<base64-encoded serialized payload>"
}
FieldTypeRequiredDescription
addressstringYesSender ID from the gateway client, or an E.164 phone number
dialing_codestringNoSender’s country calling code, for publication stats
textstringYesBase64-encoded serialized payload

Next steps

To publish offline payloads that arrive by email, allow smtp in Offline publishing.

Managing adapters

Each platform, such as Gmail, is served by an adapter installed from its own GitHub repository.

Run these commands from the install directory with publisher.sh, which runs them as the service user.

Commands

CommandDoes
platforms add <GITHUB_URL> [--tag <TAG>]Installs the newest version tag, or <TAG>
platforms update [<NAME>] [--tag <TAG>]Moves one adapter, or all, to a version tag
platforms listLists every adapter, disabled ones included
platforms disable <NAME>Hides it from users; linked accounts can still be unlinked
platforms enable <NAME>Offers it again
platforms remove <NAME> [--force]Uninstalls it (details)
platforms config show <NAME>Lists its settings, without secret values
platforms config set <NAME> <KEY>=<VALUE>|<KEY>Changes settings; a bare <KEY> asks for its value
platforms setup <NAME> [--input <KEY>=<VALUE>]Runs the adapter’s setup and saves its settings
platforms importRegisters adapter directories on disk that the database lacks, and moves their files out of the code (details)
platforms exec <NAME> -- <COMMAND>Runs a command the adapter installs

Prefix each with sudo ./publisher.sh, for example:

sudo ./publisher.sh platforms add https://github.com/smswithoutborders/gmail-oauth2-adapter

Commands taking a name also take --proto-id and --cat-id, for when a name matches more than one adapter. Administrators can do the same over the REST API, but only from GitHub orgs listed in PLATFORMS_GITHUB_ORGS.

Versions

Adapters are installed and updated by version tag, such as v1.2.0, so a repository needs one before it can be added.

An update builds the new version and its virtualenv beside the running one, then switches over, so a failed update leaves the running version in place. An update is refused if the installed tag now points to a different commit: publish a new tag instead of moving one.

Removing

remove is refused while accounts are linked through the adapter, since their tokens can only be revoked through it. Disable the adapter instead.

Warning

--force removes it anyway and leaves those tokens unrevocable.

Files

DirectoryHolds
PLATFORMS_ADAPTERS_DIR/<ID>The adapter’s code at its tag, replaced on every update
PLATFORMS_ADAPTERS_VENV_DIR/<ID>Its virtualenv
PLATFORMS_ADAPTERS_CONFIG_DIR/<ID>Its credentials.json
PLATFORMS_ADAPTERS_STATE_DIR/<ID>What it writes, such as databases

<ID> is the ID column of platforms list.

Settings

An adapter reads its settings, such as API keys, from credentials.json in its config directory. Its adapter.toml declares each one. Lists are comma-separated.

sudo ./publisher.sh platforms config set rmail smtp_host=smtp.example.com smtp_port=465
sudo ./publisher.sh platforms config set rmail smtp_password
sudo ./publisher.sh platforms config set rmail --unset alias_prefix

config set changes only the keys given. setup is for adapters whose settings come from the platform, such as a registered app for Mastodon or a downloaded client file for Gmail, and replaces all of them. --input <KEY>=@<PATH> reads a file as the service user, from the install directory, so give an absolute path it can read:

sudo ./publisher.sh platforms setup gmail --input client_file=@/tmp/client_secret.json

Redirect URIs

OAuth2 adapters, such as Gmail, send the user back to a redirect URI after they authorize. The client app chooses it and sends it to the Publisher when it links an account; the adapter uses the first of its redirect_uris setting when the app sends none.

  1. Run an HTTPS page that receives the authorization code and state and hands them back to the app.
  2. Configure the apps to send that page’s URI.
  3. Register the same URI, character for character, with the platform. A trailing slash counts.
  4. Set it in the adapter’s redirect_uris, with every other URI your apps send.

Provider guides

An adapter’s own commands

An adapter may install admin commands. exec runs one from the adapter’s virtualenv with its config and state directories; put -- before it:

sudo ./publisher.sh platforms exec <NAME> -- <COMMAND> --help

Next steps

Gmail

Lets users send email from their Gmail account.

Last checked against Google Cloud Console: 2026-10-22.

Requirements

Create the OAuth client

  1. Create a project, or pick one.
  2. Enable the Gmail API.
  3. In Google Auth Platform > Branding, click Get started.
  4. Enter the app name, support email and contact email. Choose External as the audience, unless every user belongs to your Google Workspace organization.
  5. In Data Access, add these scopes and save:
    • openid
    • https://www.googleapis.com/auth/userinfo.email
    • https://www.googleapis.com/auth/userinfo.profile
    • https://www.googleapis.com/auth/gmail.send
  6. In Clients, create a client of type Web application.
  7. Under Authorized redirect URIs, add every redirect URI your apps send, the default first, and create the client.
  8. Download the client’s JSON from the dialog that confirms it.

Important

Google shows the client secret only at creation. If you close the dialog without downloading the JSON, add a new secret to the client.

Configure the adapter

Copy the JSON to the server, then:

sudo ./publisher.sh platforms add \
  https://github.com/smswithoutborders/gmail-oauth2-adapter
sudo ./publisher.sh platforms setup gmail \
  --input client_file=@/tmp/client_secret_<ID>.apps.googleusercontent.com.json

setup copies the client ID, secret and redirect URIs from the file.

To add a redirect URI later, add it to the client in the console, then set the whole list:

sudo ./publisher.sh platforms config set gmail \
  redirect_uris=https://a.example.com/callback,https://b.example.com/callback

Check it

sudo ./publisher.sh platforms config show gmail

You should see redirect_uris in the client’s order, and client_secret as (secret, set).

Testing and production

Publishing statusWho can linkLimits
TestingTest users listed under AudienceLinks expire after 7 days
In production, unverifiedAnyone, after a warning100 users in all
In production, verifiedAnyoneNone

gmail.send is a sensitive scope, so verification needs:

  • the redirect and home page domains verified in Google Search Console;
  • a public home page and a privacy policy on that domain, describing how the app uses Gmail;
  • a justification for each scope;
  • an unlisted YouTube video showing the consent screen and how the app sends email.

Publish the app under Audience, then submit it in the Verification Center.

Troubleshooting

setup fails with redirect_uris is required

The client has no authorized redirect URIs. Add them in the console, then set them with config set gmail redirect_uris=....

redirect_uri_mismatch

The URI the app sent isn’t on the client. Add it exactly, trailing slash included.

Linking fails with Access wasn't granted to ...

The user unticked a permission on the consent screen. They should link again and allow every permission.

Sending fails with Google refused the refresh token

Links made while the app is in Testing expire after 7 days. Publish the app, and have users link again.

The client disappeared

Google deletes clients unused for 6 months. Restore it within 30 days, or create a new one.

Next steps

Register a gateway client to relay SMS to the Publisher.

Mastodon

Lets users post to Mastodon. The adapter’s setup registers the app on a Mastodon server, so there’s no console to visit.

Last checked against the Mastodon API: 2026-10-22.

Requirements

  • A Mastodon server running 4.4 or newer: see version at https://<SERVER>/api/v2/instance
  • An HTTPS redirect URI

The adapter serves one Mastodon server: only accounts on that server can link.

Configure the adapter

sudo ./publisher.sh platforms add \
  https://github.com/smswithoutborders/mastodon-oauth2-adapter
sudo ./publisher.sh platforms setup mastodon \
  --input client_name=RelaySMS \
  --input redirect_uris=https://example.com/callback \
  --input website=https://example.com \
  --input base_url=https://mastodon.social
InputRequiredDescription
client_nameYesApp name on the server’s consent page
redirect_urisYesComma-separated redirect URIs your apps send, the default first
websiteNoHome page on the consent page
base_urlNoThe server. Default: https://mastodon.social

setup registers the app with the scopes profile, write:statuses and write:media, and saves its client ID and secret. Run it again to change the redirect URIs or the server; it registers a new app and replaces every setting. After changing the server, users must link again.

Check it

sudo ./publisher.sh platforms config show mastodon

You should see client_secret as (secret, set).

Troubleshooting

Their account is on another server than base_url.

Linking fails after authorizing

The server runs Mastodon older than 4.4.

“The redirect URI included is not valid”

The app sent a URI the app registration doesn’t have. Run setup again with it.

Next steps

Register a gateway client to relay SMS to the Publisher.

Bluesky

Lets users post to Bluesky. Bluesky has no app registration: the Publisher serves the client’s metadata at a public URL, and that URL is the client ID.

Last checked against the AT Protocol OAuth spec: 2026-10-22.

Requirements

  • The REST API is public over HTTPS on port 443, behind the reverse proxy.
  • https://<HOST>/v1/platforms/bluesky/oauth/client-metadata.json answers 200 with JSON, with no redirect on the way. It does while the adapter is enabled.
  • The redirect URIs use HTTPS.

Configure the adapter

sudo ./publisher.sh platforms add \
  https://github.com/smswithoutborders/bluesky-oauth2-adapter
sudo ./publisher.sh platforms config set bluesky \
  client_id=https://publisher.example.com/v1/platforms/bluesky/oauth/client-metadata.json \
  client_uri=https://publisher.example.com \
  client_name=RelaySMS \
  redirect_uris=https://example.com/callback \
  "scope=atproto transition:generic"
SettingRequiredDescription
client_idYesThe metadata URL above, exactly as the Publisher serves it
client_uriYesHome page on the Publisher’s host, the same host as client_id
client_nameYesName on Bluesky’s consent page
redirect_urisYesComma-separated redirect URIs your apps send, the default first
scopeYesatproto transition:generic
pds_urlNoAuth server. Default: https://bsky.social

Check it

curl -i https://publisher.example.com/v1/platforms/bluesky/oauth/client-metadata.json

You should see 200 and JSON whose client_id equals the URL you fetched.

Troubleshooting

Bluesky says the client metadata is invalid

client_id must match the served URL exactly: https, /v1, no trailing slash.

The metadata URL redirects or needs a login

Bluesky refuses redirects. Serve the URL directly over HTTPS.

Access wasn't granted to every scope.

Set scope to exactly atproto transition:generic.

The account uses a different auth server.

The account isn’t on pds_url’s auth server, such as one on a self-hosted PDS. Only accounts there can link.

A changed redirect URI isn’t accepted yet

Bluesky’s auth server caches the metadata. Try again later.

Next steps

Register a gateway client to relay SMS to the Publisher.

Telegram

Lets users send Telegram messages. Users link with their phone number, a login code and, if they set one, their two-step verification password.

Last checked against my.telegram.org: 2026-10-22.

Requirements

  • Agreement to Telegram’s API terms of service
  • A Telegram account made with an official app, on a phone number you’ll keep. Each number can create only one API ID.

Get the API ID and hash

  1. Sign in at my.telegram.org with the number in international format. The login code arrives in the Telegram app, not by SMS.
  2. Open API development tools.
  3. Fill in the app title and a short name (5 to 32 letters and digits), and create the application.
  4. Copy the App api_id and App api_hash.

The hash can’t be changed: replacing a leaked one takes a new app on another number.

Configure the adapter

sudo ./publisher.sh platforms add \
  https://github.com/smswithoutborders/telegram-pnba-adapter
sudo ./publisher.sh platforms config set telegram api_id=<API_ID> api_hash

Check it

sudo ./publisher.sh platforms list --name telegram

You should see ✓ under Configured.

Troubleshooting

The form at my.telegram.org says only “ERROR”

Try again later from a home connection without a VPN, in a private window.

A user’s account is limited or banned

Heavy or spam-like sending gets accounts banned. Appeals go to recover@telegram.org.

Next steps

Register a gateway client to relay SMS to the Publisher.

RelaySMS Mail

The rmail adapter gives each user an email alias tied to their phone number, and sends email from it through SimpleLogin. Users link with a code sent over a messaging app by Shortmesh Authy. It’s the adapter that publishes offline payloads, each from a new random alias.

Last checked against SimpleLogin, Shortmesh Authy and Google: 2026-10-22.

Requirements

  • A SimpleLogin Premium account, which custom domains and sending to new contacts require
  • A domain whose DNS you control
  • An SMTP account allowed to send as your SimpleLogin mailbox address
  • A self-hosted Shortmesh Authy, to send the login codes

SimpleLogin

  1. Sign up at app.simplelogin.io. The signup address becomes your mailbox; to use another, add and verify it under Mailboxes.
  2. Add your domain, or a subdomain such as mail.example.com, under Custom domains.
  3. Create the DNS records the domain’s page lists: ownership TXT, MX, SPF, DKIM and DMARC. SimpleLogin marks the last three optional; without them, mail sent from aliases doesn’t arrive.
  4. Create an API key.

SMTP

The adapter sends each message from your mailbox address over SMTP, and SimpleLogin relays it from the user’s alias.

For a Gmail mailbox, use smtp.gmail.com, port 465, the Gmail address, and an app password, which needs 2-Step Verification on the account.

Shortmesh Authy

Run Authy-API with its Interface-API, then in the Interface-API’s admin UI:

  1. Under Devices, link the phone that sends the codes.
  2. Under Tokens, create a token.

Configure the adapter

This example uses a Gmail mailbox:

sudo ./publisher.sh platforms add \
  https://github.com/smswithoutborders/relaysms-mail-pnba-adapter
sudo ./publisher.sh platforms config set rmail \
  sl_primary_email=you@gmail.com sl_primary_domain=mail.example.com \
  smtp_host=smtp.gmail.com smtp_port=465 smtp_username=you@gmail.com \
  authy_base_url=https://authy.example.com authy_sender=+237600000000
sudo ./publisher.sh platforms config set rmail sl_api_key smtp_password authy_token

sudo ./publisher.sh platforms config show rmail lists the optional settings, such as alias prefixes.

Check it

sudo ./publisher.sh platforms list --name rmail

You should see ✓ under Configured.

curl https://authy.example.com/api/v1/platforms

You should see the channels Authy can send codes on, such as signal.

Troubleshooting

No mailbox for <EMAIL>

sl_primary_email isn’t a verified SimpleLogin mailbox.

No suffix available for hostname '<DOMAIN>'

sl_primary_domain isn’t a verified custom domain in SimpleLogin.

Sending fails with an SMTP authentication error

For Gmail, use an app password, not the account password. A changed account password revokes app passwords.

Recipients don’t get the email

Check the domain’s SPF, DKIM and DMARC records on its SimpleLogin page.

SimpleLogin refuses to create a contact

The account isn’t Premium.

Users don’t get a login code

Check that the sender device is linked and online, and that the channel the app sends, such as signal, appears at <AUTHY_BASE_URL>/api/v1/platforms.

Next steps

Register a gateway client to relay SMS to the Publisher.

Managing gateway clients

Register each number that relays SMS to this server, so the RelaySMS app lists it, then connect its relay. Credentials with gc:read and gc:write can also manage them over the REST API; every change is recorded in the audit log.

Commands

CommandDoes
gateway-clients suggest <MSISDN>Shows the number’s details and PLMN candidates; saves nothing
gateway-clients create --msisdn <MSISDN> --protocols <PROTOCOLS>Registers a number; also takes --country, --operator and --operator-code
gateway-clients listLists them; filter with --msisdn, --country or --operator
gateway-clients update <MSISDN>Changes --country, --operator, --operator-code or --protocols
gateway-clients disable <MSISDN>Hides it from the public list
gateway-clients enable <MSISDN>Lists it publicly again
gateway-clients delete <MSISDN>Removes it
gateway-clients countriesLists the countries that have a gateway client
gateway-clients operators --country <COUNTRY>Lists the operators of that country’s gateway clients
gateway-clients mcc-mnc listSearches the PLMN table; filter with --country-code, --network or --iso

Prefix each with sudo ./publisher.sh. <PROTOCOLS> is how the relay reaches the Publisher, comma-separated: https for one that posts to the REST API, smtp for one that emails. For example:

sudo ./publisher.sh gateway-clients create --msisdn +237600000000 --protocols https

create fills in any of --country, --operator and --operator-code you leave out with what suggest finds. It fills the PLMN code (MCC+MNC) only when every candidate shares it; otherwise run suggest, pick a candidate and pass it with --operator-code.

How suggest works

phonenumbers gives the number’s country, region and carrier name. The carrier is matched against that region’s operators in publisher/gateway_clients/mcc_mnc_table.json, a vendored snapshot of musalbas/mcc-mnc-table. A territory without rows of its own, like Guernsey, uses its calling code’s main region.

Names are compared word by word, ignoring case, accents, the country’s name and words such as “Telecom” or “Ltd”.

matchCandidates
carrierNetworks matching the carrier, best first. The PLMN code is filled in only when they all share one.
regionEvery operator in the country, because the carrier is unknown (common for US and Canadian numbers) or matched nothing
noneNone: the number couldn’t be placed. Enter the details yourself.

When suggest can’t place the operator, as for many US, Canadian and Twilio numbers, pass --country and --operator yourself, or leave the operator out. The public list shows each number’s country and operator.

Connecting a relay

A gateway client receives the SMS that people send and relays each payload to the Publisher’s POST /v1/publications. Pick one:

Gateway clientRuns onUse it for
Deku SMSAn Android phone with a SIM cardA local number, with no SMS provider
RelaySMS-GatewayClient-TwilioA server, behind a Twilio numberA number that doesn’t depend on a phone staying online

Requirements

Deku SMS on Android

  1. Install Deku SMS on the phone and make it the default SMS app.
  2. In its gateway settings, forward incoming SMS to https://<HOST>/v1/publications.
  3. Keep the phone powered, with mobile data or Wi-Fi.

Twilio

  1. Install RelaySMS-GatewayClient-Twilio as its README describes. It can run on the Publisher’s server, on its own subdomain such as twilio.example.com. Set TWILIO_AUTH_TOKEN from the Twilio console, and keep a copy of the generated SENDER_HASH_KEY: a new key gives every sender a new address.
  2. In its .env, set FORWARD_URLS_JSON=https://<HOST>/v1/publications.
  3. If the Publisher allows offline publishing over https, set FORWARD_TAG to its OFFLINE_PUBLISH_SHARED_SECRET.
  4. In the Twilio console, under the number’s Messaging > A message comes in, set https://<RELAY_HOST>/twilio-sms with HTTP POST.

Check it

Send an SMS from the RelaySMS app to the number, then follow the worker’s log on the Publisher:

sudo ./manage.sh logs --unit worker

You should see Published via '<PLATFORM>'. To test without a phone, the reference client posts a payload the way a gateway client does.

Next steps

Services

The Publisher runs as systemd services, grouped under relaysms-publisher.target:

ServiceRuns
relaysms-publisher-restREST API
relaysms-publisher-grpcgRPC API
relaysms-publisher-workerCelery worker: publishes queued payloads
relaysms-publisher-beatCelery Beat: schedules cleanups, and the worker heartbeat with Uptime Kuma
relaysms-publisher-smtpEmail listener; exits at once while SMTP_TRANSPORT_ENABLED is off

The REST API listens on HOST:PORT (127.0.0.1:16000) and gRPC on GRPC_HOST:GRPC_PORT (127.0.0.1:6000).

Is it working?

sudo ./manage.sh status
curl http://127.0.0.1:16000/health

You should see every service active, and {"status":"ok"}. To follow publications as they happen:

sudo ./manage.sh logs --unit worker

Each published payload logs Published via '<PLATFORM>'., and each failure a line starting Failed to (what each means). Credentials with stats:publications:read can also query the counts at GET /v1/stats/publications.

manage.sh

Run it from the install directory with sudo:

CommandDoes
statusShows each service’s state
logsFollows the service logs
start, stop, restartControls every service together
enable, disableStarts the services on boot, or stops doing so
update [--migrate]Pulls the latest code, rebuilds and restarts the services (details)
migrateApplies database migrations
checkReports every missing or invalid setting
nginx [<DOMAIN>]Renders the nginx site again and reattaches or obtains its certificate (details)
docs [<DOMAIN>]Builds this manual and serves it on its own domain (details)
uninstallRemoves the services and the install directory, with .env and data/

uninstall keeps the database and broker servers and the service user.

logs takes:

  • -u, --unit: rest, grpc, worker, beat or smtp; repeatable
  • -n, --lines: how many lines to show
  • -s, --since: a time, such as "1 hour ago"
  • --no-follow: print and exit

For example:

sudo ./manage.sh logs --unit rest --unit grpc --since "1 hour ago" --lines 200

publisher.sh

./publisher.sh runs the admin CLI as the service user, with the virtualenv and .env:

sudo ./publisher.sh --help   # every command
sudo ./publisher.sh env      # install directory, service user and the .env values the CLI uses
sudo ./publisher.sh shell    # a shell as the service user, with .env loaded

Use it instead of python3 -m publisher: run as another user, the CLI can leave adapter files the services can’t read.

Files

PathHolds
/opt/relaysms/relaysms-publisher/The install
.envSettings (root:<SERVICE_USER>, mode 640)
data/SQLite database, Celery files and adapters (owned by the service user, mode 750)
/etc/systemd/system/relaysms-publisher*Service units
/etc/nginx/sites-available/<DOMAIN>.confnginx sites

Credentials and access

Credentials log in to the REST API’s admin endpoints, each with its own scopes. One holding every scope is an administrator. The RelaySMS app and gateway clients need none.

Create a credential

sudo ./publisher.sh creds create --username ops --administrator
sudo ./publisher.sh creds create --username analyst --scope stats:publications:read

Important

The password is printed only once.

Usernames are 3 to 32 lowercase letters, digits, ., _ or -, starting with a letter or digit.

Scopes

ScopeAllows
stats:publications:readRead publication stats, without failure reasons
stats:publications:reasonsSee failure reasons in publication stats
gc:readList gateway clients
gc:writeAdd, change and remove gateway clients
platforms:readList platform adapters
platforms:writeChange and remove platform adapters
creds:readList credentials
creds:writeAdd, change and remove credentials
audit:readRead the audit log, for the areas your other scopes cover

sudo ./publisher.sh creds scopes prints the same list. Each :write scope needs its :read scope, and stats:publications:reasons needs stats:publications:read. Installing, updating and configuring adapters over the API also needs an administrator.

Commands

CommandDoes
creds listLists credentials
creds set-scopes --username <NAME> --scope <SCOPE>Replaces a credential’s scopes; repeat --scope, or pass --administrator
creds reset-password --username <NAME>Prints a new password and ends the credential’s web sessions
creds disable --username <NAME>Blocks it and ends its web sessions
creds enable --username <NAME>Lets it log in again
creds revoke-sessions --username <NAME>Logs it out of every web session; HTTP Basic still works
creds delete --username <NAME>Deletes it and its sessions, after asking; --yes skips the question

Prefix each with sudo ./publisher.sh. Credentials with creds:write can do the same over the REST API, within their own scopes.

Logging in

  • Web session: POST /v1/auth/login, then send the session cookie with every request (credentials: "include" in fetch). Logins and writes (POST, PATCH, DELETE) with the cookie must come from the API’s own origin or one listed in AUTH_WEB_ORIGINS, on the same site as the API. A session ends after AUTH_SESSION_IDLE_MINUTES idle (30) or AUTH_SESSION_MAX_HOURS (12).
  • HTTP Basic: the username and password on every request, such as curl -u analyst:<PASSWORD> https://<HOST>/v1/stats/publications.

Scope changes apply on the credential’s next request. nginx allows 5 logins a minute per IP address, and 30 HTTP Basic requests a minute per IP address.

Settings: REST API access.

Upgrading

A server install follows the branch it was installed from, main by default.

Before you upgrade

  1. Back up. Migrations can’t be undone, so going back to older code needs the backup.

  2. From the install directory, note the current commit, to roll back to:

    sudo git rev-parse HEAD
    

Update a server install

From the install directory:

sudo ./manage.sh update --migrate
sudo ./sync-env.sh
sudo ./manage.sh check
sudo ./manage.sh restart
  1. update --migrate stops the services, pulls the code, reinstalls the dependencies, rebuilds, applies pending migrations and starts the services again. When the install serves this manual, it rebuilds it too.
  2. sync-env.sh adds the settings the new template.env has and .env lacks, showing the change before writing it.
  3. check reports any setting still missing or invalid; fill it in .env.
  4. restart loads the new settings.

When .env has an invalid setting, update still restarts every service, then exits with an error (fix).

If the update changed the nginx template, which sudo git diff --stat ORIG_HEAD HEAD -- deploy/nginx lists, render the site again:

sudo ./manage.sh nginx

If you edited tracked files that the update changes, update refuses to pull. Discard the edits or git stash them, then run update again.

Roll back

  1. Take the commit you noted before upgrading.

  2. If the update applied migrations, restore the backup taken before it; older code can’t use the migrated database.

  3. Check out that commit and rebuild. --pulled skips the pull that would undo the checkout:

    sudo git checkout <COMMIT>
    sudo ./manage.sh update --pulled
    

To follow the branch again later, run sudo git checkout <BRANCH>, then sudo ./manage.sh update --migrate.

Update with Docker

git pull --recurse-submodules
docker compose up -d --build

The container applies new migrations and runs platforms import when it starts.

Update adapters

Adapters update separately, by version tag:

sudo ./publisher.sh platforms update

See Versions.

Upgrade notes

Steps a particular upgrade needs once, newest first.

Gateway client registry import (migration 019)

For installs from before 2026-10-07.

update --migrate applies migration 019, which imports data/gateway_clients/registry.json (or GATEWAY_CLIENTS_REGISTRY_FILE, if .env sets it). It logs the entries in mcc_mnc_overrides.json without importing them. Delete both files afterwards.

Adapter registry import

For installs from before 2026-10-06.

update --migrate and manage.sh migrate register adapter directories on disk that the database lacks, and move their settings and files out of the code. The Docker container does it at every start.

Backup and restore

A backup is:

  • .env: its encryption keys are the only way to read the data.
  • data/: the SQLite database, Celery files, and the adapters with their settings, state and virtualenvs.
  • A dump of the database, with MySQL or PostgreSQL.
  • Any file .env points to outside data/, such as GRPC_TLS_CERT_FILE or SMTP_TLS_CLIENT_KEY.

The installer recreates the service units, and the nginx sites and certificates for the domains you pass it, so /etc needs no backup. If you run the Twilio relay, back up its .env too: its SENDER_HASH_KEY keeps senders’ hashes the same.

Copy the archives off the host, and test a restore on a spare machine now and then.

Caution

Store the .env archive apart from the data. Anyone with both can read the linked accounts’ tokens.

Back up

From the install directory, stop the services so the data is consistent, then archive .env and data/ separately:

sudo ./manage.sh stop
sudo tar -czf /root/relaysms-env-$(date +%F).tar.gz .env
sudo tar -czf /root/relaysms-data-$(date +%F).tar.gz data

With PostgreSQL, dump the database before starting the services again:

sudo sh -c 'sudo -u postgres pg_dump relaysms > /root/relaysms-db-$(date +%F).sql'

With MySQL or MariaDB:

sudo sh -c 'mysqldump relaysms > /root/relaysms-db-$(date +%F).sql'

Then start the services:

sudo ./manage.sh start

For a database on another host, or named differently in .env, connect with the details from .env, such as pg_dump -h <DB_HOST> -U <DB_USER> <DB_NAME>. With Docker, use docker compose stop and docker compose start in place of manage.sh.

Restore

  1. Install the Publisher in the same directory, with the same --instance-name. With MySQL or PostgreSQL, pass --setup-db, --db-name, --db-user and --db-password with the values from the backed-up .env (MYSQL_* or POSTGRES_*); with RabbitMQ, the same for --broker-password. Otherwise the servers refuse the restored .env’s logins.

  2. Stop the services:

    sudo ./manage.sh stop
    
  3. From the install directory, replace the new data/ and .env with the backed-up ones:

    sudo rm -rf data
    sudo tar -xzf /root/relaysms-env-<DATE>.tar.gz
    sudo tar -xzf /root/relaysms-data-<DATE>.tar.gz
    
  4. With PostgreSQL, replace the new database with the dump:

    sudo -u postgres dropdb relaysms
    sudo -u postgres createdb -O relaysms relaysms
    sudo sh -c 'sudo -u postgres psql -v ON_ERROR_STOP=1 relaysms < /root/relaysms-db-<DATE>.sql'
    

    With MySQL or MariaDB:

    sudo mysql -e 'DROP DATABASE relaysms; CREATE DATABASE relaysms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
    sudo sh -c 'mysql relaysms < /root/relaysms-db-<DATE>.sql'
    
  5. Apply any migrations newer than the backup, then start the services and check them:

    sudo ./manage.sh migrate
    sudo ./manage.sh start
    sudo ./manage.sh check
    

tar keeps the files’ owners. If the service user’s name or ID differs on the new host, give the files back to it: sudo chown -R <SERVICE_USER>: data and sudo chown root:<SERVICE_USER> .env.

Next steps

After a restore, check that the services are up.

Observability

Self-hosted tracing, metrics and log export (SigNoz) and uptime monitoring (Uptime Kuma) for the Publisher’s services. Both are optional; tracing, metrics and log export stay off while OTEL_EXPORTER_OTLP_ENDPOINT is blank.

Set up

During install:

sudo ./install.sh --setup-observability

On an existing install, from the install directory:

sudo ./scripts/setup-observability.sh
install.shsetup-observability.shDoes
--observability-site-name <DOMAIN>--site-name <DOMAIN>Puts nginx and a Let’s Encrypt certificate in front of SigNoz
--observability-kuma-site-name <DOMAIN>--kuma-site-name <DOMAIN>The same for Uptime Kuma, on a different domain
None--signoz-email, --signoz-passwordSigNoz admin account
None--signoz-smtp-host, --signoz-smtp-port, --signoz-smtp-username, --signoz-smtp-password, --signoz-smtp-fromSMTP relay for SigNoz’s email alerts
None--skip-tracingLeaves tracing off

scripts/setup-observability.sh --help lists every flag. The script:

  • installs Docker if it’s missing;
  • writes observability/signoz/casting.yaml from its template, binds the SigNoz UI and OTLP ports to 127.0.0.1, starts SigNoz and creates the admin account;
  • starts Uptime Kuma on 127.0.0.1:3001, or the port you choose;
  • installs requirements-observability.txt and sets OTEL_EXPORTER_OTLP_ENDPOINT, unless given --skip-tracing;
  • with --site-name or --kuma-site-name, writes and enables the nginx sites and runs Certbot.

Set retention in the SigNoz UI, under Settings > Workspace > Retention Controls.

Important

casting.yaml is generated only once. To change a running install, edit observability/signoz/casting.yaml and run foundryctl cast -f observability/signoz/casting.yaml again. It recreates only the signoz container; Postgres and ClickHouse keep their data.

Uptime Kuma monitors

Open Uptime Kuma through its domain, or through an SSH tunnel at http://localhost:3001:

ssh -L 3001:127.0.0.1:3001 <USER>@<HOST>

Then add a monitor for each service.

REST

Monitor Type “HTTP(s)”, URL https://<DOMAIN>/health.

gRPC

  1. Choose Monitor Type “GRPC(S) - Keyword”.
  2. Set gRPC URL to <DOMAIN>:443 and turn on Enable TLS.
  3. Set Service Name to grpc.health.v1.Health and Method to check.
  4. Set gRPC Body to {"service":"publisher.v3.Publisher"} and Keyword to SERVING.
  5. Paste this into Protobuf:
Health check protobuf
syntax = "proto3";
package grpc.health.v1;

message HealthCheckRequest {
  string service = 1;
}

message HealthCheckResponse {
  enum ServingStatus {
    UNKNOWN = 0;
    SERVING = 1;
    NOT_SERVING = 2;
    SERVICE_UNKNOWN = 3;
  }
  ServingStatus status = 1;
}

service Health {
  rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
}

Worker

Monitor Type “Push”, interval just over 60s. Set UPTIME_KUMA_WORKER_PUSH_URL in .env to the push URL Kuma shows, then restart the services. Beat schedules this heartbeat, so the monitor also goes down when Beat stops.

SMTP listener

As for the worker, with an interval just over 20s and UPTIME_KUMA_SMTP_PUSH_URL.

Alerts

In the SigNoz UI, go to Alerts > New Alert > Logs, paste a filter below into the search bar, set the threshold to count > 0 over a rolling window, and attach a notification channel. Keep the parentheses: AND binds tighter than OR.

Publish failures, adapter errors included:

service.name = 'relaysms-publisher-worker' AND (body CONTAINS 'Failed to process payload' OR body CONTAINS 'Failed to publish message' OR body CONTAINS 'An unexpected error occurred during task processing')

Key management failures:

body CONTAINS 'Failed to generate server identity keys' OR body CONTAINS 'Token hash missing'

Turning tracing on and off

To turn tracing on by hand, from the install directory:

sudo venv/bin/pip install --quiet -r requirements-observability.txt
sudo sed -i 's|^OTEL_EXPORTER_OTLP_ENDPOINT=.*|OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317|' .env
sudo ./manage.sh restart

To turn it off, blank OTEL_EXPORTER_OTLP_ENDPOINT and restart.

Setting up by hand

SigNoz

Needs Docker Engine with the Compose plugin. Set <PASSWORD> to 12 or more characters, with upper and lower case, a number and a symbol.

curl -fsSL https://signoz.io/foundry.sh | FOUNDRY_INSTALL_DIR=/usr/local/bin bash

sed \
  -e "s/__POSTGRES_DB__/signoz/" -e "s/__POSTGRES_USER__/signoz/" \
  -e "s/__POSTGRES_PASSWORD__/$(openssl rand -hex 24)/" \
  -e "s/__SIGNOZ_PORT__/8090/" -e "s/__OTLP_GRPC_PORT__/4317/" -e "s/__OTLP_HTTP_PORT__/4318/" \
  -e "s/__SMTP_SMARTHOST__//" -e "s/__SMTP_USERNAME__//" -e "s/__SMTP_PASSWORD__//" -e "s/__SMTP_FROM__//" \
  -e "s|__SIGNOZ_EXTERNAL_URL__|http://localhost:8090|" \
  observability/signoz/casting.yaml.template > observability/signoz/casting.yaml

foundryctl cast -f observability/signoz/casting.yaml
docker compose \
  -f pours/deployment/compose.yaml \
  -f observability/signoz/docker-compose.override.yml \
  up -d

until curl -fs http://127.0.0.1:8090/api/v1/version | grep -q setupCompleted; do sleep 2; done
curl -X POST http://127.0.0.1:8090/api/v1/register \
  -H "Content-Type: application/json" \
  -d '{"name":"Admin","orgId":"","orgName":"RelaySMS","email":"admin@example.com","password":"<PASSWORD>"}'

The template stores SigNoz’s metadata in Postgres (metastore.kind: postgres); set sqlite for a single node.

Uptime Kuma

docker compose -f observability/uptime-kuma/docker-compose.yml up -d

Reverse proxy

sed -e "s/__SERVER_NAME__/<OPS_DOMAIN>/g" \
    observability/nginx-observability.conf.template |
    sudo tee /etc/nginx/sites-available/<OPS_DOMAIN>.conf >/dev/null
sudo ln -s /etc/nginx/sites-available/<OPS_DOMAIN>.conf /etc/nginx/sites-enabled/

sed -e "s/__SERVER_NAME__/<STATUS_DOMAIN>/g" \
    observability/nginx-uptime-kuma.conf.template |
    sudo tee /etc/nginx/sites-available/<STATUS_DOMAIN>.conf >/dev/null
sudo ln -s /etc/nginx/sites-available/<STATUS_DOMAIN>.conf /etc/nginx/sites-enabled/

sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d <OPS_DOMAIN>
sudo certbot --nginx -d <STATUS_DOMAIN>

<OPS_DOMAIN> serves SigNoz and <STATUS_DOMAIN> serves Uptime Kuma.

Audit log

The Publisher records every change to credentials, adapters and gateway clients, every web login and every refused login, whether made over the REST API or the CLI. Read it at GET /v1/audit-events:

curl -u ops:<PASSWORD> https://<HOST>/v1/audit-events

It needs audit:read, plus the read scope of each area: creds:read for auth.* and creds.* events, platforms:read for platforms.*, gc:read for gateway_clients.*. Events from areas you can’t read are left out. Filter with action, actor, target, since and until, such as ?action=auth.login&since=2026-10-01. Results are paginated.

Events

ActionRecorded when
creds.create, creds.update, creds.reset_password, creds.revoke_sessions, creds.deleteA credential changes. details says what changed, never secrets.
auth.loginA web session starts, or a web or HTTP Basic login for an existing username is refused
auth.logoutA web session ends
platforms.add, platforms.update, platforms.removeAn adapter is installed, updated or removed. details has the source URL and commits.
platforms.enable, platforms.disableAn adapter is offered to users again, or hidden from them
platforms.configure, platforms.setupAn adapter’s settings change. details has the keys saved.
gateway_clients.create, gateway_clients.update, gateway_clients.deleteA gateway client changes. details has its fields, or what changed.
gateway_clients.enable, gateway_clients.disableA gateway client is listed publicly again, or hidden

Fields

  • outcome: success, denied (the change went beyond the actor’s scopes) or failed (a refused login for an existing username: wrong password, or disabled).
  • actor: the username at the time; null for the CLI or a failed login.
  • details: what changed, never secrets; for auth.login, method is session or basic.

Successful HTTP Basic requests aren’t recorded. Events are kept for AUDIT_RETENTION_DAYS (Retention).

Security hardening

A server install listens on 127.0.0.1 only, runs each service under systemd sandboxing with everything read-only except its data directories, and sits behind nginx with TLS and rate limits. These steps close what’s left to you.

Network and firewall

  1. Allow only SSH, HTTP and HTTPS in:

    sudo ufw allow OpenSSH
    sudo ufw allow 'Nginx Full'
    sudo ufw enable
    
  2. Keep HOST and GRPC_HOST at 127.0.0.1, so only nginx reaches the services.

  3. On Docker, bind PORT and GRPC_PORT to 127.0.0.1 in docker-compose.yml, since Docker’s published ports bypass ufw, and put nginx in front (details).

Service user

The services run as the user who ran the installer with sudo, or as a new relaysms user when it ran from a root shell. For a dedicated user with no login or sudo rights, run the installer from a root shell (sudo -i). Changing it on an existing install means reinstalling.

Settings

SettingKeep it at
API_DOCS_ENABLEDfalse in production
AUTH_SESSION_COOKIE_SECUREtrue
AUTH_WEB_ORIGINSOnly the web client origins you run
PLATFORMS_GITHUB_ORGSOnly orgs you trust: installing an adapter runs its code on the server
SMTP_ALLOWED_SENDERSExact addresses; a domain trusts every sender on it (details)
DATABASE_ENCRYPTION_ENABLED, DATABASE_FIELD_ENCRYPTION_ENABLEDtrue

Credentials

Give each person their own credential, with only the scopes they need. Review the audit log for denied events (a change beyond the actor’s scopes) and failed ones (a refused login).

Troubleshooting

Start with the logs of the service involved:

sudo ./manage.sh logs --unit <SERVICE> --since "1 hour ago"

<SERVICE> is one of the units logs takes.

Services

A service won’t start

The service refuses to start on an invalid setting it uses. Run sudo ./manage.sh check, fix what it reports in .env, then sudo ./manage.sh restart.

update ends with .env has errors

The new version needs a setting .env lacks. Run sudo ./sync-env.sh, fill in what sudo ./manage.sh check reports, then sudo ./manage.sh restart.

/health returns 500

The REST API can’t open the database: the database server is down, its settings in .env are wrong, or, with SQLite, the service user can’t read data/. See Database.

The email listener service is inactive

It exits at once while SMTP_TRANSPORT_ENABLED is off. See SMTP transport.

Docker refuses connections right after starting

The container applies migrations before the services start. Wait, or follow docker compose logs -f.

Publishing

A payload is accepted but never published

POST /v1/publications only queues a payload; the worker decides later. Follow sudo ./manage.sh logs --unit worker and match the line:

Log lineCause
Published via '<PLATFORM>'.It worked
Failed to process payload: ...The payload didn’t decrypt or validate, its keys are unknown or used up, or offline publishing refused it (Offline publishing)
Failed to publish message: Adapter error: ...The platform refused it; the rest of the line is the adapter’s reason. Fix it with the platform’s provider guide, then the user sends the same SMS again.
Session <ID> can't be assembled from <N> segment(s): ...A multi-part payload’s segments don’t fit together
An unexpected error occurred during task processing.A bug; the traceback follows. Report it.
No line at allThe worker isn’t running (sudo ./manage.sh status), or a multi-part payload is still waiting for its other segments, for up to PAYLOAD_SESSION_MAX_AGE_HOURS

GET /v1/stats/publications lists each failure with a reason code, for credentials with stats:publications:reasons.

Linking an account fails

Check the adapter shows ✓ under Configured in sudo ./publisher.sh platforms list, then see its provider guide’s troubleshooting.

Relay

An SMS was sent, but the worker logs nothing

Check that the relay received it:

  • With Deku SMS, the phone is online and Deku SMS is its default SMS app.
  • With Twilio, follow the relay’s log (sudo journalctl -u relaysms-gatewayclient-twilio) and Twilio’s error log, under Monitor > Logs > Errors in the Twilio console.

The Twilio relay answers 403 Invalid Twilio signature.

The webhook URL in the Twilio console doesn’t match the URL the relay sees, scheme and host included. Set it to the relay’s exact https:// URL.

The Twilio relay answers 502 Forwarding failed.

A FORWARD_URLS_JSON URL refused the payload or couldn’t be reached; the relay’s log names it. Check that https://<HOST>/v1/publications answers from the relay’s host.

REST API

Invalid username or password.

The credential doesn’t exist, the password is wrong, or it’s disabled (sudo ./publisher.sh creds list).

Missing scope: ...

Add the scope with sudo ./publisher.sh creds set-scopes (Scopes).

Session expired or invalid. Please log in again.

The session idled past AUTH_SESSION_IDLE_MINUTES, reached AUTH_SESSION_MAX_HOURS, or was ended by reset-password, disable or revoke-sessions. Log in again.

Origin not allowed.

The web client’s origin is neither the API’s own nor listed in AUTH_WEB_ORIGINS; logins and writes with a session cookie both check it. Add the origin, which must be on the same site as the API, then sudo ./manage.sh restart.

Too many requests. Please try again shortly.

nginx rate-limits logins and HTTP Basic requests (limits). Wait a minute and try again.

nginx

gRPC calls fail through nginx

gRPC needs HTTP/2 on the HTTPS block. Run sudo ./manage.sh nginx to render the site again with it.

nginx -t fails after an update

Run sudo ./manage.sh nginx: it renders the site from the current template, and restores the previous file if the new one fails.

Command-line tools

ToolRunsDocumented in
install.shInstalls or updates a server installInstall on a server
manage.shControls, updates and checks the servicesServices
publisher.shThe admin CLI, as the service userBelow
sync-env.shAdds to .env the settings template.env has and it lacksUpgrading
scripts/setup-postgres.sh, scripts/setup-mysql.shSets up a database serverDatabase
scripts/setup-rabbitmq.shSets up a RabbitMQ brokerCelery broker
scripts/setup-observability.shSets up SigNoz and Uptime KumaObservability

Run them with sudo; all but install.sh from the install directory. Each takes --help.

manage.sh

See Services.

publisher.sh

Runs python3 -m publisher as the service user. sudo ./publisher.sh <GROUP> <COMMAND> --help describes each command.

GroupCommandsDocumented in
credscreate, list, scopes, set-scopes, reset-password, disable, enable, revoke-sessions, deleteCredentials and access
platformsadd, update, list, disable, enable, remove, config show, config set, setup, import, execManaging adapters
gateway-clientssuggest, create, list, update, disable, enable, delete, countries, operators, mcc-mnc listManaging gateway clients
configcheckSettings reference
seedstats, creds, platforms, gateway-clientsNone; fills a development database with sample data

It also takes env and shell (Services).

REST API

The REST API accepts payloads from gateway clients, serves public lists, and has the admin endpoints.

The endpoint reference is openapi.json, generated from the app. Open it in any OpenAPI viewer, or set API_DOCS_ENABLED=true on a development server and browse /docs.

Base URL

https://<HOST>/v1

/health is the only endpoint outside /v1.

Authentication

Admin endpoints take a web session cookie or HTTP Basic credentials; see Logging in. Each endpoint’s description names the scopes it needs.

Managing credentials

A credential can only grant scopes it holds, can’t change itself, and can’t change a credential holding scopes it lacks. These return 403.

GET /v1/creds/{username} returns an opaque ETag. Send it back unchanged as If-Match to change, reset or delete that credential: a missing header gets 428, a stale one 412. Responses that change a credential return its new ETag. Generated passwords appear only in the response that creates them.

Managing platform adapters

/v1/platforms/adapters lists every installed adapter, with its source, version tag, commit and who last changed it, and takes the same ETag and If-Match rules as credentials. A disabled adapter disappears from /v1/platforms and can’t publish or link accounts, but still revokes tokens so users can unlink. Uninstalling is refused with 409 while accounts are linked through the adapter; see Removing.

Installing (POST /v1/platforms/adapters) and updating (POST /v1/platforms/adapters/{id}/update) run code from the repository on the server, so they need an administrator and a repository from an org in PLATFORMS_GITHUB_ORGS. Both take an optional tag, defaulting to the newest version tag, and return 202 with the job’s URL in Location. Poll the job until its state is succeeded or failed; its log has the end of the git and pip output. An adapter has one job at a time; another gets 409.

An adapter’s settings are at /v1/platforms/adapters/{id}/config, for administrators only. GET lists each setting, with secrets only marked is_set, and the inputs its setup takes. PATCH changes the settings sent and keeps the rest; POST .../setup runs the adapter’s setup and replaces them all, with a file input sent as the file’s text. configured in the adapter list says whether every required setting has a value.

Managing gateway clients

/v1/gateway-clients/registry lists every gateway client, with who last changed it, and takes the same ETag and If-Match rules as credentials (scopes gc:read and gc:write). GET /v1/gateway-clients/registry/suggest?msisdn= returns the details and PLMN candidates a create would use, to prefill a form for the administrator to confirm; see how suggest works. A disabled client disappears from the public /v1/gateway-clients.

Pagination

List endpoints return {"data": [...], "next": ..., "prev": ...}, newest first. Follow next and prev as they are: they keep your filters and limit, and are null on the last and first page. Don’t build the cursor yourself.

since and until take ISO-8601 times, UTC if there’s no offset. since is inclusive and until exclusive.

Audit events

GET /v1/audit-events returns the audit log.

Publishing

POST /v1/publications takes JSON and tags payloads with protocol https. It needs no credentials: a payload publishes only if it decrypts with the keys of a linked account or, for an offline payload, passes the offline publishing rules.

It only checks the payload’s structure before queueing it. Decryption, payload type and adapter errors happen later, so they’re logged on the server, not returned.

Errors

Error bodies are {"error": "<MESSAGE>"}. The message is meant to be shown to users; the server logs more detail.

StatusMeaning
400 Bad RequestInvalid parameters or payload, invalid cursor, or invalid time window
401 UnauthorizedMissing or invalid credentials
403 ForbiddenOrigin not allowed, missing scope, or a credential change beyond your scopes
404 Not FoundThe resource doesn’t exist
409 ConflictAlready exists, in use, or changed by another request at the same moment; retry
412 Precondition FailedIf-Match doesn’t match the current ETag; reload and retry
422 Unprocessable EntityValidation error
428 Precondition RequiredIf-Match header missing
429 Too Many RequestsRate limited by nginx (limits)
500 Internal Server ErrorUnexpected server error

gRPC API

The gRPC API links and unlinks platform accounts and syncs ephemeral key pools. The RelaySMS app calls it while online.

Protocol buffer file

curl -O -L https://raw.githubusercontent.com/smswithoutborders/RelaySMS-Publisher/main/protos/v3/publisher.proto

Package publisher.v3, service Publisher.

Generate a client

Generate stubs with protoc for your language. For Python:

pip install grpcio-tools
python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. publisher.proto

Request headers

Every RPC needs these headers. Headers ending in -bin carry raw binary.

HeaderTypeRequiredDescription
x-payload-binbytesYesEncrypted request payload ciphertext
x-public-key-binbytesYesClient ephemeral public key ($eC_{pk}$, 32 bytes)
x-key-idstringYesID of the server identity key used to encrypt the payload
x-nonce-binbytesYesRequest nonce (16 bytes)
x-timestampstringYesUnix timestamp in seconds, within NONCE_TTL_SECONDS of the server’s clock

The examples use -plaintext against a local server; drop it for a server behind TLS on port 443.

Methods

Get OAuth2 authorization URL

Returns the URL that starts the OAuth2 flow.

Request: GetOAuth2AuthorizationUrlRequest

FieldTypeRequiredDescription
platformstringYesPlatform identifier, such as gmail
statestringNoOAuth2 state, returned unchanged
code_verifierstringNoPKCE code verifier
autogenerate_code_verifierboolNoGenerate a code verifier when none is sent
redirect_urlstringNoRedirect URL for OAuth2 application
request_identifierstringNoRequest identifier for tracking

Response: GetOAuth2AuthorizationUrlResponse

FieldTypeDescription
authorization_urlstringGenerated authorization URL
statestringThe state sent
code_verifierstringPKCE code verifier used
messagestringHuman-readable result
scopestringAuthorization request scope
client_idstringOAuth2 application client ID
redirect_urlstringOAuth2 application redirect URL

Example:

grpcurl -plaintext \
  -H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
  -H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
  -H 'x-key-id: <KEY_ID>' \
  -H 'x-nonce-bin: <NONCE>' \
  -H 'x-timestamp: <TIMESTAMP>' \
  -d '{
    "platform": "gmail",
    "autogenerate_code_verifier": true
  }' \
  -proto publisher.proto \
  <HOST>:<PORT> publisher.v3.Publisher/GetOAuth2AuthorizationUrl

Exchange OAuth2 code and store token

Exchanges an OAuth2 authorization code for tokens and stores them. Returns encrypted token data and a set of server ephemeral keys.

Request: ExchangeOAuth2CodeAndStoreRequest

FieldTypeRequiredDescription
platformstringYesPlatform identifier
authorization_codestringYesOAuth2 authorization code
code_verifierstringNoCode verifier for PKCE
redirect_urlstringNoRedirect URL for OAuth2 application
request_identifierstringNoRequest identifier for tracking
client_ephemeral_public_keyslist[PublicKey]YesExactly 256 ephemeral public keys generated by the client, with key_id 0-255 each used once

PublicKey message:

FieldTypeDescription
key_iduint32Key index (0-255)
public_keybytes32-byte public key

Response: ExchangeOAuth2CodeAndStoreResponse

FieldTypeDescription
successbooltrue if the call succeeded
messagestringHuman-readable result
account_identifierstringAccount identifier, such as mail@example.com
token_ciphertextbytesEncrypted token ciphertext
token_iduint32Unique identifier for the stored token
server_ephemeral_public_keyslist[PublicKey]Exactly 256 ephemeral public keys generated by the server

Example:

grpcurl -plaintext \
  -H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
  -H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
  -H 'x-key-id: <KEY_ID>' \
  -H 'x-nonce-bin: <NONCE>' \
  -H 'x-timestamp: <TIMESTAMP>' \
  -d @ \
  -proto publisher.proto \
  <HOST>:<PORT> publisher.v3.Publisher/ExchangeOAuth2CodeAndStore <<EOF
{
  "platform": "gmail",
  "authorization_code": "auth_code",
  "client_ephemeral_public_keys": [
    {"key_id": 0, "public_key": "..."},
    ... 254 more keys ...
    {"key_id": 255, "public_key": "..."}
  ]
}
EOF

Revoke and delete OAuth2 token

Revokes and deletes a stored OAuth2 token, identified by its token ID and the key ID that encrypted it.

Request: RevokeOAuth2TokenRequest

FieldTypeRequiredDescription
token_iduint32YesUnique identifier for the stored token
key_iduint32YesKey index used for encrypting the token

Response: RevokeOAuth2TokenResponse

FieldTypeDescription
successbooltrue if the call succeeded
messagestringHuman-readable result

Example:

grpcurl -plaintext \
  -H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
  -H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
  -H 'x-key-id: <KEY_ID>' \
  -H 'x-nonce-bin: <NONCE>' \
  -H 'x-timestamp: <TIMESTAMP>' \
  -d '{
    "token_id": 2941967295,
    "key_id": 123
  }' \
  -proto publisher.proto \
  <HOST>:<PORT> publisher.v3.Publisher/RevokeOAuth2Token

Get PNBA code

Requests a one-time passcode (OTP) for phone number-based authentication (PNBA).

Request: GetPNBACodeRequest

FieldTypeRequiredDescription
platformstringYesPlatform identifier
phone_numberstringYesPhone number to receive OTP
request_identifierstringNoRequest identifier for tracking
channelstringNoChannel to send the code over, such as signal

Response: GetPNBACodeResponse

FieldTypeDescription
successbooltrue if the call succeeded
messagestringHuman-readable result
expires_atint64Optional. Code expiry, Unix epoch seconds

Example:

grpcurl -plaintext \
  -H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
  -H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
  -H 'x-key-id: <KEY_ID>' \
  -H 'x-nonce-bin: <NONCE>' \
  -H 'x-timestamp: <TIMESTAMP>' \
  -d '{
    "platform": "telegram",
    "phone_number": "+237600000000"
  }' \
  -proto publisher.proto \
  <HOST>:<PORT> publisher.v3.Publisher/GetPNBACode

Exchange PNBA code and store token

Exchanges a one-time passcode (OTP) for a token, stores it, and returns encrypted token data.

Request: ExchangePNBACodeAndStoreRequest

FieldTypeRequiredDescription
platformstringYesPlatform identifier
phone_numberstringYesPhone number that received OTP
authorization_codestringYesPNBA authorization code
passwordstringNoPassword for two-step verification
request_identifierstringNoRequest identifier for tracking
client_ephemeral_public_keyslist[PublicKey]YesExactly 256 ephemeral public keys generated by the client, with key_id 0-255 each used once
channelstringNoChannel the code was sent over, such as signal

Response: ExchangePNBACodeAndStoreResponse

FieldTypeDescription
successbooltrue if the call succeeded
messagestringHuman-readable result
two_step_verification_enabledboolTwo-step verification status
account_identifierstringAccount identifier
token_ciphertextbytesEncrypted token ciphertext
token_iduint32Unique identifier for the stored token
server_ephemeral_public_keyslist[PublicKey]Exactly 256 ephemeral public keys generated by the server
key_iduint32Key ID used for encrypting token

Example:

grpcurl -plaintext \
  -H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
  -H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
  -H 'x-key-id: <KEY_ID>' \
  -H 'x-nonce-bin: <NONCE>' \
  -H 'x-timestamp: <TIMESTAMP>' \
  -d @ \
  -proto publisher.proto \
  <HOST>:<PORT> publisher.v3.Publisher/ExchangePNBACodeAndStore <<EOF
{
  "platform": "telegram",
  "phone_number": "+237600000000",
  "authorization_code": "auth_code",
  "client_ephemeral_public_keys": [
    {"key_id": 0, "public_key": "..."},
    ... 254 more keys ...
    {"key_id": 255, "public_key": "..."}
  ]
}
EOF

Revoke and delete PNBA token

Revokes and deletes a stored PNBA token.

Request: RevokePNBATokenRequest

FieldTypeRequiredDescription
token_iduint32YesUnique identifier for the stored token
key_iduint32YesKey index used for encrypting the token

Response: RevokePNBATokenResponse

FieldTypeDescription
successbooltrue if the call succeeded
messagestringHuman-readable result

Example:

grpcurl -plaintext \
  -H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
  -H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
  -H 'x-key-id: <KEY_ID>' \
  -H 'x-nonce-bin: <NONCE>' \
  -H 'x-timestamp: <TIMESTAMP>' \
  -d '{
    "token_id": 2941967295,
    "key_id": 123
  }' \
  -proto publisher.proto \
  <HOST>:<PORT> publisher.v3.Publisher/RevokePNBAToken

Sync keys

Replaces a token’s key pools: the client sends 256 new ephemeral public keys and the server returns 256 of its own.

Request: SyncKeysRequest

FieldTypeRequiredDescription
token_iduint32YesUnique identifier for the stored token
key_iduint32YesThe current key index used for encrypting the token
client_ephemeral_public_keyslist[PublicKey]YesExactly 256 new ephemeral public keys generated by the client, with key_id 0-255 each used once

Response: SyncKeysResponse

FieldTypeDescription
successbooltrue if the call succeeded
messagestringHuman-readable result
server_ephemeral_public_keyslist[PublicKey]Exactly 256 ephemeral public keys generated by the server

Example:

grpcurl -plaintext \
  -H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
  -H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
  -H 'x-key-id: <KEY_ID>' \
  -H 'x-nonce-bin: <NONCE>' \
  -H 'x-timestamp: <TIMESTAMP>' \
  -d @ \
  -proto publisher.proto \
  <HOST>:<PORT> publisher.v3.Publisher/SyncKeys <<EOF
{
  "token_id": 2941967295,
  "key_id": 123,
  "client_ephemeral_public_keys": [
    {"key_id": 0, "public_key": "..."},
    ... 254 more keys ...
    {"key_id": 255, "public_key": "..."}
  ]
}
EOF

Every RPC returns a standard gRPC status code; 0 OK is success.

Glossary

  • Adapter (platform adapter): the plugin that publishes to one platform, such as Gmail, installed from its own repository. See Managing adapters.
  • Administrator: a credential holding every scope.
  • Audit event: a record of a change or login. See Audit log.
  • Category: what an adapter publishes, such as email (--cat-id).
  • Credential: a username and password that logs in to the REST API’s admin endpoints, with its own scopes. See Credentials and access.
  • Ephemeral key pool: the client public keys and server key pairs each linked account holds, up to 256 of each; each pair is used once, with an identity key, by a payload or a token check. See Security model.
  • Gateway client: a phone number that relays SMS to the Publisher, such as an Android phone running Deku SMS. See Connecting a relay.
  • Identity key: one of the server’s 256 long-term X25519 keys, used in every gRPC request and payload, and alone for offline payloads.
  • Linked account: a user’s account on a platform, authorized once while online. The Publisher stores its token encrypted.
  • MSISDN: a phone number in international format, such as +237600000000.
  • OAuth2: linking through the platform’s consent page, as for Gmail.
  • Offline payload: content sent without a linked account, published through RelaySMS Mail. See Offline publishing.
  • Payload: the encrypted content the RelaySMS app sends by SMS; one carrying an attachment arrives in several segments.
  • PLMN: a mobile network, identified by its MCC (country code) and MNC (network code). See How suggest works.
  • PNBA (phone number-based authentication): linking with a code sent to the user’s phone, as for Telegram.
  • Protocol: how an adapter links accounts, such as oauth2 (--proto-id). For a payload, how it reached the Publisher, such as https (the REST API); see Offline publishing.
  • Redirect URI: where an OAuth2 platform sends the user back after they authorize. See Redirect URIs.
  • rmail: the name of the RelaySMS Mail adapter. See RelaySMS Mail.
  • Scope: a permission a credential holds, such as gc:write. See Scopes.
  • Sender hash: the keyed hash that replaces a sender’s phone number before anything is stored.
  • Token: what the Publisher stores for a linked account to act on the platform, encrypted.
  • Web session: a REST API login kept in a cookie, for a web client.

Architecture

The Publisher is a set of services sharing one database and one Celery broker. Each platform is reached through an adapter that runs as its own process.

---
config:
  flowchart:
    curve: linear
---
flowchart TB
    app["RelaySMS app"]
    gateway["Gateway client"]
    mailbox["Mailbox"]
    platform["Online platforms"]

    subgraph publisher["Publisher"]
        grpc["gRPC API"]
        rest["REST API"]
        smtp["Email listener"]
        beat["Celery Beat"]
        db[("Database")]
        broker[("Celery broker")]
        worker["Celery worker"]
        adapter["Adapter process"]
    end

    app -- "link an account" --> grpc
    grpc --> adapter
    app -- "SMS" --> gateway
    gateway -- "HTTPS" --> rest
    gateway -- "email" --> mailbox
    mailbox -- "IMAP" --> smtp
    rest -- "queue" --> broker
    smtp -- "queue" --> broker
    beat -- "schedule cleanups" --> broker
    broker --> worker
    grpc -- "token, keys" --> db
    db --> worker
    worker --> adapter
    adapter --> platform

Every service in the Publisher also reads and writes the database.

Services

ServiceDoes
gRPC APILinks and unlinks accounts through their adapters, stores their tokens encrypted, and exchanges ephemeral public keys with the app (SyncKeys)
REST APITakes payloads from gateway clients, and serves the admin endpoints, publication stats and public lists
Email listenerReads payloads that gateway clients email to a mailbox
Celery workerDecrypts each queued payload and publishes it through the platform’s adapter; also installs and updates adapters
Celery BeatSchedules the cleanup jobs and, when set, the worker heartbeat

The database holds linked accounts, keys, credentials, adapters, gateway clients, the audit log and publication stats. The broker carries the queue between the services and the worker; with the default SQLite broker it’s two files in data/.

Adapters

Each adapter is a Python package installed from its own repository into its own virtualenv. The Publisher runs it as a separate process for each call, with a 60-second limit, and talks to it over JSON-RPC on standard input and output. It gets its own config and state directories under data/platforms/ and a few environment variables, not the Publisher’s settings. It runs as the service user (security model). See Writing an adapter.

A publication, step by step

  1. A gateway client posts a payload to POST /v1/publications, or emails it.
  2. The REST API or the email listener checks its structure, replaces the sender’s address with a keyed hash, and queues it.
  3. A multi-part payload waits until all its segments arrive.
  4. The worker finds the payload’s keys, decrypts it, and calls the platform’s adapter to publish it.
  5. Only a successful publication uses up the keys; a failure leaves them usable.
  6. The outcome is recorded in the publication stats.

The security model covers the keys and encryption.

Security model

The RelaySMS protocol is described in the technical whitepaper. This page covers what the Publisher holds and who can read it.

Keys

KeyWhereUsed for
Server identity keys256 X25519 key pairs in the databaseEvery gRPC request and every payload; the only key for payloads sent without a linked account
Ephemeral key poolsUp to 256 client public keys and 256 server key pairs per linked accountOne use each, by a payload or a token check; a successful use deletes the pair
Encryption keys.envEncrypting the private keys, the platform tokens and, with SQLite, the database file, and keying the sender hash

The app refills a linked account’s pools with the gRPC SyncKeys call. A failed publication leaves its key pair usable, so the same SMS can publish when it’s sent again. A client must never encrypt different content with a key pair it has already used.

What travels encrypted

  • Payloads from a linked account are encrypted by the app with a pool key pair combined with an identity key, so the gateway client and the network carry only ciphertext.
  • Payloads sent without a linked account are encrypted to an identity key alone, without forward secrecy: anyone who later obtains the identity private keys can decrypt recorded ones.
  • gRPC requests carry their payload (x-payload-bin) encrypted to an identity key. A request is refused if its nonce was already seen, or if its timestamp is more than NONCE_TTL_SECONDS from the server’s clock. The other request fields, such as phone numbers, login codes and two-step passwords, rely on TLS, from nginx or GRPC_TLS_ENABLED.
  • The REST API takes payloads without credentials, and the email listener only from allowed senders. Either way, a payload only publishes if it decrypts with a linked account’s keys, or passes the offline publishing rules.

What the operator can see

DataStored as
Platform tokensEncrypted with DATABASE_FIELD_ENCRYPTION_KEY while DATABASE_FIELD_ENCRYPTION_ENABLED is on, as template.env sets it
Private keysEncrypted with DATA_ENCRYPTION_KEY
Sender phone numbersReplaced with a keyed hash before anything is stored
Message contentCiphertext while queued, and while a multi-part payload waits for its segments; decrypted only in memory, in the worker and the adapter process it hands it to
Publication statsPlatform, protocol, status, country code, failure reason and time

Adapters

An adapter runs as the service user, so it can read .env and the database. PLATFORMS_GITHUB_ORGS limits which orgs administrators can install from over the REST API.

Who can read what

Anyone holding both .env and the database can decrypt the tokens, publish as the linked accounts, and decrypt recorded payloads sent without a linked account. With field encryption off, the database alone exposes the tokens. Protect them as Backup and restore and Security hardening describe.

Data and retention

The cleanup jobs run on the CELERY_CLEANUP_CRON and CELERY_TOKEN_CLEANUP_CRON schedules, and delete data past its limit at the next run.

DataKept until
Linked accounts’ tokensThe user unlinks, or the account has no publication or key sync for TOKEN_IDLE_MAX_AGE_DAYS (90); then the Publisher also tries to revoke it on its platform
Ephemeral key poolsEach pair is deleted when a publication or token check uses it; the pools go with their token
Incomplete multi-part payloadsAll segments arrive, or PAYLOAD_SESSION_MAX_AGE_HOURS (3) passes
Publication statsKept
Audit events and adapter jobsAUDIT_RETENTION_DAYS (365)
Web sessionsLogout, AUTH_SESSION_IDLE_MINUTES (30) idle, or AUTH_SESSION_MAX_HOURS (12)
Credentials, adapters and gateway clientsYou remove them
Server identity keysKept
Adapters’ own filesUp to each adapter; they live in its state directory
LogsThe system journal’s retention, and SigNoz’s when OTEL_EXPORTER_OTLP_ENDPOINT is set

See the security model for what each record holds.

Writing an adapter

An adapter publishes to one platform. It’s a Python package in its own repository, built with the RelaySMS Adapter SDK, which the Publisher runs in its own virtualenv and calls over JSON-RPC.

Requirements

  • Python 3.12 or newer
  • A GitHub repository
  • A developer account and API credentials for the platform

Steps

  1. Copy the SDK’s echo adapter into a new repository named after the platform and protocol, such as example-oauth2-adapter.

  2. Subclass the SDK’s base class for its protocol, such as OAuth2Adapter, and implement its methods (Write an adapter).

  3. Describe it in adapter.toml: its name, protocol, category, and a [config] table for each setting (Describe it).

  4. Try it with the relaysms-adapter console, which runs it the way the Publisher does (Try it).

  5. Tag a release, such as v1.0.0 (Release an adapter).

  6. Install it on a Publisher:

    sudo ./publisher.sh platforms add https://github.com/<ORG>/<REPOSITORY>
    
  7. Set its settings with sudo ./publisher.sh platforms config set <NAME> <KEY>=<VALUE>, or platforms setup <NAME> if it has a setup (Settings).

Check it

sudo ./publisher.sh platforms list shows the adapter with its tag, and ✓ under Configured once its settings are set. Then link a test account from the RelaySMS app.

The adapters in the provider guides, such as gmail-oauth2-adapter, are complete examples.

Reference client

tools/client.py drives the gRPC and REST flows by hand, the way a phone app would. It keeps tokens and key pools in tools/db.json between runs.

pip install -r requirements.txt
python -m tools.client <COMMAND> [ARGS]

Connection options

OptionDefaultDoes
--host, --port127.0.0.1, 6000gRPC server
--tlsoffUse TLS for gRPC
--rest-apihttp://localhost:16000REST API base URL
--platform, -pNonePlatform name, such as gmail
--phone-numberNonePhone number, for phone number-based authentication (PNBA), such as for Telegram
--request-identifierNoneOptional request identifier

Linking accounts

CommandDoes
get-oauth2-urlPrints the OAuth2 authorization URL
exchange-oauth2-code --code <CODE>Exchanges the code and stores the token
revoke-oauth2-tokenRevokes a stored OAuth2 token
get-pnba-code [--auth-channel signal]Requests a PNBA one-time code
exchange-pnba-code --code <CODE> [--password <PASSWORD>]Exchanges the code and stores the token; --password for two-step verification
revoke-pnba-tokenRevokes a stored PNBA token
sync-keysReplaces the token’s 256 client and 256 server ephemeral keys
python -m tools.client get-oauth2-url --platform gmail
python -m tools.client exchange-oauth2-code --platform gmail --code <AUTH_CODE>
python -m tools.client get-pnba-code --platform telegram --phone-number +237600000000
python -m tools.client exchange-pnba-code --platform telegram --phone-number +237600000000 --code <OTP>

With several stored tokens, sync-keys and the revoke commands ask which one to use. Pass --token to choose up front.

Publishing

send encrypts a message and posts it to POST /v1/publications with --address as the sender. With an attachment, the payload is split into SMS-sized segments sent --interval seconds apart.

python -m tools.client send --platform gmail --address +237600000000 \
    --to friend@example.com --subject "Hello" --body "Test message"

python -m tools.client send --platform gmail --address +237600000000 \
    --to friend@example.com --subject "Hello" --body "See attached" --attachment ./file.pdf --interval 2.5 --shuffle

python -m tools.client send --offline --platform rmail --address +237600000000 \
    --to friend@example.com --subject "Hello" --body "No token needed" --tag <SECRET>
OptionDoes
--addressSender’s number in E.164 format (required)
--bodyMessage body (required)
--toRecipient email or number, for email and messaging platforms
--subjectSubject, for email platforms
--attachmentFile to attach, sent as several segments
--intervalSeconds between segments (default 1.0)
--shuffleSends segments out of order
--dry-runPrints the segments and their order without sending
--tokenRaw token (base64), instead of choosing interactively
--offlineUses offline-first encryption, which needs no linked token (rmail only)
--tagThe server’s OFFLINE_PUBLISH_SHARED_SECRET, when it sets one

Each online send uses up one ephemeral key pair. Run sync-keys when the pool runs low. Offline sends use none.

--shuffle --dry-run shows the segment order before a live send, to check the server reassembles out-of-order segments.