Maildrop is a disposable, public email inbox service built on Cloudflare: open any address at your own mail domain (or generate a random one) and read incoming mail instantly, with no signup. Each inbox lives in its own SQLite-backed Durable Object, receives mail through Email Routing (optionally creating a per-address rule automatically), and is deleted after 24 hours without a visit or whenever you delete it yourself. The React front end sanitizes and sandboxes HTML email, and it alerts you to new messages with an unread count in the tab title, a sound and optional browser notifications.
Source: github.com/4msar/maildrop
Nothing in the code is tied to one deployment: the mail domain and public URL come from the
request's host unless you set them (see Configuration), so a fork deploys
as-is to workers.dev.
Stack: React + Vite + Tailwind front end, Hono API on Workers, Durable Objects for storage. There is no database, bucket or third-party mail service: every inbox's state lives in its own Durable Object.
┌───────────────────────── Cloudflare ─────────────────────────┐
SMTP sender ──MX──►│ Email Routing (catch-all or per-address rules → Worker) │
│ │ │
│ ▼ email(message) │
Browser ──HTTPS───►│ Worker ─ fetch: /api/* → Hono ─┐ │
(React SPA) │ ─ static assets (SPA) │ RPC: getByName(username) │
│ ─ scheduled: cron sweep ▼ │
│ InboxDurableObject (one per inbox) │
│ SQLite: meta, messages, bodies, │
│ raw_chunks + alarm │
│ InboxRegistry ×8 shards (cron index) │
└───────────────┬──────────────────────────────────────────────┘
│ optional (ZONE_ID + CF_TOKEN)
▼
Cloudflare API: create / check / delete Email Routing rules
- Project layout
- How it works
- Local development
- Tests
- Configuration
- Deploying
- Limits, pricing and capacity
- Security model and trade-offs
- Is it production-ready?
maildrop/
├── wrangler.jsonc Worker, assets, Durable Objects, cron, rate limits, vars
│ (custom domain and MAIL_DOMAIN/SITE_URL are commented examples)
├── worker-configuration.d.ts Generated by `wrangler types` (Env + runtime types)
├── vite.config.ts React + Tailwind + @cloudflare/vite-plugin
├── vitest.config.ts @cloudflare/vitest-plugin (tests run inside workerd)
├── index.html
├── public/
│ ├── _headers CSP and security headers for static assets
│ └── favicon.svg
├── shared/ Code used by both the Worker and the browser
│ ├── api-types.ts API request/response types
│ └── username.ts Username validation, reserved names, recipient parsing
├── .dev.vars.example Optional local vars/secrets: MAIL_DOMAIN, SITE_URL, ZONE_ID, CF_TOKEN, ADMIN_TOKEN
├── worker/
│ ├── index.ts Entry point: fetch (API + HTML pages), email, scheduled; exports DO classes
│ ├── app.ts Hono routes, rate limiting, security headers
│ ├── admin.ts Manual cleanup (POST /api/admin/cleanup)
│ ├── email.ts Inbound mail handler (Email Routing → Durable Object)
│ ├── env.d.ts Types for optional vars/secrets not declared in wrangler.jsonc
│ ├── inbox.ts InboxDurableObject (storage, lifecycle, messages, routing rule)
│ ├── registry.ts InboxRegistry (sharded index for the backup cron)
│ ├── scheduled.ts Backup cleanup sweep
│ └── lib/
│ ├── config.ts TTLs, size limits, mail domain and site origin resolution
│ ├── html.ts Fills in absolute canonical/Open Graph URLs in index.html
│ ├── ids.ts Message id generation and validation
│ ├── log.ts Structured JSON logging (no message content)
│ ├── mime.ts postal-mime parsing, truncation, attachment extraction
│ └── routing.ts Cloudflare API client for Email Routing rules
├── src/ React app
│ ├── App.tsx, main.tsx, index.css
│ ├── hooks/
│ │ ├── useInbox.ts Open, heartbeat, polling, routing status, delete
│ │ └── useMailAlerts.ts New-mail sound, notifications, unread count
│ ├── lib/ API client, router, formatters, chime, localStorage helpers
│ └── components/ HomePage, InboxPage, MessageList, MessageView, RoutingBadge,
│ HtmlPreview (sanitized + sandboxed), CopyButton, …
├── test/
│ ├── unit.test.ts Usernames, ids, MIME helpers, sharding
│ ├── lifecycle.test.ts Heartbeats, concurrency, alarm expiry, cron
│ ├── e2e.test.ts Full API + email handler flows, inbox deletion
│ ├── routing.test.ts Email Routing client (against a fake API)
│ ├── admin.test.ts Admin cleanup: auth, dry run, batching
│ └── helpers.ts
└── scripts/sample.eml Sample message for local testing
All routes are under /api and return Cache-Control: no-store.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/inboxes |
Create or open an inbox. Body {"username": "hello"}, or {} for a random name. Also creates the address's Email Routing rule when ZONE_ID and CF_TOKEN are set, and returns its state as routing. |
GET |
/api/inboxes/:username |
Inbox info (does not extend expiry). |
DELETE |
/api/inboxes/:username |
Delete the inbox now: messages, alarm, registry entry and its Email Routing rule. Mail to it bounces until it is opened again. |
GET |
/api/inboxes/:username/routing |
Email Routing rule state: enabled, disabled, not_configured or unknown. |
POST |
/api/inboxes/:username/heartbeat |
Extend expiry to now + 24 h. 404/410 if it doesn't exist or has expired. |
GET |
/api/inboxes/:username/messages |
List messages (newest first). |
GET |
/api/inboxes/:username/messages/:id |
Message detail: headers, text, HTML, attachment list. |
GET |
/api/inboxes/:username/messages/:id/raw |
Original .eml (always a download). |
GET |
/api/inboxes/:username/messages/:id/attachments/:index |
One attachment. ?inline=1 displays PNG/JPEG/GIF/WebP inline; all other types download. |
DELETE |
/api/inboxes/:username/messages/:id |
Delete a message. |
POST |
/api/admin/cleanup |
Admin only (Authorization: Bearer $ADMIN_TOKEN). Deletes every inbox and every literal routing rule for the mail domain. See Manual cleanup. |
GET |
/api/config, /api/health |
Mail domain and limits; liveness check. |
Each inbox is env.INBOX.getByName(username). Its private SQLite database holds:
| Table | Contents |
|---|---|
meta (1 row) |
username, created_at, last_ping, expires_at, message_count, total_bytes, plus a fixed-window inbound rate counter |
messages |
One row per message: envelope and header addresses, subject, preview, size, attachment metadata (JSON), a capped header list (JSON), flags for truncated or malformed content |
bodies |
Parsed text and html bodies, one row each, capped at 1.5 MB |
raw_chunks |
The original MIME message split into 1 MiB BLOB rows (message_id, seq) |
Why this layout. SQLite-backed Durable Objects cap each row, string or BLOB at 2 MB,
so the raw .eml is chunked at 1 MiB and every body is stored in its own row. Listing reads
only the small messages columns, so polling never touches large values. Opening a message
reads one messages row and up to two bodies rows. Attachments are not stored a second
time: they are extracted from the raw chunks on demand, which keeps storage at roughly
1× the message size plus its parsed bodies, at the cost of re-parsing when someone downloads
an attachment. All writes for one message happen in a single transactionSync, so a failure
can never leave a half-stored message.
The schema is created lazily on the first initialize(). Mail sent to an address nobody has
opened is rejected without creating any storage.
- The Email Routing catch-all rule sends every message for the domain to the Worker's
email()handler. - The handler parses the envelope recipient (
message.to). It rejects invalid or reserved usernames, and deliversname+tag@toname. WhenMAIL_DOMAIN(orSITE_URL) is set it also rejects other domains; when neither is set it accepts any domain Email Routing hands it, since only zones whose rules point at this Worker can reach it. - It rejects early when
message.rawSizeexceedsMAX_MESSAGE_BYTES(default 10 MiB; Email Routing itself rejects anything over 25 MiB). The stream is also capped while it is read, in caserawSizeis wrong. - It asks the Durable Object whether the inbox is active. Unknown or expired inboxes are rejected before the body is read.
- postal-mime parses the message, with caps on nesting depth, part count and header size.
If MIME parsing fails, the raw message is still stored and flagged
malformed. receiveEmailenforces the per-inbox quotas (message count, total bytes, and 30 messages per 10 minutes), then stores everything in one transaction.- Any rejection calls
message.setReject(reason), which bounces with a permanent SMTP error.
- Open. The inbox page sends a heartbeat immediately. If that returns
404or410, the page callsPOST /api/inboxes, which runsinitialize(): metadata is created withlastPing = now,expiresAt = now + 24h, and a Durable Object alarm is set forexpiresAt. - Heartbeat. The page then sends a heartbeat every hour, plus one when a hidden tab becomes
visible again if the last one is over an hour old (browsers throttle background timers).
Each heartbeat sets
lastPing = nowandexpiresAt = lastPing + 24h. - Expiry. When the alarm fires, the object re-reads
expiresAt. If a heartbeat has moved it forward, the alarm is rescheduled. Otherwise the inbox is purged withdeleteAlarm()+deleteAll(), which removes all metadata, messages and chunks in one atomic operation. - Lazy expiry. Every operation (heartbeat, list, read, incoming email) first checks
expiresAt. An overdue inbox is purged on the spot, so expired content is never served even if the alarm is late. - Email never extends lifetime.
receiveEmaildoesn't touchexpiresAt, and it rejects mail for expired or missing inboxes rather than recreating them. - Deleting. The inbox page has a Delete button (with confirmation) that purges the inbox immediately, the same way expiry does.
- Reopening an expired or deleted address with
POST /api/inboxescreates a fresh, empty inbox.
Concurrency. A Durable Object runs one event at a time, and every SQL call here is
synchronous, so heartbeats, deliveries, alarms and cron calls on the same inbox are fully
serialized. The purge runs inside blockConcurrencyWhile and checks expiresAt again
before deleting. A heartbeat that was processed first always wins, and nothing can run
between that check and deleteAll(). lifecycle.test.ts covers this: it runs 25 concurrent
heartbeats, 4 deliveries and 10 cron checks against one inbox, and also fires an alarm
after a late heartbeat.
Why cleanup survives restarts. Durable Object alarms are stored with the object, not in
the Worker's memory. They fire even if no Worker or object instance is running at that time.
If a handler throws, the runtime retries it with exponential backoff (up to 6 retries).
Nothing about expiry is kept in memory: expiresAt and the alarm are both persisted.
Backup cron (17 * * * *). initialize() also records the inbox in one of 8
InboxRegistry shards, along with a "check after" time. Each hour the cron asks each shard for
up to 100 entries that are due, and calls cleanupExpired() on each inbox. That call is the
authoritative check: it purges the inbox if it's expired, or re-arms a missing alarm and
returns the new expiresAt for the registry. Heartbeats never write to the registry, so it
doesn't become a hot spot. This covers alarms lost to bugs or exhausted retries.
- Random addresses (
adjective-noun-1234) or a custom name, checked with the same validator the server uses. - One-click copy (Clipboard API with an
execCommandfallback). - Polls every 8 s while the tab is visible and every 60 s while hidden; manual refresh button.
- Loading skeletons, empty ("waiting for mail"), error-with-retry and expired states.
- A badge shows the address's Email Routing rule: Receiving enabled, Routing disabled or Routing not configured.
- Delete (with an inline confirmation) removes the inbox, its messages and its routing rule immediately.
- New-mail alerts: the tab title shows the unread count (
(2) name@… · Maildrop), unread messages get a dot, a short chime plays (Web Audio, no file; toggle with the speaker button), and optional desktop notifications (bell button) appear while the tab is in the background. Messages already present when the page loads never alert. Read state and alert preferences are kept inlocalStorage. index.htmlkeeps root-relative canonical and Open Graph URLs. For/,/index.htmland/inbox/*the Worker runs first (assets.run_worker_first) and rewrites them to absolute URLs onSITE_URLor the request's origin withHTMLRewriter.- Plain text is rendered as React text (always escaped).
- HTML goes through three layers:
- DOMPurify strips scripts, handlers, forms, frames, SVG/MathML and
srcset. - It renders in an
<iframe sandbox="allow-popups allow-popups-to-escape-sandbox">, with no scripts and no same-origin access. - A CSP inside the document blocks all network requests except
cid:images, which are rewritten to this origin's attachment URLs. Remote images and styles load only after the user clicks Load remote content.
- DOMPurify strips scripts, handlers, forms, frames, SVG/MathML and
- Attachments are addressed by index only, never by a storage path. Only raster images are
ever served inline. Everything else is
application/octet-streamwithContent-Disposition: attachment,nosniff, andCSP: sandbox.
Requirements: Node.js 20+ (tested with 24) and npm.
npm install
npm run dev # Vite + the Workers runtime (workerd) on http://localhost:5173The Cloudflare Vite plugin runs the Worker, both Durable Objects, alarms and the rate
limiters locally. Local storage lives in .wrangler/state. No configuration is needed: the
UI shows addresses at the host you opened it on (localhost). To use other values locally,
copy .dev.vars.example to .dev.vars (see Configuration).
Send a test email (open the inbox in the UI first, otherwise the message is rejected):
curl -X POST 'http://localhost:5173/cdn-cgi/local/email' \
--url-query 'from=sender@example.com' \
--url-query 'to=smoke-test@localhost' \
--data-binary @scripts/sample.emlThe message must include a Message-ID header. Trigger the cron sweep with
curl 'http://localhost:5173/cdn-cgi/handler/scheduled'.
Other scripts:
npm run typecheck # app, worker, vite config and tests
npm run lint # oxlint
npm run build # production build into dist/
npm run cf-typegen # regenerate worker-configuration.d.ts after editing wrangler.jsoncnpm testTests run inside the real Workers runtime through @cloudflare/vitest-plugin, with
MAX_MESSAGE_BYTES=1 MiB and MAX_MESSAGES_PER_INBOX=5 so limit cases run quickly.
- Unit (
unit.test.ts): username rules and reserved names, recipient parsing and+tag, message id format and path-injection rejection, UTF-8-safe truncation, filename sanitizing, MIME parsing, attachment extraction, malformed input. - Lifecycle (
lifecycle.test.ts):initializecreates metadata and an alarm; heartbeats extend tolastPing + 24h; concurrent heartbeats, deliveries and cron calls; expired inboxes are never revived by a heartbeat or by mail; mail doesn't extendexpiresAt; the alarm purges everything; lazy expiry before the alarm runs; the alarm reschedules after a late heartbeat; the cron purges an inbox whose alarm was lost; and thescheduled()handler. - End-to-end (
e2e.test.ts): through the Worker's realfetch()andemail()handlers. Creates random and custom inboxes; receives, lists, reads, downloads (.emland attachments) and deletes mail; checks that only images are served inline,+tagdelivery, rejection of unknown, invalid, reserved and foreign recipients, oversized messages (declared and actual size), storage of malformed MIME, byte-exact reassembly of multi-chunk messages, the quota, path traversal, cross-inbox reads and security headers; deleting an inbox and reopening it. - Routing (
routing.test.ts): the Email Routing client against an in-memory fake of the Cloudflare API (create, reuse an existing rule, pagination, delete, errors), and the "not configured" path. - Admin (
admin.test.ts): token checks,limitvalidation, dry run, batched wiping.
vitest.config.ts sets MAIL_DOMAIN=mail.example.com and blanks ZONE_ID and CF_TOKEN, so
tests never call the real Cloudflare API even when .dev.vars holds real credentials.
The browser UI is not covered by automated tests. It was checked manually against npm run dev.
Every setting is optional. Plain settings go in vars in wrangler.jsonc (or
npx wrangler deploy --var NAME:value); secrets go in with npx wrangler secret put NAME.
Locally, put any of them in .dev.vars (copy .dev.vars.example).
| Name | Kind | Default | Purpose |
|---|---|---|---|
MAIL_DOMAIN |
var | host of SITE_URL, then the request's host |
Domain inboxes receive mail at, e.g. mail.example.com. Set it when the UI and mail domains differ, or the UI is also reachable on workers.dev. |
SITE_URL |
var | the request's origin | Public origin for canonical and Open Graph URLs, e.g. https://mail.example.com. |
EMAIL_WORKER_NAME |
var | maildrop |
Worker that per-address routing rules send mail to. Must match name in wrangler.jsonc. |
MAX_MESSAGE_BYTES |
var | 10485760 (10 MiB, capped at 25 MiB) |
Largest accepted message. |
MAX_INBOX_BYTES |
var | 52428800 (50 MiB) |
Storage quota per inbox. |
MAX_MESSAGES_PER_INBOX |
var | 100 |
Message quota per inbox. |
ZONE_ID, CF_TOKEN |
secret | unset (off) | Create a per-address Email Routing rule for each inbox. See step 4b. |
ADMIN_TOKEN |
secret | unset (off) | Enables POST /api/admin/cleanup. See Manual cleanup. |
wrangler deploy replaces the Worker's plain vars with the ones in wrangler.jsonc, so a var
set only in the dashboard is removed on the next deploy. Keep it in wrangler.jsonc, pass it
with --var, or store it as a secret instead (npx wrangler secret put MAIL_DOMAIN works; it
is just a string).
Email Routing only allows catch-all rules on a zone's apex domain. On a subdomain such as
mail.example.com, Cloudflare lets you add Email Routing, but only literal per-address rules
(at most 200 per domain). That can't support arbitrary disposable addresses. Pick one of these:
| Option | What you do | Trade-off |
|---|---|---|
| A. Use a dedicated apex domain (recommended) | Register a cheap domain (for example example-mail.com), add it as a zone, and use it as the mail domain. The UI can live on another host. |
Costs a domain. Keeps disposable mail isolated from your main domain's reputation. |
| B. Use your main domain itself | Use example.com as the mail domain and point its apex catch-all at the Worker. |
Every address at example.com that has no explicit rule becomes a public inbox. Don't do this if you receive real mail at example.com. |
| C. Make the subdomain its own zone | Delegate mail.example.com as a separate zone, so it becomes that zone's apex. |
Needs subdomain zone support on your Cloudflare plan. Check with Cloudflare before relying on it. |
| D. Stay on a subdomain with per-address rules | Use mail.example.com and turn on per-address rules. |
At most 200 inboxes open at once. |
Out of the box wrangler.jsonc has no routes, so the Worker deploys to
https://maildrop.<your-subdomain>.workers.dev. A workers.dev host can't receive mail, so for
real use:
-
Serve the UI on your own host. Uncomment
routesinwrangler.jsoncand set<your-host>:The host's zone must be on the same Cloudflare account. Wrangler creates the DNS record; the deploy fails if a conflicting A, AAAA or CNAME record already exists there.
-
Set the mail domain if it differs from the UI host. If the UI is served on the mail domain itself (for example both are
mail.example.com), nothing else is needed: addresses use the request's host. Otherwise setMAIL_DOMAIN(option A: UI onmail.example.com, mail atexample-mail.com). Also set it if the UI stays reachable onworkers.dev, so addresses shown there are correct. -
Optionally set
SITE_URLto pin canonical and Open Graph URLs to one origin.
To keep your own values out of a fork's wrangler.jsonc, pass them at deploy time instead:
npm run deploy -- --var MAIL_DOMAIN:example-mail.com --var SITE_URL:https://mail.example.comThe rate limiter namespace_id values (71001, 71002) must be unique within your account.
Change them if they collide with another Worker's. If you rename the Worker (name), set
EMAIL_WORKER_NAME to the same value.
npx wrangler login
npm run deploy # vite build + wrangler deployThis creates both Durable Object classes (migration v1, new_sqlite_classes), the cron
trigger and the rate limiters, and attaches the custom domain if you configured one. Check the
result with npx wrangler deploy --dry-run first if you like: it lists every binding and var
without uploading anything.
The Deploy to Cloudflare button does the same from the upstream repository. In
a fork, change its ?url= to your fork's URL.
In the Cloudflare dashboard, select the mail domain's zone:
- Go to Compute › Email Service › Email Routing (the menu has moved before; search for "Email Routing" if it isn't there) and enable it. Let Cloudflare add the MX and SPF records it proposes. Remove any other MX records on that domain.
- Open Routing rules › Catch-all address, set the action to Send to a Worker,
choose the
maildropWorker, and save. Make sure the rule is enabled. - Optionally add literal rules that forward
postmaster@andabuse@to a real mailbox. The Worker rejects those names because they are reserved, and literal rules take priority over the catch-all.
Instead of a catch-all, the Worker can create one literal rule per inbox. Set two secrets:
npx wrangler secret put ZONE_ID # zone id of the mail domain (Overview page, right column)
npx wrangler secret put CF_TOKEN # API token with Zone › Email Routing Rules › EditWhen both are set, POST /api/inboxes finds or creates a rule <name>@<mail domain> → Send to Worker (worker name from EMAIL_WORKER_NAME, default maildrop), and the rule is deleted
when the inbox expires. When either is missing, inboxes are simply opened and mail relies on
the catch-all. A failed API call never blocks opening an inbox. The inbox page shows a badge:
Receiving enabled, Routing disabled (the rule is off in the dashboard), or Routing
not configured. Email Routing allows at most 200 rules per zone, so this caps the number
of inboxes that can be open at once. Locally, copy .dev.vars.example to .dev.vars. With
real credentials there, npm run dev creates real rules in your zone.
curl https://<your-host>/api/health
curl https://<your-host>/api/config # "domain" is the mail domain addresses will useOpen an inbox in the browser, send it an email from any provider, and watch it arrive.
Use npx wrangler tail or Workers & Pages › maildrop › Logs to see email.stored and
email.rejected events.
POST /api/admin/cleanup deletes every literal Email Routing rule for an address at the
mail domain, plus every inbox: all inboxes in the registry and any inbox a rule names. Rules
for reserved names such as postmaster@ and abuse@, and the catch-all, are left alone. The
route is off (404) until you set a token:
npx wrangler secret put ADMIN_TOKEN # e.g. the output of `openssl rand -hex 32`Preview first, then run it. Each call handles at most limit rules and inboxes (default 40,
max 500) to stay under the Workers subrequest limit, so repeat it until the response says
"done": true:
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "https://<your-host>/api/admin/cleanup?dry_run=1"
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "https://<your-host>/api/admin/cleanup"The response lists the counts found, deleted and failed, the addresses and usernames in this
batch, and any skipped reserved addresses. Rules are only seen when ZONE_ID and
CF_TOKEN are set (routingConfigured).
Workers Observability is enabled (observability.enabled). Every log line is JSON with an
event field. Useful ones are email.stored, email.rejected (with reason),
email.failed, email.malformed_mime, inbox.created, inbox.expired (with trigger),
inbox.deleted, inbox.store_failed, cron.cleanup (checked, expired, failed),
routing.rule_ready, routing.ensure_failed, routing.delete_failed, admin.cleanup,
http.error and http.unhandled. Logs carry ids, sizes, sender domains and error codes only, never
subjects, bodies or full addresses. Rate limiter rejections are not shown in the dashboard,
so alert on 429 responses in Workers Logs if you need that.
Figures were taken from Cloudflare's docs on 2026-10-11. Re-check them before relying on them.
Durable Objects (SQLite backend)
| Limit | Value |
|---|---|
| Max row / string / BLOB size | 2 MB (the reason for 1 MiB raw chunks) |
| Storage per object | 10 GB (Paid) |
| Storage per account | 5 GB (Free), unlimited (Paid) |
| CPU per request | 30 s default (configurable up to 5 min) |
| Requests per object | soft limit of about 1,000/s |
| Alarms | one per object; at-least-once; 15-minute wall-time limit per invocation; up to 6 retries with backoff |
| Pricing | Free | Paid |
|---|---|---|
| Requests | 100,000/day | 1M/month included, then $0.15/M |
| Duration | 13,000 GB-s/day | 400,000 GB-s/month, then $12.50/M GB-s |
| Rows read | 5M/day | 25B/month, then $0.001/M |
| Rows written | 100,000/day | 50M/month, then $1.00/M |
| Stored data | 5 GB total | 5 GB-month, then $0.20/GB-month |
Every setAlarm() is billed as one row written, and each alarm invocation counts as a request.
Email Routing: inbound messages up to 25 MiB; 200 rules per domain; catch-all on apex
domains only. Email Workers use the normal Workers CPU and memory limits. On the Free plan,
parsing large messages may exceed the CPU limit (EXCEEDED_CPU).
Rate limiting binding: periods of 10 s or 60 s only; counts are per Cloudflare location and eventually consistent ("not an accurate accounting system").
What this means in practice
- A small email costs about 5–8 rows written: the message row and its index, up to 2 body rows, 1+ raw chunks, and the meta update. Free covers roughly 12–20k emails/day before rows-written runs out; Paid covers millions per month.
- Polling is the main request cost. Each open, visible tab makes about 450 Durable Object requests per hour. The Free plan's 100k requests/day is about 220 tab-hours. On Paid, 1,000 tabs open 8 hours a day is about 108M Durable Object requests/month (about $16), plus the matching Worker requests at Workers Paid rates. WebSocket hibernation would cut this a lot (see below).
- Defaults: 10 MiB per message, 100 messages or 50 MiB per inbox, and 30 inbound messages
per 10 minutes per inbox. Change them in
vars.
- Inboxes are public. Knowing the name is the only access control, as on maildrop.cc.
Random names (
adjective-noun-NNNN, about 6 million combinations) can be guessed by anyone who tries. Custom names liketestwill be shared by many people. The UI says so on every page. - Anyone who knows an address can delete its messages or keep it alive by sending heartbeats. That follows from having no accounts.
- Server-side validation. Usernames must match
^[a-z0-9](?:[a-z0-9]|[._-](?=[a-z0-9]))*$, be 3–32 characters, and not be a reserved name. Message ids must be exactly 26 characters[0-9a-z]{10}[0-9a-f]{16}, and attachment indexes must be 1–3 digits. Path characters can never reach storage. Durable Object routing is by name, and every method also checks the stored username, so one inbox can't read another's data. - Rate limits. 300 API requests and 10 inbox creations per minute, keyed by client IP. People behind NAT or a VPN share that budget. The limiter is approximate by design, so it won't stop a distributed abuser. Consider Turnstile on inbox creation, or WAF rate-limiting rules, if abuse appears.
- Bounces.
setReject()returns a permanent SMTP error, so senders don't retry. That includes the rare "temporary storage problem" case. - Spam and abuse. There is no spam filtering. Mail is accepted only for inboxes someone has opened, and per-inbox quotas cap the damage. Disposable domains are commonly blocklisted by sign-up forms, and a public inbox can receive illegal content. Plan an abuse contact and takedown process. This is one more reason to use a dedicated domain (option A).
- HTML email. Sanitized, sandboxed, isolated by CSP, and with remote content blocked by default. "Load remote content" reveals the viewer's IP to the sender's tracking servers, as in any mail client.
- Secrets.
CF_TOKENonly needs Email Routing Rules › Edit on one zone and never leaves the Worker.POST /api/admin/cleanupis disabled unlessADMIN_TOKENis set, and the token is compared in constant time. Use a long random value. - Caching. API responses are
no-store. Hashed assets are cached as immutable, andindex.htmlisno-cache.
The code is complete and tested. It runs inside the Workers runtime locally, and
wrangler deploy --dry-run resolves every binding. It has not yet been deployed to a real
account, and real SMTP delivery has not been tested. Before calling it production-ready:
- Settle the mail-domain question (above). A catch-all on a subdomain does not work; either use an apex domain or enable per-address rules (step 3b), which cap open inboxes at 200 per zone.
- Run a real deliverability test from Gmail and Outlook, including an attachment close to 10 MiB, to confirm parse time stays within your plan's CPU limit.
- Decide on abuse handling: Turnstile, an abuse address, and a takedown procedure.
- Watch
cron.cleanupfor a few days.expiredshould stay near 0 because alarms do the work. A risingfailedcount means a real problem. - Limits to know about at scale:
- The registry is split into 8 shards, and the cron visits up to 800 due inboxes per hour.
Raise
REGISTRY_SHARDSorCRON_BATCH_PER_SHARDifcron.cleanupstays busy. This only affects the backup path. - Polling could be replaced with WebSocket hibernation to cut request costs.
- Attachments are re-parsed from the raw message on every download.
- The registry is split into 8 shards, and the cron visits up to 800 due inboxes per hour.
Raise