DIY · Own Domain · Zero Cost

Your own inbox service, built in an afternoon.

We own sitzio.de, we already use Cloudflare, and Cloudflare Email Routing is free. Point MX at Cloudflare, wire up a Worker, and we have the same thing Mailosaur sells — disposable addresses on a real domain, JSON API for the agent — for $0/month, forever, with no trial expiring.

Monthly cost
$0
CF Email Routing + Workers free tier
Addresses
Catch-all on any subdomain
Worker invocations
100k/day
Free tier; we'll use ~20/day
Setup time
~45 min
MX + Worker + KV + test
01 · WHY ROLL OUR OWN

Because we already have the pieces.

Mailosaur charges $9/mo because they built exactly this on top of someone else's cloud. We have the cloud. Comparison on the axes that matter for our workflow:

  Mailosaur CF Email Routing + Worker
Cost $108/yr  minimum, annual commit $0  within free tier forever
Domain <id>.mailosaur.net — obviously a test service test@mail.sitzio.de — looks like production mail
Setup effort 2 min — sign up, grab API key ~45 min — MX, Worker, KV binding, secret
Link extraction Built-in: message.html.links[] We parse it in the Worker with PostalMime + regex
Ownership Vendor lock-in, data on their servers We own it  inbox stays in our CF account
Works offline No — external service No — needs CF, but no trial, no auth expiry
Daily limit 50 tests/day (Starter) 100k Worker calls/day — effectively unlimited
02 · HOW IT FITS TOGETHER

The flow, end to end.

Auth provider (Supabase/Clerk/whatever sitzio.de uses) sends mail via its normal SMTP. The mail arrives at Cloudflare's MX because we pointed mail.sitzio.de there. CF routes it to our Worker. The Worker parses and stores it in KV. The agent reads via a second Worker endpoint authenticated with a bearer token.

graph LR
  A["AI Agent
(CLI)"] -->|"1. signup
to=run-42@mail.sitzio.de"| B["sitzio.de
(web app)"] B -->|"2. triggers"| C["Auth Provider
Supabase / Clerk"] C -->|"3. SMTP send
magic link"| D[("Cloudflare MX
mail.sitzio.de")] D -->|"4. route to"| E["Email Worker
email() handler"] E -->|"5. parse + store"| F[("KV Namespace
INBOX")] A -->|"6. GET /inbox/:addr
Bearer token"| G{{"HTTP Worker
fetch() handler"}} G -->|reads| F G -.->|"7. JSON + links"| A A -->|"8. follow link"| B B -->|"9. user verified"| H(["✓ Auth flow passed"]) classDef agent fill:#1a1a22,stroke:#f38020,color:#ece8e1,stroke-width:2px classDef site fill:#15151a,stroke:#7fc1c9,color:#ece8e1,stroke-width:1.5px classDef cf fill:#2a1a0a,stroke:#f38020,color:#fce8d4,stroke-width:2px classDef worker fill:#1e1a12,stroke:#f38020,color:#fce8d4,stroke-width:2px classDef store fill:#15151a,stroke:#8f8c85,color:#ece8e1,stroke-width:1.5px classDef success fill:#1a2212,stroke:#92d36e,color:#e2f3d4,stroke-width:2px class A agent class B,C site class D cf class E,G worker class F store class H success
Three CF products doing three jobs: Email Routing accepts MX traffic, Workers runs our parse + API code, KV stores messages with a TTL. All free-tier generous for this use. No external hops.
03 · SETUP

Seven steps, one afternoon.

Assumes sitzio.de is already on Cloudflare. Everything uses wrangler CLI (already installed on this machine) and the CF dashboard.

  1. Pick a subdomain for test mail
    Don't use the bare apex (it probably sends real mail). Use a dedicated subdomain like mail.sitzio.de so test traffic is isolated.
  2. Enable Email Routing on the subdomain
    CF Dashboard → sitzio.de → Email → Email Routing → Enable for mail.sitzio.de. Cloudflare auto-adds the three MX records + SPF TXT record. Wait ~2 min for DNS.
  3. Create a KV namespace for the inbox
    $ wrangler kv namespace create INBOX # copy the id into wrangler.toml → kv_namespaces binding
  4. Scaffold the Worker project
    $ npm create cloudflare@latest inbox-worker -- --type=hello-world --lang=ts $ cd inbox-worker && npm i postal-mime
  5. Write the Worker (code below — both email + fetch handlers)
    The email() handler parses and stores; the fetch() handler serves the agent's read API with bearer-token auth.
  6. Set the API token secret + deploy
    $ wrangler secret put API_TOKEN # paste long random string $ wrangler deploy
  7. Route incoming mail to the Worker
    Dashboard → Email Routing → Routes → Add catch-all rule → Action: Send to Worker → pick inbox-worker. Done. Every email to *@mail.sitzio.de now hits our Worker.
04 · THE WORKER

~60 lines does everything.

This is the entire src/index.ts. Two handlers on one Worker: email for inbound mail, fetch for the agent's read API.

src/index.ts
import PostalMime from "postal-mime";

export interface Env {
  INBOX: KVNamespace;
  API_TOKEN: string;
}

export default {
  // ─── Inbound mail handler ───────────────────────────────
  async email(message: ForwardableEmailMessage, env: Env) {
    const parsed = await PostalMime.parse(message.raw);
    const record = {
      id: crypto.randomUUID(),
      to: message.to,
      from: message.from,
      subject: parsed.subject ?? "",
      text: parsed.text ?? "",
      html: parsed.html ?? "",
      links: [...(parsed.html ?? "").matchAll(/href="(https?:[^"]+)"/g)].map(m => m[1]),
      codes: [...(parsed.text ?? "").matchAll(/\b(\d{4,8})\b/g)].map(m => m[1]),
      receivedAt: new Date().toISOString(),
    };
    await env.INBOX.put(
      `msg:${message.to}:${record.id}`,
      JSON.stringify(record),
      { expirationTtl: 3600 } // auto-expire after 1h
    );
  },

  // ─── Read API for the agent ─────────────────────────────
  async fetch(req: Request, env: Env): Promise<Response> {
    if (req.headers.get("authorization") !== `Bearer ${env.API_TOKEN}`)
      return new Response("unauthorized", { status: 401 });

    const url = new URL(req.url);
    const match = url.pathname.match(/^\/inbox\/([^\/]+)$/);
    if (!match) return new Response("not found", { status: 404 });
    const addr = decodeURIComponent(match[1]);

    if (req.method === "DELETE") {
      const list = await env.INBOX.list({ prefix: `msg:${addr}:` });
      await Promise.all(list.keys.map(k => env.INBOX.delete(k.name)));
      return new Response("cleared");
    }

    // GET /inbox/:addr?await=10  — poll up to N seconds for any message
    const deadline = Date.now() + parseInt(url.searchParams.get("await") ?? "0") * 1000;
    do {
      const list = await env.INBOX.list({ prefix: `msg:${addr}:` });
      if (list.keys.length) {
        const msgs = await Promise.all(list.keys.map(k => env.INBOX.get(k.name, "json")));
        return Response.json({ items: msgs });
      }
      await new Promise(r => setTimeout(r, 1000));
    } while (Date.now() < deadline);
    return Response.json({ items: [] });
  },
};
Why KV over D1? One message = one key, list-by-prefix gives us per-address queries for free, TTL handles cleanup. D1 would be overkill. If we later want retention beyond an hour, swap KV for R2 or bump the TTL.
05 · THE AGENT LOOP

Same six steps, our endpoints.

Functionally identical to the Mailosaur loop — mint, signup, trigger, poll, extract, assert. Only the URL changes.

01 · MINT
Generate address
run-$(uuid)@mail.sitzio.de
02 · SIGNUP
Submit form
POST signup on sitzio.de with the minted address.
03 · TRIGGER
Provider sends
Auth provider queues the verification email.
04 · POLL
Await mail
GET /inbox/<addr>?await=10
05 · EXTRACT
Pull the link
Read items[0].links[0] from JSON response.
06 · ASSERT
Follow & verify
Hit the link, confirm auth session.
06 · OUR API

Three endpoints, bearer-token auth.

Base URL: https://inbox-worker.<your-subdomain>.workers.dev. Every request carries Authorization: Bearer $API_TOKEN.

Method Path Behavior
GET /inbox/:address List all messages for an address. Returns { items: [...] }.
GET /inbox/:address?await=10 Block up to N seconds until at least one message arrives. Polls every 1s.
DELETE /inbox/:address Clear all messages for that address. Call between test runs.
07 · THE CONTRACT

Drop this into CLAUDE.md.

Once the Worker is deployed and these env vars are set, the agent has everything it needs.

CLAUDE.md
## Auth Testing — Cloudflare Email Routing

# Inbox service (our own Worker)
INBOX_API="https://inbox-worker.<subdomain>.workers.dev"
INBOX_TOKEN="$INBOX_API_TOKEN"   # in .env, never in git
INBOX_DOMAIN="mail.sitzio.de"

### Protocol for any auth-flow change:

  1. Mint a unique address:
       EMAIL="run-$(date +%s)-$(openssl rand -hex 3)@$INBOX_DOMAIN"

  2. Clear any stale messages first:
       curl -X DELETE -H "Authorization: Bearer $INBOX_TOKEN" \
         "$INBOX_API/inbox/$EMAIL"

  3. Submit the signup / login form with $EMAIL.

  4. Await the verification mail (blocks up to 10s):
       curl -H "Authorization: Bearer $INBOX_TOKEN" \
         "$INBOX_API/inbox/$EMAIL?await=10" | jq '.items[0]'

  5. Extract the magic link:
       LINK=$(... | jq -r '.items[0].links[0]')

  6. Follow the link, assert the authenticated session exists.

  RULES:
  - ALWAYS mint a fresh address per test run.
  - If the await returns empty items after 10s, the provider's
    SMTP is broken — report, don't retry blindly.
  - SMTP 250 on the send side is NOT a pass. Always verify here.