mailindia

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.

sender MTA ──MX──▶ Cloudflare Edge ──Email Routing──▶ mailindia-mx Worker │ Durable Object per address ▼ (SQLite, TTL sweep) MailIndia API ──▶ your browser / your app

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.

01

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.

02

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.

03

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:

typenamepriotargetnote
MX@64route1.mx.cloudflare.netDNS only (grey cloud)
MX@65route2.mx.cloudflare.netDNS only (grey cloud)
MX@66route3.mx.cloudflare.netDNS only (grey cloud)
TXT@—"v=spf1 include:_spf.mx.cloudflare.net ~all"SPF
TXT_dmarc—v=DMARC1; p=none; rua=mailto@dmarc@yourdomain.comstart 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.

04

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.

05

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.token

list

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.