tenmin.app
Guide

How tenmin.app works under the hood — and why it costs $30/year to run

Last updated · 2026-05-29
·14 min read

tenmin.app is small enough that you can hold the entire system in your head at once. Two Cloudflare Workers, one KV namespace, a Pages site, and a domain-level email-routing rule. Total ongoing cost: about thirty dollars a year — almost all of it the domain renewal, because everything else runs inside Cloudflare's free tier at our traffic levels.

This guide is the architectural tour: what each piece does, why we picked it, and the design choices that let the whole thing stay this cheap. If you're considering building something similar — a disposable inbox of your own, a test receiver for your CI pipeline, a one-off "I need an address at my domain that auto-deletes" tool — most of the shape translates directly.

The shape, in one diagram

Sender's mail server
       │
       ▼
Cloudflare Email Routing  (catch-all *@tenmin.app)
       │
       ▼
Worker:  tenmin-email-processor
   • parses MIME with postal-mime
   • extracts local-part as inbox ID
   • validates against [a-z0-9._-]{1,64}
   • writes to KV with expirationTtl: 600
       │
       ▼
Cloudflare KV  (binding INBOX_KV)
   key:   inbox:<id>
   value: newest-first JSON array of messages
   ttl:   600 seconds, refreshed on every write
       ▲
       │
Worker:  tenmin-api  (Hono)
   • GET /api/inbox/:id  → JSON
   • CORS allowlist (no wildcards)
       ▲
       │
Pages:   tenmin.app  (React / Vite / Tailwind)
   • polls /api/inbox/:id every 3 seconds
   • renders messages in sandboxed iframe
   • 10-minute countdown via setInterval

There is no database. There is no queue. There is no cron job. The auto-deletion is the TTL on the KV entry — when 600 seconds pass, the key stops returning, and the data is garbage-collected at the next compaction. New messages reset the TTL because we write the entire inbox array each time; that's what "Extend" effectively does from the UI side.

Why Cloudflare for all of it

The piece-by-piece reasoning:

Email Routing

Cloudflare Email Routing is free, accepts catch-all rules at any verified domain, and — crucially — has a "send to Worker" destination type. Instead of forwarding the mail to a human inbox somewhere else, the routing rule pipes the raw RFC 5322 message into a Worker's email handler. That Worker can either accept the message (storing it, forwarding it, transforming it) or reject it with a meaningful SMTP error.

The free-tier quota is generous: at the time of writing, around a thousand inbound messages a day for free, with paid tiers if you need more. Our peak traffic is well below that, so it stays free.

The alternative we'd have used five years ago — a VPS running Postfix, or a paid receiver like Mailgun's inbound API — would have meant either a monthly bill or fifty lines of sysadmin work to keep the box patched. Email Routing eliminates both.

Workers

Cloudflare Workers run JavaScript at the edge with a generous free tier (a hundred thousand requests a day at the time of writing, with a 10ms-per-request CPU budget). We run two of them: one for accepting mail, one for serving the read API. They don't share code; they share only the KV namespace they read from and write to.

Why two Workers instead of one? Because the email-processor only handles inbound mail events, and the api-worker only handles HTTP. Combining them would mean either reading the runtime context in two different ways inside one handler, or pretending the API was a "mail server" too. Keeping them separate makes each one trivial.

KV

Cloudflare Workers KV is a strongly-consistent-on-write, eventually-consistent-on-read key-value store with a built-in expirationTtl on every put. The TTL is important for us — without it, we'd need a cron Worker to scan old keys and delete them, which costs money and adds complexity. With TTL, deletion is free.

KV is not ideal for high-write hot keys (writes propagate slowly through the cache network), but our write pattern is "one write per inbound message" with low contention. Reads are cached at the edge near each reader, which is exactly what the polling UI wants. The match is good.

Pages

Cloudflare Pages is static-site hosting with a CDN, custom domain, and free SSL. The frontend is a React/Vite/Tailwind SPA, built into a static bundle and uploaded with wrangler pages deploy. The deploy is sub-minute and there's no server component for the static site itself.

The inbox-ID design

The local part of the address — the bit before @tenmin.app — is the inbox ID. We pick six random hexadecimal characters on the client (about 16 million possible IDs), which has two consequences worth understanding.

First, IDs are not unguessable. Sixteen million is enough that random collision is rare for a given lifetime, but it's well within reach of a brute-force read attack. Someone scanning /api/inbox/000000 through /api/inbox/ffffffwould find every active inbox in a few hours. We don't currently rate-limit this aggressively, so anyone curious enough to try can do so.

This is intentional. Disposable inboxes are by definition shared infrastructure receiving publicly-addressable mail; treating them as private channels would mislead users. Our privacy policy says explicitly that the address is "effectively public — anyone who knows or guesses the six-character ID can read what was sent to it during its lifetime." The right mental model is: anything sent to a tenmin.app address is one curious stranger away from being read by them too. Don't use it for high-stakes mail.

Second, IDs repeat. With sixteen million possible IDs and a ten-minute lifetime, the same ID will be reissued to a different visitor eventually. The TTL guarantees there's no overlap between two simultaneous occupants of the same ID — when you grab abc123, any previous mail at that ID has already expired — but you might receive mail meant for the previous occupant if their sender retried late. Senders that keep retrying for hours are unusual but do exist. Be aware.

The validation regex, in three places

We validate inbox IDs with the same regex in three places: [a-z0-9._-]{1,64}. The email-processor rejects any incoming mail to an address that doesn't match (via message.setReject, which bounces with an SMTP error). The api-worker returns 400 for any request whose URL parameter doesn't match. The frontend generates only IDs that match. Three layers of the same check, none of them trusting the others.

The pattern allows lowercase letters, digits, dots, underscores, and hyphens — the intersection of "valid email local-part characters" and "safe in a URL path segment." It excludes uppercase (mail systems treat the local-part as case-insensitive anyway and we store lowercase) and excludes anything that would need URL-encoding. The 1–64 length cap matches RFC 5321's maximum.

The hand-rolled email-processor also enforces a few other rules: total message size cap, attachment count limit, and a hard limit on how many messages live in a single inbox at once. These are defensive — they exist to keep a single inbox from being used as a free storage layer for an attacker uploading large attachments. They don't kick in for any realistic legitimate use.

HTML rendering: two layers of defence

Email contains arbitrary HTML written by senders we don't trust. Rendering it directly in the frontend would be an invitation for cross-site scripting. We do two things to keep the renderer safe.

First, the frontend runs the HTML through a hand-rolled sanitiser before display. The sanitiser strips <script>, <style>, <iframe>, all on* event handlers, and javascript: URLs. It's about ten lines of regex; we deliberately avoid bringing in a heavy library like DOMPurify because we're solving a small problem and the regex approach is auditable in a single screen.

Second, the cleaned HTML is rendered inside a sandboxed <iframe> with no sandbox allowances — no scripts, no top-level navigation, no access to the parent page. Even if a malicious payload survives the sanitiser, the iframe sandbox catches it.

Remote images do load. We don't strip them, because doing so would break far too many legitimate emails to be worthwhile — every retailer's order confirmation, every newsletter, every two-factor code email rendered with a brand logo would look broken. The trade-off is that tracking pixels also load. Users who want maximum paranoia can switch to the plain-text view by collapsing and re-expanding a message; the body is stored as text alongside the HTML.

The polling lifecycle

The frontend polls the read API every three seconds. The polling effect is set up per inbox — when you grab a new ID, the previous poller is torn down and a new one starts. Inside the poller, requests are made with an AbortController so an in-flight request from a stale inbox doesn't deliver into the new one.

The countdown to expiration ticks every second via a separate setInterval. The reason these two intervals don't conflict is subtle and worth flagging: the polling callback reads the countdown through a ref, not through React state. If we'd used state, the polling callback would re-bind every second as the countdown ticked, the setup effect would re-fire every second, and the inbox would silently wipe its list each tick. We hit exactly that bug early on. The fix — read through a ref — is documented in the project's CLAUDE.md as a thing to avoid regressing.

What we'd do differently if we were starting today

Three honest reflections:

First, we'd probably skip Google Analytics. The current site loads GA4 only if a measurement ID is configured, and we did configure one. The signal-to-noise ratio of GA events for a service this small is poor, and the cookies it sets are the kind of thing people specifically come to a privacy-shaped service to escape. Cloudflare's own Web Analytics is privacy-preserving and would suffice.

Second, we'd consider rate-limiting the read API more aggressively from day one. Right now, anyone scanning the ID space can do so unchallenged. The cost of a brute-force read is "you might find some inboxes," which is the same as the cost of just guessing a single ID and getting lucky — but a stricter limit would be a cheap improvement.

Third, we'd think harder about the attachments story. We accept and store attachments, but the UI doesn't display them inline. That's a quiet half-feature. Either we should commit to handling them (with their own size and type validation) or strip them at the email-processor layer so users have a clear expectation.

If you want to build your own

The recipe, in one paragraph: register a domain, point it at Cloudflare, enable Email Routing, create a Worker on the inbound mail handler, give it a KV binding, write the twenty lines of code that extract the recipient local-part and put the parsed message into KV with a TTL. Add a second Worker that reads from KV by ID. Deploy a static frontend that polls the second Worker. You're done. The whole thing fits inside one afternoon if you've used Workers before.

Cloudflare's documentation has good starter examples for Email Workers. The postal-mime npm package handles MIME parsing well; the alternatives (mailparser, letter-parser) work too but are slightly chunkier. Hono is a clean choice for the read API but is overkill — a raw fetch handler in a Worker is half the size and just as readable for this scope.

Further reading