A bot written in Python that posts fedi sign-up requests in a Matrix chat and, depending on the reaction in the reply, approves or denies the signup.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-24 15:12:36 +02:00
imgs staging-2.0 (#1) 2026-09-03 12:53:05 +00:00
k8s staging-2.0 (#1) 2026-09-03 12:53:05 +00:00
tests Release v3.1.0: configurable User-Agent, --version command, version carried by every release 2026-09-23 16:51:25 +02:00
.gitignore update SKILLS formatting and .gitignore 2026-09-24 15:12:36 +02:00
bot.example.yaml Release v3.1.0: configurable User-Agent, --version command, version carried by every release 2026-09-23 16:51:25 +02:00
bot.py Release v3.1.0: configurable User-Agent, --version command, version carried by every release 2026-09-23 16:51:25 +02:00
CHANGELOG.md Release v3.1.0: configurable User-Agent, --version command, version carried by every release 2026-09-23 16:51:25 +02:00
Dockerfile remove openshift config from Dockerfile, not used anymore. 2026-09-23 15:38:13 +02:00
LICENSE feature-complete release of te bot, plus license, documentation, Dockerfile etc. 2026-06-30 13:47:28 +02:00
LLM-NOTICE.md feature-complete release of the bot, plus license, documentation, Dockerfile etc. 2026-06-30 13:50:02 +02:00
README.md Release v3.1.0: configurable User-Agent, --version command, version carried by every release 2026-09-23 16:51:25 +02:00
requirements-dev.txt Release v3.0.0: pending-list sweeps, self-paced admin API traffic, durable action retries, System status view 2026-09-19 18:41:10 +02:00
requirements.txt Release v2.2.0 add email domain block feature (#2) 2026-09-04 18:54:43 +00:00
SKILLS.md update SKILLS formatting and .gitignore 2026-09-24 15:12:36 +02:00

Mastodon Signup Review Bot — Setup Guide

This bot sits between Mastodon's account-approval workflow and your moderation team. When someone signs up on an instance that requires manual approval, the bot:

  1. waits until the new user has confirmed their email address,
  2. looks up the signup IP's country, city, and ASN,
  3. optionally validates the signup email (UserCheck / self-hosted Reacher),
  4. surfaces the review to one or both of:
    • a web UI — a minimal HTML page at /webui listing pending signups with ✅ Accept / ❌ Reject buttons (plus an opt-in ⛔ Reject + block domain button; see Review integrations), or
    • a Matrix room — a review card that moderators resolve by replying accept / reject in the thread, or by reacting ✅ Accept, ❌ Reject, or ⛔ Reject + block domain,
  5. calls the Mastodon Admin API to approve or reject the account, and
  6. records the decision (web UI list update and/or Matrix thread confirmation).

The web UI has two extra views: IP and email check (/webui?view=check) runs an IP lookup and/or the configured email checks on any address on demand, and System status (/webui?view=status), a read-only dashboard of stuck or failed work — rate-limit state, signups still waiting for confirmation, and actions awaiting retry.

Both integrations are optional — enable the web UI only, the Matrix room only, or both, via the integrations block in bot.yaml. At least one must be enabled; with both off the bot exits at startup. When both are on they are equivalent: the first decision wins and the signup disappears from the other surface.

It supports any number of Mastodon instances feeding into a single web UI and/or Matrix room.

Review card in the Matrix room Replying accept / reject in the thread pane Web UI — light theme Web UI — dark theme Light / dark comparison

Click any screenshot to enlarge.

Contents


Requirements

  • Python 3.10 or newer
  • Network access from the bot host to:
    • each Mastodon instance's API
    • ip-api.com (HTTPS, port 443) for IP geolocation
    • your Matrix homeserver — only if the Matrix integration is enabled
    • api.usercheck.com (HTTPS) — only if the usercheck.com email check provider is enabled
    • your self-hosted Reacher instance — only if the reacher email check provider is enabled
  • A dedicated Matrix account for the bot — only if the Matrix integration is enabled
  • An admin-level API token on each Mastodon instance you want to manage (Setting up the Mastodon application and webhook)

Installation and running the bot

Install dependencies (exact pins in requirements.txt):

pip install -r requirements.txt

Directly with Python

python bot.py
# with debug logging:
LOG_LEVEL=DEBUG python bot.py

As a systemd service

# /etc/systemd/system/mastodon-signup-bot.service
[Unit]
Description=Mastodon signup review bot
After=network.target

[Service]
User=mastodon-bot
WorkingDirectory=/opt/mastodon-signup-bot
ExecStart=/opt/mastodon-signup-bot/venv/bin/python bot.py
Restart=on-failure
RestartSec=5
Environment=BOT_CONFIG=/opt/mastodon-signup-bot/bot.yaml
# Environment=LOG_LEVEL=DEBUG   # uncomment while debugging

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now mastodon-signup-bot
journalctl -u mastodon-signup-bot -f

In Docker

Pull and run from the registry (or build locally — a Dockerfile is included):

docker pull git.fediverse.foundation/ff_pub/fedi-signup-bot:latest

docker run -d \
  --name fedi-signup-bot \
  -p 8080:8080 \
  -v /path/to/your/bot.yaml:/app/bot.yaml:ro \
  -v /path/to/bot-cache:/app/cache \
  -e WEBHOOK_CACHE_FILE=/app/cache/webhook_cache.json \
  --restart unless-stopped \
  git.fediverse.foundation/ff_pub/fedi-signup-bot:latest

The cache volume persists the webhook replay-dedup cache across restarts — without it, a captured signed webhook could be replayed within its TTL window after every restart.

Debug environment variables:

Variable Effect
LOG_LEVEL=DEBUG Log every sync event and full API responses — the main setup-verification tool
LOG_PII=1 Log full email addresses instead of fingerprints (incident debugging only)

Verifying the setup

curl https://bot.example.com/health
# {"status": "ok"}

To see which version is running:

python bot.py --version
# mastodon-signup-bot 3.0.0

/health returns only that one line and is IP-restricted (private networks by default — see health: in Configure the bot). With the web UI enabled:

curl -s https://bot.example.com/webui | head
# <!doctype html> … <h1>📬 Mastodon signup review — 0 pending</h1> …

Confirm Matrix connectivity (if enabled) in the startup logs (Joined review room !roomid, Matrix sync loop started as @signup-bot:example.org). For an end-to-end test, sign up for a test account with a real working email address — after you click the confirmation link, a review appears within one poll cycle (default 60 s).


Setting up the Matrix integration

Only needed if integrations.matrix: true.

Create the bot account

Use any Matrix homeserver (Synapse, Dendrite, Conduit, …). Register a dedicated account — do not reuse a personal one. The simplest way to get an access token is a password login via curl:

curl -s -XPOST 'https://matrix.example.org/_matrix/client/v3/login' \
  -H 'Content-Type: application/json' \
  -d '{
        "type": "m.login.password",
        "identifier": { "type": "m.id.user", "user": "signup-bot" },
        "password": "REPLACE_ME"
      }'

Copy the returned access_token into bot.yaml. Treat it like a password — it grants full access to the bot account. Do not log out the device this token belongs to, or the token is invalidated and the bot stops working.

Create the review room

  1. Create a Matrix room for signup review. A private room restricted to your moderation team is strongly recommended — approval/rejection decisions and signup IPs are visible to everyone in it.
  2. Invite the bot account to the room; the bot auto-joins on startup, but the invite must already exist.
  3. Get the room's internal ID (starts with !, not the human-readable # alias). In Element: Room Settings → Advanced → "Internal room ID".

Setting up the Mastodon application and webhook

Required for every Mastodon instance you want to manage, regardless of which review integration(s) you use. Do both steps as an administrator, once per instance.

Create the Admin API token

The token is what lets the bot query account status and approve/reject accounts through the Mastodon Admin API — every moderation decision ultimately goes through it, so store it carefully.

  1. Log in with an account that has administrator privileges.
  2. Go to Preferences → Development → New Application, name it (e.g. signup-review-bot), and grant scopes:
    • mandatory: admin:read:accounts, admin:write:accounts
    • optional — only for the reject + block email domain feature (Review integrations): admin:write:email_domain_blocks (required) and admin:read:email_domain_blocks (optional)
  3. Submit and copy the access token from the application's detail page.

The token is instance-specific — repeat for every instance. Adding scopes later does not require a new token: edit the existing application, tick the extra scope(s), save; the token stays valid.

Register the webhook

Mastodon pushes an account.created event to the bot the moment someone submits the signup form — this is what starts every review.

  1. Go to Administration → Webhooks → Add new endpoint. 2. URL: https://<your-bot-host>/webhook/<mastodon-domain> — e.g. https://bot.example.com/webhook/mastodon.example.org. The <mastodon-domain> segment must exactly match the domain: value in bot.yaml; the bot uses it to route the webhook to the right API credentials. 3. Events: select account.created. 4. Save. Mastodon shows a signing secret — copy it into bot.yaml as webhook_secret for this instance. It is required: without it the bot rejects every webhook for that instance with 401, and there is no opt-out.

The endpoint must be reachable from the Mastodon server over HTTPS — put the bot behind a TLS-terminating reverse proxy (Reverse proxy setup).


Configure the bot

cp bot.example.yaml bot.yaml

Edit bot.yaml (config is read once at startup — restart the bot after editing; use the BOT_CONFIG env var for a different path):

# 127.0.0.1 (loopback) is the default and the safe choice — put a reverse
# proxy in front. "0.0.0.0" binds every interface (loud startup warning).
webhook_host: "127.0.0.1"
webhook_port: 8080

# Optional server settings (defaults shown — uncomment to override):
# trust_proxy: false          # use the first X-Forwarded-For hop for the /webui
#                             #   audit IP, access log and /health allowlist —
#                             #   only behind a proxy you control
# webui_secure_cookie: true   # Secure flag on the theme cookie; keep true behind TLS
# health: true                # /health access: true (private-only, default) |
#                             #   false (allow all) | ["10.0.0.0/8", …] (exactly
#                             #   these IP/CIDRs); [] allows nothing

integrations:
  matrix: true        # post review cards to the Matrix room (default true)
  webui: true         # serve the HTML review page at /webui (default false)
  webui_refresh: 15   # seconds between automatic web UI refreshes (0 disables)

matrix:
  homeserver: "https://matrix.example.org"
  user_id: "@signup-bot:example.org"
  access_token: "syt_..."            # see Setting up the Matrix integration
  review_room: "!roomid"             # internal room ID only, no domain suffix
  # allowed_users:                   # optional: restrict who may moderate from
  #   - "@alice:example.org"         #   the room (full MXIDs); absent/empty =
                                     #   anyone in the room may act

email_confirmation:
  poll_interval: 60      # seconds between email-confirmation checks
  timeout: 86400         # give up after 24 h if never confirmed
  on_timeout: notify     # notify | reject | silent — see Understanding account status polling

# Pending-list polling (DEFAULT MODE — the single source of truth for
# account state; one sweep of the pending list per instance).
# enabled: false restores legacy per-account polling.
# pending_list:
#   enabled: true        # off = legacy per-account polling (see below)
#   poll_interval: 60    # seconds between pending-list sweeps

manual_action_poll_interval: 300   # post-card Mastodon polls (legacy mode only)

# Optional DoS limits (defaults shown — uncomment to override):
# max_concurrent_reviews: 50  # in-flight review pollers
# review_queue_wait: 10       # seconds a webhook waits for a slot before 429
# max_pending_reviews: 500    # cap on the in-memory pending queue
# on_pending_cap: evict       # evict oldest | reject new webhooks with 429

# Optional: rate-limit the bot's OWN admin API traffic per instance
# (see "Rate-limiting the bot's own API traffic")
# api_rate_limit:
#   requests_per_minute: 50  # steady-state cap for background polls (0 = no cap)
#   burst: 10                # short bursts allowed
#   backoff_base: 60         # cooldown after the first HTTP 429, seconds
#   backoff_multiplier: 2.0  # growth per consecutive 429
#   backoff_max: 900         # cooldown cap, seconds

# Optional: adaptive confirmation polling + failed-action retry tuning
# (defaults shown — uncomment to override)
# adaptive_polling:
#   enabled: true            # off = always poll at the fixed base interval
#   threshold: 10            # in-flight reviews at/above which ALL polls widen
#   extended_interval: 300   # widened cadence (seconds) while the load is high
# action_retry:
#   max_attempts: 20         # failed-action retry cap before EXHAUSTED
#   interval: 30             # seconds between retry passes
#   file: /var/lib/fedi-signup-bot/action_outbox.json   # where the outbox
#                       # JSON lives (default: next to bot.yaml; must be
#                       # absolute) — env ACTION_OUTBOX_FILE wins over this

# Optional: persist pending reviews + in-flight signups across restarts
# (default OFF — a restart loses them; see "Persisting pending signups")
# persistence:
#   enabled: true
#   type: disk
#   path: /var/lib/fedi-signup-bot/signup_cache.json   # must be absolute

# Optional: relocate the webhook replay-dedup cache (default: next to
# bot.yaml as webhook_cache.json).  --webhook-cache and env
# WEBHOOK_CACHE_FILE win over this; an unusable path warns at startup,
# it never blocks (the cache must not break webhook handling).
# webhook_cache:
#   file: /var/lib/fedi-signup-bot/webhook_cache.json

# Optional: User-Agent sent with every outgoing API request (Mastodon admin
# API, ip-api lookups, email checks) — what your instance sees in its logs.
# Default: fedi-signup-bot/<version of the running bot>.
# user_agent: "my-mastodon-bot/1.2.3"

# Reject + block signup email domain (opt-in, default OFF) — see Review
# integrations. Requires the admin:write:email_domain_blocks token scope;
# a per-instance value overrides the global key.
# email_domain_block: true

# IP-API configuration. Leave empty to use the free tier (HTTP only — data might leak).
#ip_api:
#  key: "YOUR_IP_API_PRO_KEY"   # enables HTTPS via pro.ip-api.com

email_check:
  - provider: usercheck.com
    enabled: false
    token: ""             # leave empty for free tier (100 lookups/day); paid key optional
  - provider: reacher
    enabled: false
    url: "http://reacher.internal:8080"
    token: ""             # optional auth token
    # timeout: 20         # Reacher can be slower due to SMTP probing

mastodon_instances:
  - domain: "mastodon.example.org"
    access_token: "..."                # see Setting up the Mastodon application and webhook
    webhook_secret: "..."              # required — no secret, all webhooks 401
  - domain: "another.instance.social"
    access_token: "..."
    webhook_secret: "..."
    email_poll_interval: 30            # optional per-instance overrides
    email_timeout: 43200
    on_timeout: reject
    email_domain_block: true
    # Per-instance overrides (win key-by-key): api_rate_limit, adaptive_polling

Reference for the less obvious keys:

Key Default Meaning
trust_proxy false Use the first X-Forwarded-For hop for the web-UI audit IP, access log and /health allowlist. Only behind a proxy you control — the header is spoofable otherwise.
webui_secure_cookie false Secure flag on the theme cookie; set true behind TLS.
health private-only IP allowlist for /health: true/absent = private networks + loopback; false = allow all; list = exactly those networks ([] allows nothing). A bad CIDR fails startup.
integrations.webui_refresh 15 Web UI auto-refresh interval in seconds; 0 disables.
matrix.allowed_users (absent) Moderator allowlist (full MXIDs); absent/empty = room membership is the only gate.
pending_list (enabled / poll_interval) on / 60 Default mode. One sweep of the instance's pending list every poll_interval seconds is the single source of truth for confirmation + manual-action detection. enabled: false restores legacy per-account polling — see below.
email_confirmation.* / per-instance email_poll_interval, email_timeout, on_timeout 60 / 86400 / notify Confirmation-wait tuning; per-instance values win over the global block.
manual_action_poll_interval 300 Post-card watcher cadence for manual moderator actions in Mastodon. (legacy mode only — with pending_list on, the watcher rides the shared sweeps)
email_domain_block (global + per-instance) off Opt-in reject + block email domain; see Review integrations. The per-instance value wins over the global one.
ip_api.key (empty) ip-api.com Pro key → HTTPS lookups. Free tier uses plain HTTP.
DoS limits (max_concurrent_reviews, review_queue_wait, max_pending_reviews, on_pending_cap) 50 / 10 / 500 / evict Bound the in-memory resources a signup burst can consume.
api_rate_limit (requests_per_minute / burst / backoff_base / backoff_multiplier / backoff_max) 50 / 10 / 60 / 2.0 / 900 Per-instance rate gate for the bot's own admin API traffic — 429 cooldown + steady-state cap; see below.
adaptive_polling (enabled / threshold / extended_interval, plus a per-instance override) on / 10 / 300 While an instance has ≥ threshold in-flight (pre-card) reviews, ALL its confirmation polls widen to extended_interval seconds until the load drops. (legacy mode only — config validation warns if it is set while pending_list is on)
action_retry (max_attempts / interval / file) 20 / 30 / <config dir>/action_outbox.json Failed-action (outbox) retry cap, cadence and file location (file must be absolute — same rules as persistence.path). Env ACTION_OUTBOX_MAX_ATTEMPTS / ACTION_OUTBOX_TICK / ACTION_OUTBOX_FILE win.
persistence (enabled / type / path) off Opt-in disk store — pending reviews + in-flight signups survive a restart (see below).
webhook_cache.file <config dir>/webhook_cache.json Where the replay-dedup cache is persisted so the dedup window survives restarts. Must be absolute (same rules as persistence.path); --webhook-cache / env WEBHOOK_CACHE_FILE win. An unusable path warns at startup instead of failing — the cache must never break webhook handling.
degraded_summary_interval 600 While an instance is rate-limited or unreachable, one WARNING per affected instance repeats every N seconds; silent when healthy. 0 disables. The System status view shows the same state live.

Persisting pending signups across restarts (opt-in). By default the pending queue lives in memory: a restart loses every pending review (web UI list empties, Matrix cards silently stop being actionable) and abandons every signup still waiting for email confirmation. To make them survive, enable the disk store:

persistence:
  enabled: true
  type: disk          # currently the only option
  path: /var/lib/fedi-signup-bot/signup_cache.json   # must be absolute

The state is written after every change and restored at startup before the webhook listener comes up: after a restart the web UI list is populated again, Matrix cards are actionable again, and in-flight signups resume with their remaining confirmation deadline (an account that confirmed during the downtime is picked up on the first poll). An unusable path (missing directory, not writable, path is a directory) exits startup with an error describing the problem — it never silently falls back to in-memory-only. Runtime disk errors are logged and the in-memory state stays authoritative, so a full or read-only disk can never break webhook handling or moderation; a corrupt state file degrades to an empty start with a warning. For Docker, mount a volume at the file's directory so the state survives container rebuilds.

Each option can also be set from the environment or command line (PERSISTENCE_ENABLED / PERSISTENCE_TYPE / PERSISTENCE_PATH, --persistence-enabled / --persistence-type / --persistence-path) with the usual precedence: command line > environment > bot.yaml; an override is re-validated at startup exactly like the config file.

Rate-limiting the bot's own API traffic (per-instance rate gate). A burst of unconfirmable signups can push the instance's admin API into its rate limit on its own. The bot therefore routes every admin API call (confirmation polls, post-card watcher, approve/reject, domain blocks) through one gate per Mastodon instance:

  • Any HTTP 429 puts that whole instance into a cooldown (honouring the server's Retry-After, growing with each consecutive 429 up to backoff_max). During it every admin call for that instance waits; any other response resets the escalation.
  • A steady-state cap (requests_per_minute + burst) limits background polls even before the API starts 429-ing; requests_per_minute: 0 disables it.
  • Human-paced actions (Accept/Reject clicks, reject+block) are exempt from the cap — a moderator click never queues behind pollers — but they wait out a cooldown.
  • Poll sleeps carry an independent ±25% jitter so pollers that start together don't fire in lockstep.

Log hygiene during a storm: exactly one aggregate WARNING per backoff transition (episode start or escalation) instead of one line per account, a periodic summary every degraded_summary_interval seconds while degraded, and one INFO line on recovery. Failed pending-list sweeps close the same way: one WARNING when the streak starts (429 or API/network error) and one INFO pending-list sweep recovered line when the next complete sweep lands — a 429 storm therefore ends with two lines, the gate's "no longer rate-limited" and the sweep's recovery. Tune api_rate_limit if your instance's admin API is stricter — or lower requests_per_minute proactively.

Adaptive confirmation polling (widen the cadence under load — legacy mode only). With pending_list enabled (the default) there are no per-account polls to widen. In legacy mode every unconfirmed signup is its own poller, so a signup wave multiplies the bot's request rate on its own: when the in-flight (pre-card) review count reaches adaptive_polling.threshold (default 10), all confirmation polls for that instance widen to extended_interval seconds (default 300) and return to the base cadence automatically as the load drops — the confirmation wait is minutes-to-hours long, so this costs almost nothing in detection latency. One INFO line per transition; live state in the System status view. Per-instance override: repeat the block inside the instance entry (wins key-by-key). While pending_list is on the setting is dead weight at both levels — config validation warns about it.

Failed action retries (pending-actions outbox). The rate gate stops the bot from causing 429 storms; the outbox makes sure an action that gets caught in one is never lost. When an approve/reject fails with a transient error (HTTP 429, another 5xx, or a network timeout) it is recorded in a durable on-disk outbox and retried by a background pass — paced by the rate gate like everything else — until it succeeds, the account turns out to be gone, or the attempt cap (action_retry.max_attempts, default 20) is reached. This covers both on_timeout: reject auto-rejects (the spam-wave case) and a moderator's click that hit a 429. Exhaustion is loud: a CRITICAL log line plus a ❌ Matrix notice (when enabled), and the entry stays in the file for visibility. The outbox lives in an on-disk JSON file whose location you can set with action_retry.file (default: action_outbox.json next to your config; the env var ACTION_OUTBOX_FILE still wins over the config key). The path must be absolute and is probed strictly at startup — an unusable location exits with an error, never a silent in-memory fallback — and the outbox is restored before the webhook listener comes up, so pending retries survive restarts: a failed action is never silently dropped.

Validating your config

Before you point a webhook (or a reverse proxy) at the bot, validate the config file on its own — the same checks as a real startup, but no server and no Matrix login:

python bot.py --check                      # validates bot.yaml (or $BOT_CONFIG)
python bot.py --check /path/to/bot.yaml    # or an explicit file

It prints every problem it finds — missing required keys, wrong types or out-of-range values, unknown/typo'd keys (with a "did you mean …" hint), unfilled placeholder values, env-var issues — and exits 0 if the config is valid, 1 on errors. Unknown keys are warnings by default; add --strict to make them fail too:

python bot.py --check --strict

Use it in CI and as a dry run before deploying.

Command-line options

Every environment variable above can also be set on the command line, and command-line options take precedence over environment variables, which take precedence over bot.yaml:

Option Env equivalent Meaning
-h, --help — print a description of the bot and all options, then exit
--version — print the bot version, then exit
-c PATH, --config PATH BOT_CONFIG path to the config file (default bot.yaml)
-l LEVEL, --log-level LEVEL LOG_LEVEL DEBUG / INFO / WARNING / ERROR / CRITICAL (default INFO; DEBUG also logs full API responses)
--pii, --no-pii LOG_PII log full email addresses instead of fingerprints (incident debugging only)
--webhook-cache PATH WEBHOOK_CACHE_FILE where the webhook replay cache is persisted
--persistence-enabled, --no-persistence-enabled PERSISTENCE_ENABLED enable/disable persisting pending signups to disk (default: bot.yaml)
--persistence-type TYPE PERSISTENCE_TYPE persistence store type — currently only disk (default: bot.yaml)
--persistence-path PATH PERSISTENCE_PATH where the JSON persistence state file is written (default: bot.yaml)
--check [PATH], --strict — validate the config and exit without starting any server (see above)

Short and long forms are equivalent; an unknown or invalid option is a hard error (exit 2), not silently ignored. Examples:

python bot.py -c prod.yaml -l DEBUG      # use prod.yaml with debug logging
python bot.py --pii                      # log full email addresses
python bot.py --check                    # dry-run the config, start nothing

WEBHOOK_DEDUP_TTL, MAX_PENDING_REVIEWS and the ACTION_OUTBOX_* variables have no command-line flag. The only one with a bot.yaml equivalent is ACTION_OUTBOX_FILE (action_retry.file) — the environment variable still wins.

Email validation

Optionally check each signup's email address with one or both providers — disabled by default. Enabled providers run concurrently with the IP lookup (no extra latency) and produce a single card line:

🔍 Email check: usercheck.com → ✅ ok  |  reacher → ⚠️ risky

Each enabled provider also posts a detailed thread message under the Matrix card (disposable flag, MX validity, SMTP probe result, …); if all are disabled a short thread notice says so.

Provider What it does Notes
usercheck.com Cloud check: disposable flag, MX validity, known public/free provider Free tier: no key, 100 lookups/day per IP. Privacy: each applicant's email is sent to usercheck.com — review your instance's privacy policy first.
reacher Self-hosted Reacher: adds a live SMTP probe Emails stay in your infrastructure. Many major providers (Google, Microsoft, Fastmail) block SMTP probing, so unknown is expected — use it alongside UserCheck.

Enable by setting enabled: true in the email_check list in bot.yaml. Either, both, or neither can be active.

Environment variables

Each of these can also be set with a command-line flag (see Command-line options), which takes precedence over the environment variable.

Variable Default Meaning
BOT_CONFIG bot.yaml Path to the config file.
LOG_LEVEL INFO Log level; DEBUG logs every sync event and full API responses.
LOG_PII off 1/true/yes logs full email addresses instead of fingerprints. Incident debugging only — do not leave on in a deployment.
BOT_VERSION built-in version Version string shown at startup and by --version (injected by the published Docker image; overrides the version built into bot.py).
WEBHOOK_CACHE_FILE <config dir>/webhook_cache.json Where the webhook replay-dedup cache is persisted so the dedup window survives restarts. Wins over webhook_cache.file in bot.yaml.
PERSISTENCE_ENABLED off Enables opt-in persistence of pending signups across restarts even when bot.yaml has no persistence: section (see Persisting pending signups).
PERSISTENCE_TYPE disk Persistence store type; only disk is implemented — any other value exits startup with an error.
PERSISTENCE_PATH (from bot.yaml) Where the JSON persistence state file is written; must be absolute. Wins over persistence.path.
WEBHOOK_DEDUP_TTL 600 Replay-dedup window in seconds. Primarily a test-suite override.
MAX_PENDING_REVIEWS 500 Pending-queue cap; the max_pending_reviews config key wins. Primarily a test-suite override.
ACTION_OUTBOX_FILE <config dir>/action_outbox.json Where the pending-actions outbox is stored; it survives restarts. Wins over action_retry.file in bot.yaml (see Failed action retries).
ACTION_OUTBOX_MAX_ATTEMPTS 20 Retry cap per failed action before it is marked exhausted and alerted. Wins over action_retry.max_attempts.
ACTION_OUTBOX_TICK 30 Seconds between outbox retry passes. Wins over action_retry.interval; primarily a test-suite override.
DEGRADED_SUMMARY_TICK (from bot.yaml) Cadence of the degraded-state summary in seconds. Wins over degraded_summary_interval; primarily a test-suite override.

Review integrations (Matrix / web UI)

⚠️ Secure the web UI before enabling it. /webui is unauthenticated by design: anyone who can reach it can approve or reject signups — equivalent to holding a Mastodon admin token. Restrict access at the reverse proxy (basic auth, IP allow-list, VPN-only routing, or an auth proxy) and never expose /webui to the open internet (Reverse proxy setup). If you only need Matrix review, keep webui: false so the endpoint is not registered at all.

The integrations block in bot.yaml selects the review surfaces — either one or both:

matrix webui Behaviour
true false Matrix only
false true Web UI only — no Matrix account, room, or sync loop needed at runtime
true true Both — a decision in either place is final; the other surface stops showing the signup
false false Rejected at startup — at least one integration must be enabled

Web UI

With webui: true, open http://<your-bot-host>:<webhook_port>/webui. Each pending signup is a card with the same fields as the Matrix card — user (+locale), email, email-check result, IP, location, city, registration time, invite reason — plus ✅ Accept and ❌ Reject buttons (and ⛔ Reject + block domain when enabled for that instance).

  • Every feature has a no-JavaScript fallback, so it works with screen readers, no-JS browsers, and proxies that strip scripts.
  • Theme: light / dark / system picker (follows the OS live for "system"); both palettes meet WCAG-AA contrast.
  • Third view — System status (/webui?view=status): read-only dashboard of stuck and failed work — each instance's rate-limit state, every signup still waiting for e-mail confirmation (age, time left until its deadline, failures), and the pending-action retries (attempts, last error, next retry or EXHAUSTED). Pre-card signups appear on no other surface; while any exist the pending list shows a one-line hint linking here.
  • Auto-refresh: re-checks every webui_refresh seconds, reloading only when the pending list actually changed (preserving scroll position and focus).
  • Forms are CSRF-protected with a sticky per-browser token.
  • Actions are logged with the client IP (Moderation: approve … by webui (203.0.113.42)) — behind a proxy, configure real-IP header handling if you rely on that for auditing.

Matrix room

Each pending signup is one card in the review room; details (ip-api link, email-check breakdowns) are posted as thread messages so the timeline stays at one line per signup. Moderators resolve a card by:

  • reacting ✅ Accept, ❌ Reject, or — when reject + block is enabled — ⛔ Reject + block domain, or
  • replying accept, reject, or reject+block in the card's thread.

Confirmations are posted in the same thread and name who acted. Moderation is gated by room membership, plus an optional code-level backstop: matrix.allowed_users (see Configure the bot).

Reject + block signup email domain (opt-in, default off)

Reject the account and block its email domain instance-wide (Mastodon's admin e-mail-domain blocks — blocking foo.com covers subdomains; persists until deleted). Enable with email_domain_block: true; a per-instance value overrides the global one. Requires the token scope admin:write:email_domain_blocks; the bot checks at startup and warns (non-fatally) if it is missing.

Triggers, on every surface:

Surface Trigger
Matrix thread reply accept, reject, or reject+block (also reject block)
Matrix reaction ✅ Accept, ❌ Reject, or ⛔ Reject + block domain (:no_entry:)
Web UI third button ⛔ Reject + block domain per card

The reject always runs first; only after it succeeds does the bot block the domain — outcomes are appended to the confirmation notice (⛔ blocked · ℹ️ already blocked (duplicate = success) · ⚠️ FAILED (<status>), e.g. 403 missing scope — the account stays rejected). When the feature is disabled, the Matrix reject+block / ⛔ triggers still work as a plain reject with an explicit "domain block skipped" notice, and the web-UI button is not rendered (a stale page POSTing it gets 404). Auto-reject on confirmation timeout never blocks domains — domain blocks are strictly human-triggered.

Shared behaviour

  • Pending reviews exist only in memory (unless persistence is enabled). If the bot restarts while a signup awaits a decision, acting on the old card or list entry afterward silently does nothing — use the "Pending accounts" admin link to handle it manually in Mastodon.
  • Manual handling in Mastodon is detected within one poll cycle (the pending_list sweep interval by default; manual_action_poll_interval in legacy mode): the signup is removed from the web UI list and (when Matrix is enabled) the card gets a 📝 reaction plus a thread notice. No moderator action is needed then.

Reverse proxy setup

The bot listens on plain HTTP on webhook_port and binds 127.0.0.1 by default — only reachable from the local host, which is exactly what a local reverse proxy needs. Put a TLS-terminating proxy in front so Mastodon's webhook can reach it over HTTPS.

Setting webhook_host: "0.0.0.0" binds every interface (the bot logs a loud warning at startup) — only do that behind a firewall that already restricts who can reach the port, or the webhook, /health, and the unauthenticated /webui are exposed on all networks.

The bot sends a baseline of security headers (nosniff, Referrer-Policy, frame-ancestors CSP) and HSTS over HTTPS, but TLS and access control belong at the proxy. Recommended split:

  • /webui — human-facing; protect it here (basic auth below, or IP allow-list / VPN-only routing / SSO check). This is the only line of defence — the bot itself performs no authentication.
  • / (webhook + /health) — machine-to-machine traffic; keep off the open internet if you can. Add rate limiting here if you expose the webhook directly.

Example Nginx config with basic auth for /webui:

server {
    listen 443 ssl;
    server_name bot.example.com;

    ssl_certificate     /etc/letsencrypt/live/bot.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/bot.example.com/privkey.pem;

    # HSTS belongs at the edge — set it here (the backend only emits HSTS
    # when it itself sees HTTPS).
    add_header Strict-Transport-Security "max-age=31536000" always;

    # Web UI — human-facing, protected with basic auth. Nginx picks the
    # longest matching prefix, so /webui* (list, theme picker, Accept /
    # Reject form POSTs) is handled here, not by "/" below.
    location /webui {
        auth_basic "Restricted — signup review";
        auth_basic_user_file /etc/nginx/htpasswd_webui;

        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
    }

    # Webhook + health check — machine-to-machine traffic. /health is
    # IP-restricted by the bot's `health` block (private-only by default),
    # so keep this off the open internet if you can.
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
    }
}

Basic auth setup (Debian/Ubuntu shown; dnf install httpd-tools on RHEL/Fedora):

sudo apt install apache2-utils
sudo htpasswd -c /etc/nginx/htpasswd_webui moderator     # -c creates the file; add more users without -c
sudo chmod 640 /etc/nginx/htpasswd_webui
sudo chown root:www-data /etc/nginx/htpasswd_webui        # RHEL/Fedora: root:nginx
sudo nginx -t && sudo nginx -s reload

Two proxy details that matter for the bot:

  • Keep proxy_set_header X-Real-IP $remote_addr; so the web-UI audit log shows the real client address rather than the proxy's. The bot reads X-Forwarded-For (set it too, as in the example above) and honours it only when you set trust_proxy: true — with that on, the access log, the web-UI audit line and the /health allowlist all see the real caller instead of the proxy's own address (only behind a proxy you control).
  • HSTS: in the plain-HTTP-behind-TLS-proxy setup above, the backend does not emit HSTS on its own — configure it at the proxy, as shown.

Daily operation

When a new signup comes in (after the user confirms their email), moderators see:

📬 New signup on mastodon.example.org

👤 User:        @alice@mastodon.example.org (Locale: en)
📧 Email:       alice@example.com
🔍 Email check: usercheck.com → ✅ ok  |  reacher → ⚠️ risky
🔌 IP:          203.0.113.42
🌍 Location:    Germany (DE)  ·  AS3320 Deutsche Telekom AG
🏙 City:        Berlin
🕐 Registered:  2026-06-30T10:00:00Z

🔗 Pending accounts: https://mastodon.example.org/admin/accounts?status=pending

Reply accept / reject — or — react ✅ / ❌ / ⛔

The 🔍 Email check line is omitted when no providers are enabled. Extra detail lives in thread messages (ip-api.com link, per-provider breakdowns); the card itself carries only the pending-accounts link.

Moderators then act:

  • Web UI: click ✅ Accept or ❌ Reject on the card at /webui (or ⛔ Reject + block domain when enabled). The page redirects back to the list without that signup; the decision is logged with the client IP.
  • Matrix room: react ✅ Accept, ❌ Reject, or ⛔ Reject + block domain, or reply accept / reject / reject+block in the thread. The confirmation names who acted — e.g. "✅ Approve of @alice@… by @mod:example.org succeeded." (or webui (<ip>) for a web-UI click) — and is posted in the same thread.

When both integrations are enabled, the first action wins and the signup is removed from both. If a Mastodon moderator approves or rejects manually via the admin UI, the bot detects it within one poll cycle (pending_list sweep interval by default; manual_action_poll_interval in legacy mode), removes the signup from the web UI list, and (when Matrix is enabled) reacts 📝 with a thread notice:

📝 @alice@mastodon.example.org was manually approved in Mastodon
   before the bot could act. Removed from queue.

Understanding account status polling

Before posting a signup to Matrix, the bot waits for the user to confirm their email address. Instead of one API call per unconfirmed signup, each instance is swept once every pending_list.poll_interval seconds (default 60 s): one paginated read of the pending list returns every pending account with its confirmation, approval and suspension state in a single pass, so N stuck signups cost a few requests per cycle instead of N. The sweep is the single source of truth for:

  • email confirmation — an account shows as confirmed → the review card is posted;
  • early manual actions (pre-card) — an account that drops out of the pending list is verified once: manually rejected/deleted or approved → no card, just a notice;
  • manual actions after the card — the post-card watcher rides the same sweeps and removes handled signups with a 📝 thread notice.

Only a complete sweep updates the state: an error on any page keeps the last good snapshot, so a rate-limit storm can never produce a false "gone" verdict. Sweep health (last complete sweep, failed-sweep streak) shows in the web UI's System status view.

pending_list.enabled: false restores legacy per-account polling — one check per unconfirmed signup every email_confirmation.poll_interval (default 60 s), with adaptive widening (adaptive_polling:) and the separate post-card watcher cadence (manual_action_poll_interval) applying in that mode only.

All admin traffic — sweeps, verifications and every approve/reject the bot performs — passes through the per-instance rate gate above.

Timeout behaviour (on_timeout, global or per-instance):

Value Behaviour
notify (default) Posts a ⏰ notice to Matrix (when enabled); leaves the account untouched. Non-destructive, visible audit trail for abandoned signups.
reject Auto-rejects via the Admin API, then posts the ⏰ notice. Irreversible — a user who confirms later finds the account gone.
silent Does nothing and logs nothing.

Troubleshooting

  • Reactions/replies don't trigger approval, logs show nothing. Set LOG_LEVEL=DEBUG — every sync event is logged. No m.reaction lines at all → the homeserver isn't delivering reactions (check the bot wasn't kicked from the room).
  • Reactions are logged but nothing happens. A line like "…matched action=approve but event not in pending…" means the in-memory state lost that event — most likely the bot restarted after the card was posted. Use the "Pending accounts" link to act manually.
  • Bot posts a "manually approved/rejected" notice for its own actions. Should not happen (own actions are tracked before the API call). Check the logs for a race around that account ID.
  • Clicking Accept/Reject says the account "no longer exists". The signup was manually rejected or deleted in Mastodon between the last poll and your click — a clear confirmation, not an error: for a reject the desired end state (account gone) already holds; for an approve, check the admin panel. A genuine failure still says "FAILED" with the HTTP status code.
  • Webhook 404. The <domain> URL segment doesn't exactly match any domain: entry in bot.yaml (including subdomain).
  • Webhook 401. Signature verification failed — mandatory for every webhook, including Mastodon's plain-text ping. Check the instance has a non-empty webhook_secret matching the secret on the webhook's detail page in Mastodon's admin panel. There is no opt-out.
  • Webhook 409. The same signed body was already accepted within the dedup window (default 600 s), or the account is already pending/in-flight. Expected no-op — re-delivery or replay.
  • Reject succeeded but the notice says domain block FAILED (403). Token lacks admin:write:email_domain_blocks — add it by editing the existing application (no token re-creation). 422 means Mastodon rejected the domain value itself; the log shows the response body.
  • No review card ever appears. Most likely the user hasn't confirmed their email yet, or the bot is still inside the timeout window. "already approved" / "already rejected" log lines mean a manual action suppressed the card.
  • The bot pauses ALL activity on an instance for minutes at a time; logs say admin API rate limited … (cooling down ALL admin calls). The instance answered 429 — typically a burst of unconfirmable signups. By design: all admin traffic for that instance waits out an escalating cooldown, then resumes automatically and nothing is lost. You get one aggregate WARNING per backoff transition, a periodic summary (degraded_summary_interval) while it lasts, one INFO pending-list sweep recovered line when the next complete sweep lands, and the live picture in the System status view. If it happens routinely, lower api_rate_limit.requests_per_minute / burst or raise the poll intervals.
  • Logs say ACTION OUTBOX EXHAUSTED and a Matrix ❌ notice was posted. An approve/reject failed on every attempt up to the retry cap (default 20) — usually a long-lived 429 storm or a misconfigured token. The action was NOT performed; the account is still pending in Mastodon, so handle it there manually.
  • Web UI shows a signup already handled in Mastodon. The post-card watcher removes them within one sweep cycle — wait one cycle.
  • Web UI and Matrix room disagree (one is empty). Pending state is in memory — after a restart, pre-restart cards can no longer be actioned from either surface. Act via the "Pending accounts" link.
  • UserCheck shows "unavailable" for every signup. Rate limiting (free tier: 100 lookups/day per IP — add a paid token:) or timeouts. Graceful degradation, not an error.
  • Reacher returns "unknown" for every address. Expected — many major providers block SMTP probing; unknown means inconclusive, not invalid.
  • Reacher times out. Default timeout is 20 s (a real connection to the target mail server); increase timeout: in the provider config if needed.
  • IP geolocation shows "no IP data". ip-api.com free-tier limits, transient unreachability, or a malformed webhook ip. Degrades gracefully — the card posts without geo/ASN info.

Developer notes

Project shape

Everything lives in one file: bot.py, organized top-to-bottom in execution order with section banners (logging → config → enrichment → Mastodon Admin API → rate gate → pending-list poller → pending-review state → persistence / outbox → SignupReviewBot class → web UI → webhook server → main()). There is deliberately no package layout — keep it that way. SKILLS.md is the canonical map of the code, its key invariants (do-not-break list) and extension points — read it before modifying bot.py. requirements.txt pins exact versions; after any bump, re-run the regression gate below. The project was substantially developed with generative LLM assistance — see LLM-NOTICE.md.

Test suite

The suite in tests/ is a pytest suite that runs the real bot.main() against a fake Mastodon admin API (fake_mastodon.py) and a stubbed Matrix client — no real homeserver or instance needed:

python -m venv venv && venv/bin/pip install -r requirements.txt -r requirements-dev.txt
venv/bin/pytest tests/                      # the FULL suite (~5–6 min)
venv/bin/pytest tests/test_cli_pytest.py    # just the CLI options — fast
venv/bin/pytest -k gate                     # just the admin API rate-gate tests
venv/bin/pytest -k adaptive                 # just the adaptive-polling tests (unit + e2e)
venv/bin/pytest -k outbox                   # just the pending-actions outbox tests
venv/bin/pytest -k degraded                 # just the degraded-state summary tests
venv/bin/pytest tests/test_webui_status_pytest.py   # just the System status view

The suites use ports 18080 (bot) and 18443 (fake Mastodon) and write scratch files to temp-can-be-deleted/ — that folder can be wiped after a run. run_bot.py is the shared subprocess harness; test_config.yaml is its fallback config. For a quick syntax/import check without the suite: venv/bin/python -c "import bot".