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.
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.
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
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).
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.
- D1:
npx wrangler d1 create bouncer, put thedatabase_idinwrangler.jsonc, thennpm run migrate:remote. - Deploy + custom domain:
npm run deploy; give the Worker a custom domain on your zone (e.g.bouncer.mx).workers_devstays disabled. - Cloudflare Access: add a self-hosted Access app for that hostname allowing only
your Google identity. Put the app AUD in
wrangler.jsoncvars.ACCESS_AUDand the team domain inACCESS_TEAM_DOMAIN(<team>.cloudflareaccess.com), then redeploy. - Fallback destination (strongly recommended): set
vars.FALLBACK_FORWARD_TOto 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. - 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
bouncerWorker, then add the domain and setdefault_forward_toon 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.
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.
- Forwarding destination resolves alias override -> domain default ->
default_forward_tosetting ->FALLBACK_FORWARD_TOenv var. Any destination must be a verified address in Cloudflare Email Routing. - Plus-addressing normalizes to the base alias (
foo+news@dlogs underfoo, full recipient kept on the message row). ui/src/uiis vendored from mantine-kit; keep it in sync with the kit rather than editing it directly.- Design/spec docs live untracked in
.local-dev/.