self-hosted mail
from clone to a domain that burns mail
MailIndia has no SMTP server of its own. Cloudflare's edge terminates mail for your zone, hands each message to a Worker, and the Worker writes it to a Durable Object that sweeps its own rows. The API is a thin, documented read surface on top.
no dns required
Run it in five commands
install + start
npm install npm run api:dev # express api on :8790, in-memory store npm run web:dev # next.js on :3000
With MX_WORKER_URL unset the API keeps messages in process memory and GET /v1/health honestly reports degraded. Mail cannot arrive from the outside, so POST /v1/dev/inject exists to deliver a synthetic message to a mailbox you own — which is what the “inject a demo message” button on the inbox calls.
inject without the ui
API=http://localhost:8790
JSON=$(curl -s -X POST "$API/v1/mailboxes")
ADDR=$(echo "$JSON" | jq -r .data.address)
TOK=$(echo "$JSON" | jq -r .data.token)
curl -s -X POST "$API/v1/dev/inject" \
-H 'content-type: application/json' \
-H "authorization: Bearer $TOK" \
-d "{\"address\":\"$ADDR\",\"subject\":\"ping\",\"text\":\"hello\"}"The injection endpoint is refused when NODE_ENV=production unless ALLOW_DEV_INJECT=1, and it always requires the mailbox token.
Deploy the receiver worker
The Worker is the only component that can accept SMTP, and Cloudflare's edge does that for free. One Durable Object is created per mailbox address and keeps its own SQLite rows, so storage and expiry live next to the mail.
workers/mx
cd workers/mx && npm install npx wrangler login
secret + deploy
npx wrangler secret put MAILINDIA_API_TOKEN # random 32+ chars npx wrangler deploy
verify
curl -s https://mailindia-mx.<account>.workers.dev/health
# {"status":"ok","service":"mailindia-mx","domains":["yourdomain.com"]}Set MAILINDIA_DOMAINS in wrangler.jsonc (comma separated) before deploying; the worker rejects any address outside that list.
Turn on Email Routing
In the Cloudflare dashboard open your zone → Email → Email Routing → Enable. Pick Send to a Worker as the action and choose mailindia-mx, then set the catch-all rule to all addresses.
Cloudflare only lists a worker as an action target after its first successful upload. If the catch-all is missing, deploy once more and re-open the panel.
Publish the DNS records
Email Routing can write these for you with “Configure DNS records → Modify records”. If you add them by hand for zone yourdomain.com, they are:
| type | name | prio | target | note |
|---|---|---|---|---|
| MX | @ | 64 | route1.mx.cloudflare.net | DNS only (grey cloud) |
| MX | @ | 65 | route2.mx.cloudflare.net | DNS only (grey cloud) |
| MX | @ | 66 | route3.mx.cloudflare.net | DNS only (grey cloud) |
| TXT | @ | — | "v=spf1 include:_spf.mx.cloudflare.net ~all" | SPF |
| TXT | _dmarc | — | v=DMARC1; p=none; rua=mailto@dmarc@yourdomain.com | start reporting only |
check
dig MX yourdomain.com # route1/2/3.mx.cloudflare.net, priorities 64/65/66
MX records must be grey-cloud (DNS only). An orange-clouded MX does nothing, and any leftover MX from another provider will swallow your mail first.
Point the API at the worker
The Express API talks to the worker over its internal HTTP surface. MX_WORKER_TOKEN must equal the MAILINDIA_API_TOKEN secret you uploaded, and MAILINDIA_TOKEN_SECRET must be identical everywhere it is set — the mailbox token is an HMAC of the address, so a changed secret invalidates every existing token.
.env for apps/api
MAILINDIA_DOMAINS=yourdomain.com MAILINDIA_TOKEN_SECRET=<stable 32+ chars> MX_WORKER_URL=https://mailindia-mx.<account>.workers.dev MX_WORKER_TOKEN=<same value as the worker secret> MONGODB_URI= # optional: only for mik_ api keys
start
npm run api:dev # store: durable · mailindia.example.com
MONGODB_URI is optional. Without it the key endpoints answer 503 keys_unavailable and everything else works.
Prove it end to end
Mint an address on your own domain, send yourself a message from any other account, then read it back through the documented API.
mint
API=http://localhost:8790
JSON=$(curl -s -X POST "$API/v1/mailboxes" -H 'content-type: application/json' -d '{"address":"hello","ttlSeconds":600}')
echo "$JSON" | jq -r .data.address
echo "$JSON" | jq -r .data.tokenlist
ADDR=hello@yourdomain.com TOK=<token from the mint> curl -s "$API/v1/mailboxes/$ADDR/messages" -H "authorization: Bearer $TOK" | jq '.data[].subject'
The API's POST /v1/dev/inject does the same thing without DNS — it is how this site demos itself.
Environment
MAILINDIA_DOMAINS
mailindia.example.com
comma separated domains the service accepts
MAILINDIA_TOKEN_SECRET
32+ random chars
HMAC key for mailbox tokens — keep it stable
MX_WORKER_URL
(unset in dev)
unset ⇒ in-memory store, /v1/health reports degraded
MX_WORKER_TOKEN
(unset in dev)
must equal the worker's MAILINDIA_API_TOKEN
MONGODB_URI
(optional)
api keys + mailbox registry; never message bodies
MAILINDIA_ADMIN_TOKEN
(optional)
enables /v1/key management
NEXT_PUBLIC_API_URL
http://localhost:8790
where the web app sends fetches
Everything is read once at boot with safe defaults, and .env.example documents all of it. To receive on more than one domain, add it to MAILINDIA_DOMAINS in both the worker and the API, then enable that zone in Email Routing.
When mail does not arrive
- dig MX yourdomain.com — if you see another provider's hosts, delete those records; MX is zone-wide, not per-name.
- MX records must be grey-cloud. Cloudflare's proxy does not carry SMTP.
- The worker's /domains endpoint must list the domain, or the address is rejected before storage.
- Email Routing must show Live for the zone, and the catch-all rule must target mailindia-mx.
- MAILINDIA_TOKEN_SECRET must match wherever the API runs — multiple API instances with different secrets produce different tokens for the same address.
- A mailbox deleted for inactivity returns 404 until it is minted again; the token is deterministic, so the address simply comes back.