Skip to content
4msarPublic

About

Temporary email using cloudflare workers!

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Maildrop

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.

Deploy to Cloudflare

                    ┌───────────────────────── 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

Contents

Project layout

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

How it works

API

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.

Durable Object storage schema

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.

Inbound email

  1. The Email Routing catch-all rule sends every message for the domain to the Worker's email() handler.
  2. The handler parses the envelope recipient (message.to). It rejects invalid or reserved usernames, and delivers name+tag@ to name. When MAIL_DOMAIN (or SITE_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.
  3. It rejects early when message.rawSize exceeds MAX_MESSAGE_BYTES (default 10 MiB; Email Routing itself rejects anything over 25 MiB). The stream is also capped while it is read, in case rawSize is wrong.
  4. It asks the Durable Object whether the inbox is active. Unknown or expired inboxes are rejected before the body is read.
  5. 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.
  6. receiveEmail enforces the per-inbox quotas (message count, total bytes, and 30 messages per 10 minutes), then stores everything in one transaction.
  7. Any rejection calls message.setReject(reason), which bounces with a permanent SMTP error.

Lifecycle: heartbeat and 24-hour expiry

  • Open. The inbox page sends a heartbeat immediately. If that returns 404 or 410, the page calls POST /api/inboxes, which runs initialize(): metadata is created with lastPing = now, expiresAt = now + 24h, and a Durable Object alarm is set for expiresAt.
  • 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 = now and expiresAt = 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 with deleteAlarm() + 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. receiveEmail doesn't touch expiresAt, 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/inboxes creates 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.

Front end

  • Random addresses (adjective-noun-1234) or a custom name, checked with the same validator the server uses.
  • One-click copy (Clipboard API with an execCommand fallback).
  • 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 in localStorage.
  • index.html keeps root-relative canonical and Open Graph URLs. For /, /index.html and /inbox/* the Worker runs first (assets.run_worker_first) and rewrites them to absolute URLs on SITE_URL or the request's origin with HTMLRewriter.
  • Plain text is rendered as React text (always escaped).
  • HTML goes through three layers:
    1. DOMPurify strips scripts, handlers, forms, frames, SVG/MathML and srcset.
    2. It renders in an <iframe sandbox="allow-popups allow-popups-to-escape-sandbox">, with no scripts and no same-origin access.
    3. 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.
  • Attachments are addressed by index only, never by a storage path. Only raster images are ever served inline. Everything else is application/octet-stream with Content-Disposition: attachment, nosniff, and CSP: sandbox.

Local development

Requirements: Node.js 20+ (tested with 24) and npm.

npm install
npm run dev            # Vite + the Workers runtime (workerd) on http://localhost:5173

The 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.eml

The 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.jsonc

Tests

npm test

Tests 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): initialize creates metadata and an alarm; heartbeats extend to lastPing + 24h; concurrent heartbeats, deliveries and cron calls; expired inboxes are never revived by a heartbeat or by mail; mail doesn't extend expiresAt; 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 the scheduled() handler.
  • End-to-end (e2e.test.ts): through the Worker's real fetch() and email() handlers. Creates random and custom inboxes; receives, lists, reads, downloads (.eml and attachments) and deletes mail; checks that only images are served inline, +tag delivery, 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, limit validation, 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.

Configuration

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

Deploying

1. Choose the mail domain (read this first)

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.

2. Configure the Worker

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:

  1. Serve the UI on your own host. Uncomment routes in wrangler.jsonc and set <your-host>:

    "routes": [{ "pattern": "mail.example.com", "custom_domain": true }],

    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.

  2. 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 set MAIL_DOMAIN (option A: UI on mail.example.com, mail at example-mail.com). Also set it if the UI stays reachable on workers.dev, so addresses shown there are correct.

  3. Optionally set SITE_URL to 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.com

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

3. Deploy the Worker

npx wrangler login
npm run deploy         # vite build + wrangler deploy

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

4. Turn on Email Routing for the mail domain

In the Cloudflare dashboard, select the mail domain's zone:

  1. 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.
  2. Open Routing rules › Catch-all address, set the action to Send to a Worker, choose the maildrop Worker, and save. Make sure the rule is enabled.
  3. Optionally add literal rules that forward postmaster@ and abuse@ to a real mailbox. The Worker rejects those names because they are reserved, and literal rules take priority over the catch-all.

4b. Optional: per-address routing rules (needed on a subdomain)

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 › Edit

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

5. Verify

curl https://<your-host>/api/health
curl https://<your-host>/api/config   # "domain" is the mail domain addresses will use

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

Manual cleanup

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

Monitoring

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.

Limits, pricing and capacity

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.

Security model and trade-offs

  • 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 like test will 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_TOKEN only needs Email Routing Rules › Edit on one zone and never leaves the Worker. POST /api/admin/cleanup is disabled unless ADMIN_TOKEN is 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, and index.html is no-cache.

Is it production-ready?

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:

  1. 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.
  2. 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.
  3. Decide on abuse handling: Turnstile, an abuse address, and a takedown procedure.
  4. Watch cron.cleanup for a few days. expired should stay near 0 because alarms do the work. A rising failed count means a real problem.
  5. 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_SHARDS or CRON_BATCH_PER_SHARD if cron.cleanup stays 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.

About

Temporary email using cloudflare workers!

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages