Skip to content

Repository files navigation

bouncer

Self-hosted, multi-domain email alias manager on Cloudflare Email Routing.

Register at any site with a per-site alias (foo@bouncer.mx, spotify@mgmail.us, …). Catch-all mail hits a single Cloudflare Worker that logs metadata, rejects retired aliases, flags likely leaks, and forwards to a configurable destination. A Mantine React SPA behind Cloudflare Access lets you see who is emailing which alias and retire aliases that turn spammy.

Live on bouncer.mx (primary) and mgmail.us (migrated off Google Workspace), both forwarding to a personal Gmail.

Architecture

sender ── SMTP ──> Cloudflare Email Routing (catch-all, per zone)
                        │
                        ▼
                  Worker email() handler   (same Worker bound to every zone)
                        │  parse recipient, auto-create alias, log metadata,
                        │  reject-if-retired (550), leak heuristic,
                        │  resolve destination chain -> forward
                        ▼
                  forward to the resolved destination (Gmail)

visitor ────────────────────────────────────────> Worker fetch() ── /  public landing (ASSETS)
owner ──> Cloudflare Access (Google login) ────> Worker fetch()
                                                   ├─ /api/*  accessAuth JWT re-verify -> Hono JSON API + D1
                                                   └─ else    ASSETS binding -> React SPA

One Worker, one D1 database, two entry points (email() and fetch()). The admin UI is a static SPA (ui/, Mantine + Vite) built to ui/dist and served by the Worker's ASSETS binding; /api/* is the JSON control plane. Cloudflare Access fronts the admin surface (/ is a public landing page), and accessAuth re-verifies the Access JWT on every /api path, so a direct route to the Worker cannot bypass it. Message bodies are never stored, only sender / subject / timestamps. The mail path is fail-open: a D1 problem never stops forwarding.

Layout

src/            Worker: email handler, D1 queries, leak heuristic, /api, Access auth
migrations/     versioned D1 schema migrations (wrangler d1 migrations apply)
test/           Worker tests (vitest, @cloudflare/vitest-pool-workers)
ui/             admin SPA (Mantine + Vite + React); @ui/* vendored from mantine-kit
wrangler.jsonc  Worker + assets + D1 + vars

Development

npm install                  # Worker deps
(cd ui && bun install)       # SPA deps (Bun)

npm test                     # Worker tests (Workers pool, local D1)
(cd ui && bun run test)      # SPA tests

npm run migrate:local
(cd ui && bun run build)     # build the SPA into ui/dist
npm run dev                  # wrangler dev: serves SPA + /api; auth bypassed via .dev.vars

wrangler dev serves the built ui/dist, so rebuild the SPA to see UI changes (or run cd ui && bun run dev for the Vite dev server with HMR against a running Worker's /api).

Deploy

npm run deploy               # builds the SPA (build:ui) then `wrangler deploy`

The deploy script builds ui/dist first, so a deploy never ships stale assets. wrangler rollback reverts a bad deploy.

Setup runbook (first-time, per account/domain)

  1. D1: npx wrangler d1 create bouncer, put the database_id in wrangler.jsonc, then npm run migrate:remote.
  2. Deploy + custom domain: npm run deploy; give the Worker a custom domain on your zone (e.g. bouncer.mx). workers_dev stays disabled.
  3. Cloudflare Access: add a self-hosted Access app for that hostname allowing only your Google identity. Put the app AUD in wrangler.jsonc vars.ACCESS_AUD and the team domain in ACCESS_TEAM_DOMAIN (<team>.cloudflareaccess.com), then redeploy.
  4. Fallback destination (strongly recommended): set vars.FALLBACK_FORWARD_TO to a verified inbox. Without it, mail arriving while D1 is unreachable, or before a default destination is set, is temp-failed back to the sender instead of forwarded.
  5. Per domain: enable Email Routing on the zone (accept its MX/SPF; this replaces Google MX when migrating off Workspace), verify each destination inbox, set the catch-all action to the bouncer Worker, then add the domain and set default_forward_to on the Settings page.

Steps 2-5 can also be driven via the Cloudflare API; Email Routing setup needs a token with email_routing:write, and the Access app needs an Access: Apps and Policies token.

Smoke test

Send mail from an external account (not the destination inbox, which loops) to smoketest@<domain>:

  • it lands in the destination inbox (check Spam on a new domain),
  • the alias auto-appears on the Aliases page,
  • the message shows in Activity with disposition forwarded.

Retire smoketest in the UI and send again: the sender gets a 550 bounce and Activity shows a rejected attempt.

Notes

  • Forwarding destination resolves alias override -> domain default -> default_forward_to setting -> FALLBACK_FORWARD_TO env var. Any destination must be a verified address in Cloudflare Email Routing.
  • Plus-addressing normalizes to the base alias (foo+news@d logs under foo, full recipient kept on the message row).
  • ui/src/ui is vendored from mantine-kit; keep it in sync with the kit rather than editing it directly.
  • Design/spec docs live untracked in .local-dev/.

About

Self-hosted email alias manager on Cloudflare Email Routing. Per-site aliases, leak flagging, one-click retirement. Runs my mail.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages