# Ephemera — Inbound email → your webhook (Cloudflare Email Routing)

> Self-executing Markdown. One binding of the *receive-inbound-email* intent — the inbound sibling of
> [`email.cloudflare.md`](./email.cloudflare.md) (outbound send). Receive mail at an address on your domain,
> hand it to a Worker, and fire an HTTP POST to your app. The cloud is the source of truth; this file is
> intent + write-back ledger.

> **Requires** a `zone(DOMAIN)` on Cloudflare (Email Routing writes the MX). **Provides**
> `email-webhook(DOMAIN, ADDRESS → WEBHOOK_URL)`. **Note vs SES:** there is **no native "paste a webhook
> URL" field** — the "webhook" is a Worker *you* deploy whose `email()` handler `fetch()`es your endpoint.
> That is more flexible than SES→SNS, but it is **your** glue: no provider retry, no queue, no DLQ (see
> Durability).

---

## 🤖 Director prompt

Observe before acting; verify each step against the authoritative source (the API or `@1.1.1.1`, **never**
the local resolver — see the negative-cache note); stop at 🔴/💥 for human go; write realized values back
into Live State. Cloudflare provisions in seconds; the gates are the DNS writes to a live zone.

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## Intent

Turn inbound email into an HTTP call your application can handle. Cloudflare **Email Routing** delivers mail
addressed to `ADDRESS@DOMAIN` to a Worker's `email()` handler; the handler reads the message and POSTs it to
`WEBHOOK_URL`. This is the honest answer to "does Cloudflare email do inbound webhooks?" — yes, as a Worker
you own, not a managed webhook.

**Acceptance contract** (proven live 2026-07-28 on a clean zone):
1. mail sent to `ADDRESS@DOMAIN` **reaches the Worker** (`email()` runs)
2. the handler **fires the webhook** — an HTTP POST to `WEBHOOK_URL` returns 2xx
3. the received message shows **dkim=pass** in its `Authentication-Results` (read `message.headers`)

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | Domain (a Cloudflare zone) | free-text | — (**required**) | `DOMAIN` | §1 enable + MX |
| 2 | Address to catch | free-text | `hook@DOMAIN` | `ADDRESS` | §3 rule matcher |
| 3 | Where to POST the parsed mail | free-text URL | — (**required**) | `WEBHOOK_URL` | §2 Worker var |
| 4 | Durability of inbound | `fire-and-forget` / `store-then-process` | `store-then-process` | `DURABILITY` | §2 handler shape |

```yaml
resolved_inputs:
  domain:      <your-zone>
  address:     hook@<your-zone>
  webhook_url: https://<your-app>/inbound-email
  durability:  store-then-process
```

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   authored from a live dogfood
last_verified: —
```

> **Candor — proven live, then torn down.** Dogfooded 2026-07-28 on acme.com (a clean zone, zero prior
> mail): mail to `hook@acme.com` → Email Routing → the Worker's `email()` → an HTTP POST to an external
> endpoint (`httpbin.org`) returned **200**, with **dkim=pass** on the received message. Torn down after
> (zone returned to 0 MX / 0 TXT). What is **not** proven: durability under a failing/slow webhook,
> behavior under concurrent mail + Worker cold-start, and `forward()`/`reply()` paths (see Deliberately not
> included). Treat the mechanism as proven, the reliability envelope as unmeasured.

| ✔ check | expected | observed | result |
|---|---|---|---|
| routing enabled + MX live | `email/routing` `enabled:true`; `@1.1.1.1` shows `route{1,2,3}.mx.cloudflare.net` | — | — |
| rule present | `email routing rules list` shows ADDRESS → worker | — | — |
| mail reaches Worker | a test send makes `email()` run (tail/log or KV receipt) | — | — |
| webhook fires | the handler's POST to WEBHOOK_URL returns 2xx | — | — |
| auth | received message shows `dkim=pass` | — | — |

## 0. Variables

```bash
export DOMAIN="<your-zone>" ADDRESS="hook@<your-zone>" WEBHOOK_URL="https://<your-app>/inbound-email"
export CF_ZONE_ID="<zone-id>" ACCOUNT_ID="<account-id>"   # zone: curl ./zones?name=$DOMAIN; account from the same result
# token in $CLOUDFLARE_API_TOKEN; `command wrangler` bypasses the local wrapper.
```

> **🔑 Token scope (dogfood finding).** A Workers-*deploy* token returns Cloudflare API error 10000 (Authentication error) on rule creation. The token needs
> **`Email Routing: Edit`** (zone) — and `Email Routing Addresses: Edit` (account) if you `forward()` to a
> destination. Add it at dash.cloudflare.com/profile/api-tokens (scopes are dashboard-only).

## Dependency frontier

```
zone on Cloudflare ─> §1 enable routing (writes MX) ─> §2 deploy Worker ─> §3 rule (ADDRESS→worker) ─> ✔ §4 send+webhook
```
Non-negotiable: the **Worker must exist before the rule** (the rule references it by name); the **MX must be
live before mail routes** (verify authoritatively, not via a poisoned local resolver).

## 1. Enable Email Routing  🔴 (writes MX to a live zone) 🟢

```bash
command wrangler email routing enable "$DOMAIN"    # writes route{1,2,3}.mx.cloudflare.net MX + a routing SPF/DKIM
```
```bash
# ✔ verify AUTHORITATIVELY — the local resolver may hold a negative-cache from any pre-enable MX query:
dig @1.1.1.1 +short MX "$DOMAIN"                     # route1/2/3.mx.cloudflare.net
curl -fsS "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/email/routing" \
  -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" | grep -oE '"enabled": ?true'
```
> **⚠ DNS negative-cache trap (dogfood-confirmed).** If you `dig MX $DOMAIN` *before* enabling, the resolver
> caches "no MX" for the zone's SOA-minimum TTL and keeps serving it after the records exist — so a plain
> `dig` reads empty while mail already routes. Verify via `@1.1.1.1` or the API, never the resolver you just
> poisoned. (Same trap is documented zone-wide in EPHEMERA.md.)

## 2. Deploy the inbound Worker  🟢

The Worker exports an `email()` handler. Envelope fields are available directly — **no MIME parse needed**
for the common case. `message.from` is the **SMTP envelope sender** (for Cloudflare-relayed mail this is the
bounce address, *not* the header `From:`); read `message.headers.get("from")` if you need the display sender,
and trust `message.headers.get("authentication-results")` for identity.

```javascript
// index.js
export default {
  async email(message, env, ctx) {
    const payload = {
      envelopeFrom: message.from,                                  // SMTP MAIL FROM (may be a bounce addr)
      headerFrom:   message.headers.get("from") || "",             // the display sender
      to:           message.to,
      subject:      message.headers.get("subject") || "",
      authResults:  message.headers.get("authentication-results") || "",
      rawSize:      message.rawSize,
      receivedAt:   new Date().toISOString(),
    };
    // fire the webhook — this IS the "inbound webhook". Target an EXTERNAL endpoint (see 522 note).
    const r = await fetch(env.WEBHOOK_URL, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
    });
    if (!r.ok) {
      // no provider retry exists — a non-2xx here is DROPPED unless you make it durable (see §Durability)
      console.error("webhook failed", r.status);
      // NOTE: message.setReject(reason) returns a PERMANENT 5xx → the sender gets a bounce/NDR; it does
      // NOT retry into your handler. The only no-loss path is store-then-process (DO/D1) + replay.
    }
  },
};
```
```jsonc
// wrangler.jsonc
{ "name": "email-inbound-hook", "main": "index.js", "compatibility_date": "2025-07-01",
  "observability": { "enabled": true },
  "vars": { "WEBHOOK_URL": "https://<your-app>/inbound-email",
            "MANAGED_BY": "ephemera", "SOURCE": "email-routing.cloudflare.md" } }
```
```bash
command wrangler deploy    # note the deployed script name for §3
```
> **⚠ workers.dev → workers.dev is a bad webhook target (dogfood finding):** a Worker POSTing to another
> Worker on the same `*.workers.dev` threw transient **522**s (edge hairpin / cold-start) before succeeding.
> Point `WEBHOOK_URL` at your real application endpoint (a normal external HTTPS URL) — that path was clean.

## 3. Route the address to the Worker  🟡

```bash
command wrangler email routing rules create "$DOMAIN" \
  --name ephemera-inbound-hook \
  --match-type literal --match-field to --match-value "$ADDRESS" \
  --action-type worker --action-value email-inbound-hook
```
```bash
# ✔ verify
command wrangler email routing rules list "$DOMAIN"   # ADDRESS → worker, enabled
```
> A catch-all instead of a literal address: `--match-type all` (no `--match-field/--match-value`).

## 4. Send-and-receive test  ✔ 🟡 (a rehearsal-grade self-send)

Send a message to `ADDRESS@DOMAIN` from any real inbox (or, if [`email.cloudflare.md`](./email.cloudflare.md)
is set up, self-loop: `wrangler email sending send --from noreply@$DOMAIN --to $ADDRESS ...`). Then confirm:

```bash
# watch the handler run + the webhook fire (live):
command wrangler tail email-inbound-hook           # look for the fetch + status
# and confirm the received message authenticated:
#   at WEBHOOK_URL, inspect the posted payload → authResults contains  dkim=pass
```
> → Live State: fill the ✔ rows; set `status: live`.

## Durability (the SES→SNS/S3 gap — read before production)

Email Routing gives you **at-most-once** delivery to your handler and **no retry** of your webhook. If
`email()` returns without forwarding/rejecting and your POST failed, **the mail is gone**. For anything you
cannot lose:

- **`store-then-process` (default knob):** the `email()` handler writes the message to a **Durable Object /
  D1** and returns fast; a separate consumer POSTs to your app with its own retry. This is the durable analog
  of SES→S3. (The store is where you'd also implement bounce/complaint bookkeeping — Cloudflare has **no
  push** bounce/complaint webhook; you poll the GraphQL dataset.)
- **`setReject` is refuse, NOT retry (reviewer catch):** `message.setReject(reason)` returns a **permanent
  5xx** to the sending server — the sender generates a **bounce/NDR** and the mail leaves your pipeline
  entirely. The `reason` string is human-readable, not a retry-control; the Email Workers API exposes no
  temporary (4xx) reject. Use it to *refuse* mail loudly instead of silently dropping it — never as a
  "hold and retry" mechanism. The only no-loss path is `store-then-process`.
- **CPU/subrequest limits** apply in `email()`; keep the handler thin (store + return), do the heavy work in
  the consumer.

## Update (idempotent reconcile)

- Re-run §1 `enable` — idempotent (no-op if MX present). Re-run §3 `rules create` only if the rule is absent
  (`rules list` first; creating a duplicate matcher is allowed but ambiguous).
- Change `WEBHOOK_URL` → edit the Worker var and `wrangler deploy`; no DNS change.
- Change `ADDRESS` → `rules update`/recreate; no DNS change.

## Teardown  💥

> **Dogfood finding:** `wrangler email routing disable` / `rules delete` are **interactive** and the
> rule-delete takes **positional** `<domain> <rule-id>` (not `--rule-id`). The scriptable path is the API;
> disabling the feature removes the managed MX.

```bash
Z="$CF_ZONE_ID"; API=https://api.cloudflare.com/client/v4; A="Authorization: Bearer $CLOUDFLARE_API_TOKEN"
RID="$(command wrangler email routing rules list "$DOMAIN" | awk '/ephemera-inbound-hook/{print $1}')"
curl -fsS -X DELETE "$API/zones/$Z/email/routing/rules/$RID" -H "$A"     # delete the rule
curl -fsS -X POST   "$API/zones/$Z/email/routing/disable"   -H "$A" -d '{}'   # disable routing (removes MX)
curl -fsS -X DELETE "$API/accounts/${ACCOUNT_ID}/workers/scripts/email-inbound-hook?force=true" -H "$A"
```
```bash
# ✔ verify — clean (assert the negatives: no MX AND no routing TXT left behind)
dig @1.1.1.1 +short MX "$DOMAIN"    # empty
curl -fsS "$API/zones/$Z/dns_records?type=TXT" -H "$A" | grep -c mx.cloudflare.net   # 0
command wrangler email routing rules list "$DOMAIN" 2>/dev/null | grep -c ephemera-inbound-hook   # 0
```
> → Live State: status: gone.

## Deliberately not included

- **Outbound send** — [`email.cloudflare.md`](./email.cloudflare.md) (its own intent).
- **`forward()` / `reply()`** — forwarding needs a *verified destination address*
  (`wrangler email routing addresses create`, then a click on the confirmation email — a ⏳ human gate);
  replying needs `send_email` or `mimetext`. Both are real but were not dogfooded here.
- **MIME/attachment parsing** — add `postal-mime` when you need the body/attachments; the envelope + headers
  above cover the webhook-trigger case without a dependency.
- **Bounce/complaint push webhooks** — Cloudflare has none (suppressions + GraphQL poll only); do not port an
  SES SNS-bounce pipeline expecting parity.
