Receive, store, thread, search and send email from your own domains, running entirely on Cloudflare (Workers · Durable Objects · R2 · D1 · Queues) — with an MCP endpoint for agents (read/search/draft, no send) and a transactional REST API for programmatic outbound mail behind scoped API keys.
[](https://github.com/santigamo/reccado/actions/workflows/ci.yml) [](./LICENSE) [](https://workers.cloudflare.com) [](https://www.typescriptlang.org) [](https://hono.dev) [](CHANGELOG.md)', strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now','+2 hours'), 'manual')"
```
**From the terminal (scripts and agents)**, `pnpm operator` does that same pairing ladder for you
and keeps the resulting session so you can call the `/api/*` control plane without a browser:
```bash
pnpm operator login --env dev --host inbox. --email you@ # mint → spend → store
pnpm operator whoami --host inbox. # signed-in owner + expiry, or "not signed in"
pnpm operator logout --host inbox. # server sign-out + delete the local session file
```
`login` mints a single-use code (10-minute TTL, `--ttl` 1–60) into the D1 of the given `--env`
(read from `wrangler.generated..json` or `wrangler.jsonc`; a `localhost` host uses the local
D1), but only when the email is already a registered owner — spending a pairing code *links* any
email as an owner, so a typo would otherwise grant access; `--allow-new-owner` is the explicit
bootstrap override. The session cookie is stored owner-only in
`~/.config/reccado/sessions/.json` (0600, dir 0700) and is never printed; the code is
force-expired if anything after minting fails. `--host` defaults to `$RECCADO_HOST`, `--email` to
the first `OWNER_BOOTSTRAP_EMAILS` entry when set locally. Scripts reuse
`scripts/lib/operator-session.ts` (`loadSession` + `operatorFetch`/`operatorJson`, which add the
cookie and the `Origin` header mutating `/api/*` routes require, and throw `OperatorAuthError`
on a 401).
To prove the transactional API works end to end on a **deployed** environment (not just against
the Durable Object, which is all the test suite exercises), run the smoke test with that session:
```bash
pnpm smoke:transactional --env dev --host inbox. --mailbox \
--sender hello@send. --to you@ # read-only checks + plan
pnpm smoke:transactional ... --send [--wait-delivery 60] # one real send, then cleanup
```
`--send` creates a throwaway template and a live key locked to `--to` (quota 5, 1h), sends one
message, checks replay, policy rejection and status, and always revokes and archives what it made.
Confirm in the recipient's mailbox that From shows the sender name and SPF/DKIM/DMARC pass. See
[`docs/OPERATIONS.md`](docs/OPERATIONS.md#transactional-smoke-test).
See [`SECURITY.md`](SECURITY.md) for the model.
**Onboarding a product in one command** — once the deployment exists, `pnpm onboard` does
everything above for one product (Email Sending on `send.` and optionally the apex, the
`MAIL_SENDING_DOMAINS` write, Email Routing rules, domain + mailbox + aliases, templates, API keys
stored straight into 1Password) from a single JSON manifest:
```bash
pnpm operator login --env dev --host inbox. --email you@
pnpm onboard --env dev --manifest ./onboard.json --host inbox. # dry run: already / would do / blocked
pnpm onboard --env dev --manifest ./onboard.json --host inbox. --apply # do the missing steps
```
Every step reads current state first, so a dry run is also a drift check, and a second `--apply`
is all `already`. It never deploys, never overwrites a routing rule, and never mints a key it
cannot store. Start from [`examples/onboard/onboard.example.json`](examples/onboard/onboard.example.json);
details in [`docs/OPERATIONS.md`](docs/OPERATIONS.md#onboarding-a-product). The integrator side
(using the key from the product) is in [`docs/INTEGRATING.md`](docs/INTEGRATING.md).
### 3. Verify
```bash
pnpm doctor --env dev --cloud --url https://inbox. # auth, D1, secrets, live session issuer
pnpm smoke:access https://inbox. # fails if unauthenticated /api/* returns 200
pnpm smoke:routing --domain --env dev # fails if no Email Routing rule targets the Worker
```
`pnpm doctor --cloud --url` fails if an unauthenticated request gets a `200` instead of an Access
redirect. It intentionally does **not** treat a `*.workers.dev` URL as proof of Access; verify the
custom domain that your Access application actually covers.
The deployed Worker also exposes `GET /api/setup/status` (behind Access): index-DB health plus
control-plane completeness (domain/mailbox/alias/routing counts and a `canReceive` flag).
For an exhaustive binding audit, `pnpm verify:cf` cross-checks the Worker name, R2, queues, D1,
Email Sending and an example routing rule against the account. It exits early asking for your real
D1 id (the repo ships a placeholder) — pass resource names/IDs by env var or CLI flag
(`CF_VERIFY_D1_ID=`, `--worker`, `--r2`, …); see [`docs/IMPLEMENTATION.md`](docs/IMPLEMENTATION.md).
## Telegram bridge (optional)
Off unless `TELEGRAM_BOT_TOKEN` is set. When on, each inbound message is pushed to a Telegram chat
as a preview card, and replying to that card drafts an email reply.
**Sender, subject and snippet — not the body.** Telegram cloud chats are not end-to-end encrypted,
and mail routinely carries password resets and 2FA codes. Reading the full message stays an
explicit act in Reccado.
**Replies keep the human-confirm invariant.** A Telegram reply creates a draft and shows a preview
with ✅ Enviar / ✖️ Descartar. Nothing leaves the mailbox until the button is pressed, and the send
runs through the same path as the web UI (`src/lib/outbound-send.ts`), so the D1 ledger and the
message index stay consistent regardless of which surface confirmed.
**How a reply finds its thread.** One mechanism: `reply_to_message` — quote the notification card.
It works in any chat, and it is the only way a reply is resolved.
Topics deliberately do *not* identify a conversation. When the bound chat is a supergroup with
forum mode, Reccado gives each **mailbox** its own topic, named after the mailbox — never after an
email subject, because a topic name is permanent UI and an email subject is attacker-controlled
text. A topic therefore holds many conversations, so "the newest card in this topic" is not a
usable answer to "which email is this reply for": a bare message in a topic gets a nudge to quote
the card instead. Forum support is detected once (`getChat` at `/start`, cached in
`runtime_config`), not configured; a plain chat keeps getting plain messages.
A topic can also be given its own name, independent of the mailbox's display name (which is the
From name of its replies): `pnpm operator telegram topic --name "..."` creates it, or
`--adopt ` maps one that already exists. `pnpm operator telegram status` shows the bot,
the bound chat, the bot's rights and each mailbox's topic, and `pnpm smoke:telegram` posts a
labelled test message into every topic and checks it landed there. The token never leaves the
worker: these commands call `/api/telegram/*` with your operator session. See
[docs/OPERATIONS.md](docs/OPERATIONS.md#moving-the-telegram-bridge-to-a-forum).
### Setup
```bash
# 0. apply the D1 migration FIRST — the bridge writes to telegram_links on every
# inbound message, and a missing table silently loses every notification
pnpm d1:migrate:dev # or d1:migrate:prod
# 1. create a bot with @BotFather and set its token on the worker
wrangler secret put TELEGRAM_BOT_TOKEN --env dev
# 2. deploy, then load the authenticated UI once
pnpm run deploy:dev
# 3. read the pairing code the deployment minted for itself. While no operator is
# linked, the hourly cron keeps exactly one single-use code alive in D1
wrangler d1 execute inbox-mcp-index-dev --remote --env dev --command \
"SELECT code, expires_at FROM owner_pairing_codes WHERE consumed_at IS NULL \
AND expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now') ORDER BY created_at DESC LIMIT 1"
# 4. send /start from the account you want to authorise. The code is spent
# on first use, the account lands in owner_identities, and that chat is adopted
```
Who may drive the bot is still your declaration — a Telegram account that can order the bot is
an account that can send mail as you. What changed is how you write it down: the code says "the
next account to answer with this, in the next two hours, is me", and the machine observes which
user id showed up. Nobody's personal id ends up in a committed file, and revoking one is a
`DELETE` rather than a redeploy. `TELEGRAM_ALLOWED_USER_IDS` still works if it is set, as an
emergency bootstrap for a deployment you cannot pair.
Only one input is yours: the bot token. Everything else the worker knows, observes for itself, or
asks you to confirm once with a code.
- **The webhook secret is derived, not configured.** It is an HMAC of the bot token
(`deriveWebhookSecret`, `src/telegram/api.ts`), so no human ever invents, stores or re-supplies
it, and it rotates with the token.
- **The webhook URL is reconciled hourly by the cron** (`reconcileTelegramWebhook`,
`src/telegram/registration.ts`): it compares what Telegram believes against the truth and
re-registers on drift. The worker learns its own public origin by observing a request that
already cleared the web auth perimeter (an owner's Better Auth session) — never an
unauthenticated one, because the `Host` header is forgeable and that value decides where
Telegram delivers.
- **The chat is adopted, not declared.** The first `/start` from an operator stores the chat in D1
(`runtime_config`). First writer wins: a later `/start` from another chat does *not* move the
binding, and the bot says so. Moving it on purpose is `pnpm operator telegram rebind --chat `
(dry run, then `--apply`), which checks the chat and the bot's rights with Telegram first and
audits the move — no raw D1.
- **The operator is paired, not committed.** Whom you trust is still your decision — whoever drives
the bot can send mail as you — but it is recorded in `owner_identities` (D1) by spending a
single-use, expiring code, not by putting a personal user id in a file and redeploying. Every
link and every rejected attempt is written to `ops_events`.
- **One owner record, two facets.** The same table holds the emails that pass the web and MCP
perimeter and the Telegram accounts that may drive the bot. Two lists answering "who owns this
deployment" is how they drift apart.
`GET /api/health` reports `dependencies.telegram` with `mode: off | partial | on` plus a `missing`
list, so a bridge that is configured but not yet delivering says exactly what it is waiting for.
**`/telegram/webhook` sits deliberately outside the session perimeter.** Telegram cannot present a
Better Auth session or OAuth token, so the route authenticates itself instead: the
`X-Telegram-Bot-Api-Secret-Token` header (compared in constant time) plus the operator allowlist. An unpaired bridge does answer the route — refusing to
would make pairing impossible, since `/start ` arrives through it — but the secret still
gates every update, and the only command that does anything for a stranger is the one that spends
a valid code.
### Limits worth knowing
| Limit | Consequence |
| --- | --- |
| 4096 characters per message | Long mail is truncated to a preview; open Reccado for the rest. |
| Bots download ≤20 MB from Telegram | Attaching large files from the phone is not possible (attachments from Telegram are not supported yet — Reccado replies with a note). |
| ~1 message/second per chat | A burst of newsletters is rate-limited by Telegram, not by Reccado. |
| 48 hours to edit a message | An untouched confirm card older than that can no longer be updated in place; the button itself expires after 24 h. |
| Telegram HTML is a small subset | Formatting in a reply (bold, italic, links, code) is converted to minimal email HTML; anything without an email equivalent goes through as plain text. |
| Reply-to-sender only | A Telegram reply goes to the original sender; CC recipients are dropped. Reply-all lives in the web UI. |
| Replies come from the mailbox's primary address | Mail that arrived at an alias (`support@`) is answered from the mailbox's primary address (`hello@`). Alias-accurate `From` is not implemented yet. |
| Text only | Attachments cannot be sent from Telegram (the bot answers with a note); inbound attachments are flagged in the card but stay in Reccado. |
## Transactional API (optional)
Off unless `TRANSACTIONAL_API_KEY_PEPPER` is set. An explicit, operator-created pre-authorization
exception to the human-confirm rule: external apps use mailbox-bound API keys to send
template-based mail through the same Email Sending engine.
**Enable:**
```bash
openssl rand -hex 32 # generate a pepper once
wrangler secret put TRANSACTIONAL_API_KEY_PEPPER --env dev
pnpm d1:migrate:dev # applies 0006/0007 projections (transactional_api_keys, transactional_request_log)
```
Rotating the pepper invalidates every existing transactional key — re-issue keys after rotation.
Template and key management stay behind the Better Auth session perimeter (plus a `mailboxes.owner_email`
ownership check):
```text
POST/GET /api/mailboxes/{mailboxId}/transactional/templates
PUT /api/mailboxes/{mailboxId}/transactional/templates # idempotent sync of a full list
PUT /api/mailboxes/{mailboxId}/transactional/templates/{templateId}
POST /api/mailboxes/{mailboxId}/transactional/templates/{templateId}/archive
POST /api/mailboxes/{mailboxId}/transactional/api-keys # returns plaintext key once; accepts senderName
GET /api/mailboxes/{mailboxId}/transactional/api-keys
POST /api/mailboxes/{mailboxId}/transactional/api-keys/{keyId}/revoke
POST /api/mailboxes/{mailboxId}/transactional/api-keys/{keyId}/rotate
```
Sending is the one path that deliberately bypasses the session perimeter — the key is the auth:
```bash
curl -sS -X POST 'https://inbox./v1/mailboxes//transactional/messages' \
-H 'Authorization: Bearer rck___' \
-H 'Idempotency-Key: ' \
-H 'Content-Type: application/json' \
-d '{"template":"welcome","to":"user@example.com","variables":{"name":"Ada"}}'
curl -sS 'https://inbox./v1/mailboxes//transactional/messages/' \
-H 'Authorization: Bearer rck___'
```
**Integrating a product?** Read [`docs/INTEGRATING.md`](docs/INTEGRATING.md) — the
integrator's contract: endpoint, idempotency, every response code and what to do about it.
Key points (details in [`docs/OPERATIONS.md`](docs/OPERATIONS.md#transactional-api-current-state)):
- Keys are `rck___`, stored only as an HMAC hash; scoped to a mailbox +
sender with `transactional:send` / `transactional:status` / `transactional:templates:use`,
template allowlist, recipient policy, optional daily quota and expiry.
- `Idempotency-Key` is mandatory; same key + same payload replays the original result, different
payload → `409`. Request records are reserved (with quota) at `pending` before the provider call.
- Outcomes: `sent`, `permanent_failure`, `unknown` (ambiguous, never auto-retried — review
manually), `accepted`, `rejected`, `idempotency_conflict`. A replay returns the original
outcome. Provider error messages are never stored.
- **Only a message the provider accepted answers 2xx.** `sent` → `200`, `accepted` → `202`;
`permanent_failure` → `502` and `unknown` → `504`, so a client that throws on non-2xx cannot
mistake an undelivered message for a sent one, and can tell a definite failure from an unresolved
one without parsing the body. Rejections are `401` (missing auth), `400` (missing
`Idempotency-Key`), `429` (quota) or `403`; an idempotency conflict is `409`.
- **Template variables are dropped once a send reaches a terminal state.** They routinely carry
action-capable tokens (verification links, password resets, invitations); the request row keeps
its payload hash and status for idempotency, not the values.
- **Test keys never send real mail** — the production send path rejects them until a simulated/test
sink exists.
- Cloudflare Email Sending events are consumed through a Queue; hard bounces and complaints
create mailbox-local suppressions that block future transactional sends (hard bounces expire
after 90 days, complaints never — intent does not lapse on a timer). Those events only exist for
a sending domain an event subscription binds to the queue: `pnpm setup:sending` creates and
verifies it, `pnpm doctor --cloud` cross-checks the live provider lists, and
`/api/health` → `dependencies.sendingFeedback` reports, per sending domain, whether events are
actually arriving — so a null `delivery_status` can be told apart from a dead feedback channel.
Test keys still have no simulated delivery sink and are rejected for sending.
- Stale transactional requests are reconciled hourly to `unknown` and never retried automatically.
- Hardening: JSON-only, 100 KB body cap, `Cache-Control: no-store`, no CORS, no cookies, no
query-param credentials.
## Configuration
### Secrets and vars
| Name | Kind | Purpose | Required? |
| --- | --- | --- | --- |
| `BETTER_AUTH_SECRET` | secret | Signing key for the Better Auth issuer inside the worker (web sessions, `/api/auth/*`, `/mcp` OAuth tokens). Machine-generated and uploaded by `pnpm setup:auth --apply`. Auth fails closed outside `localhost` without it, or if it is shorter than 32 characters. Rotating it signs out every current session. | **Required** for any non-`localhost` deployment |
| `OWNER_BOOTSTRAP_EMAILS` | secret | Bootstrap for the owner registry (`owner_identities` in D1), unioned with it — not the record itself. It is the **master key into the registry**: `/login` only emails an OTP to an address in this union, and `/mcp` OAuth only issues tokens to it. With an empty registry **and** this unset, `/api/*` and `/mcp` fail closed (`503`) — use the pairing-code rescue (see [Wire your domain](#2-wire-your-domain)) to open the first session. | Optional once an owner is registered in D1 |
| `TRANSACTIONAL_API_KEY_PEPPER` | secret | HMAC-SHA256 pepper that hashes transactional API keys (`rck_*`). Key ops and the `/v1/.../transactional/*` send/status endpoints fail closed (`503`) without it. Rotating it invalidates all existing transactional keys. | Required to enable the transactional API |
| `CLOUDFLARE_API_TOKEN` | secret | Least-privilege token for admin provisioning workflows (zone read, DNS edit for setup:sending's SPF/DMARC/DKIM/MX records, Email Routing write for catch-all API setup, WAF rate-limiting rule creation for `/api/auth/*` in setup:auth). Also enables setup:domain's up-front custom-domain conflict check via the Workers Custom Domains API. | Optional |
| `PHASE0_DEBUG_TOKEN` | secret | Gates the `/api/debug/phase0/*` introspection endpoints (R2 head, DO schema/state dumps, local email simulation in deployed environments). These endpoints are unreachable unless this token is set, and every request must present it. | Optional (leave unset to disable debug endpoints entirely) |
| `MAIL_FROM_ADDRESS` | var (`wrangler.jsonc` → `vars`) | Default outbound sender address. Must be a verified sender on a domain onboarded to Cloudflare Email Sending. | **Required** |
| `MAIL_SENDING_DOMAINS` | var | Comma-separated domains verified in Cloudflare Email Sending. A mailbox whose domain is listed replies as itself; anything else goes out as `MAIL_FROM_ADDRESS` with `Reply-To` set to the mailbox. | Optional (recommended once your domain is verified) |
| `TELEGRAM_BOT_TOKEN` | secret | Bot token from @BotFather. Unset = the Telegram bridge is off entirely and `/telegram/webhook` answers 404. | Optional |
| `TELEGRAM_ALLOWED_USER_IDS` | var | Emergency bootstrap for the Telegram half of the owner registry, unioned with the identities linked in D1. Normally you link an account by sending `/start ` instead, which keeps personal user IDs out of the repo. The chat is adopted on first `/start` and the webhook secret is derived from the bot token. | Optional (only to recover a deployment you cannot pair) |
### Bindings (`wrangler.jsonc`)
| Binding | Type | Purpose | Required? |
| --- | --- | --- | --- |
| `MAILBOX_DO` | Durable Object (`MailboxDurableObject`, SQLite storage) | Canonical per-mailbox state: messages, threads, labels, FTS, drafts, outbox, idempotency, realtime WebSocket sessions. | **Required** |
| `MAIL_OBJECTS` | R2 bucket | Raw inbound MIME, parsed HTML bodies, attachments, backup manifests/exports. | **Required** |
| `INBOUND_EMAIL_QUEUE` | Queue producer + consumer | Metadata-only transport from the Email Routing handler to the mailbox Durable Object, with a configured `dead_letter_queue` for poison messages. | **Required** |
| `INDEX_DB` | D1 database | Cross-mailbox/control-plane index: `domains`, `mailboxes`, `aliases`, `routing_rules`, `message_index`, `ingest_events`, `outbound_sends`, `ops_events`, plus transactional projections (`transactional_api_keys`, `transactional_request_log`). Rebuildable from the Durable Objects, not authoritative. | **Required** |
| `EMAIL` | Email Sending (`send_email`) | Outbound mail via `env.EMAIL.send()` after explicit human confirmation. | **Required** for outbound sending |
| `triggers.crons` | Cron Trigger | Periodic backup sweep (writes per-mailbox manifests to R2 and an `ops_events` row). | **Required** for scheduled backups |
## Compatibility
- **Node.js** — `engines.node: ">=22.15.0"` in `package.json`; `.node-version` pins `24`. CI runs Node 22.15.0 and 24.
- **pnpm** — `packageManager: pnpm@11.1.1` in `package.json`. Use Corepack or install that version
directly.
- **Wrangler** — `^4.105.0` (devDependency). Cloudflare resource commands in this README assume a
4.x Wrangler CLI.
- **Cloudflare plan** — outbound sending to **arbitrary recipients** (not just verified
destination addresses) requires a **Workers Paid plan**; see
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (Risks) and
[`docs/IMPLEMENTATION.md`](docs/IMPLEMENTATION.md) (Prerequisites).
- **Cloudflare features** required on the account: Workers, Durable Objects, R2, Queues, D1, Email
Routing, Email Sending, Cron Triggers. A WAF rate-limiting rule on `/api/auth/*` is optional but
recommended (setup:auth creates or prints it).
- **Public routing** — the default (production) environment ships with `workers_dev: false` in
`wrangler.jsonc`: it is intentionally not reachable on the shared `*.workers.dev` subdomain.
Front it with your own custom domain before deploying to production. The `dev` environment
(`reccado-dev`) keeps `workers_dev: true` for local-to-cloud smoke tests, but real-session UI
verification and the WAF rule still want a custom domain.
## Troubleshooting
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Queue backlog growing | Mailbox Durable Object errors on ingest, or D1 index writes failing | Inspect Queues metrics and tail Worker logs for the failing mailbox; check DO errors before pausing inbound routing (only pause if there's real data-loss risk). |
| DLQ non-empty | Poison messages (unsupported schema version) or repeated transient ingest failures | Inspect `/api/admin/dlq`, classify poison vs. transient, fix the underlying code/config, and only replay after confirming idempotency keys make replay safe. |
| `email()` handler errors before enqueue | R2 write failure while storing raw MIME | The handler must not enqueue without a raw R2 key — let Email Routing's retry/reject behavior handle it; do not enqueue partial state. |
| Mailbox stops updating but inbound keeps arriving | D1 is unavailable | The Durable Object remains the source of truth and keeps ingesting; the D1 cross-mailbox index falls behind. Retry the index write through the Queue, then run `/api/admin/reindex` for the affected mailbox once D1 recovers. |
| A message shows up with no parsed body/search hits | MIME parsing failed inside the Durable Object | Expected degraded behavior: the message row is kept with `parse_status='failed'` and the raw R2 key preserved (the email is never dropped); check `/api/admin/ops-events` for the parse-failure event. |
| `confirm-send` returns an error and nothing sends | Outbound send failed at the provider, or recipient/size limits exceeded | Check `outbound_sends.status='failed'` and `error_code` for the draft; fix the underlying issue (recipient count, size, sender verification) and retry — `confirm-send` is idempotency-keyed, so retries with the same key never double-send. |
| An unauthenticated UI visit never lands on `/login`, or `/api/*` answers `503 auth_not_configured` | `BETTER_AUTH_SECRET` is unset (or shorter than 32 characters), so the issuer refuses to start | Upload it with `pnpm setup:auth --env --url https:// --apply`, re-run `pnpm doctor --cloud --url https://`, then reload the UI. |
| `pnpm wrangler deploy --env dev` deploys the wrong Worker name | The Cloudflare Vite plugin can redirect Wrangler to its own generated config and drop the `--env` name override | Deploy with `pnpm run deploy:dev`: it deploys `--config dist/server/wrangler.json` built for the env and refuses to run if that build names a different Worker than `wrangler.jsonc`. |
| A var `setup:sending` wrote (e.g. `MAIL_SENDING_DOMAINS`) never reaches the Worker | Deployed with raw `wrangler deploy`, which reads only the tracked `wrangler.jsonc` and never the gitignored `wrangler.generated..json` | Deploy with `pnpm run deploy[:dev]` (overlays the generated file and prints every value it applied). `pnpm doctor --env ` shows the overlay; `--cloud` fails when a deployed `MAIL_SENDING_DOMAINS` entry is not enabled in Email Sending. |
| `pnpm setup:cloud --apply` fails while building or patching `dist/server/wrangler.json` | The TanStack/Vite build failed, or the build output was not produced before Wrangler deploy | Fix the build error first (`pnpm run build` should pass), then rerun the same `setup:cloud` command. Do not hand-edit the tracked `wrangler.jsonc`; `setup:cloud` patches the built config from `wrangler.generated..json`. |
| `pnpm setup:auth --apply` ran but `/login` still cannot sign anyone in | The owner registry is empty and `OWNER_BOOTSTRAP_EMAILS` is unset, so the issuer closes registration (`503 owner_not_configured`) | Register the owner in D1 or set `OWNER_BOOTSTRAP_EMAILS`, or spend a pairing code minted with `wrangler d1 execute` (see [Wire your domain](#2-wire-your-domain)). Verify with `pnpm doctor --cloud --url https://`. |
| `pnpm setup:domain --apply` refuses to attach the hostname | The hostname is already a Workers Custom Domain on a **different** Worker (e.g. left over from a rename) | The script won't silently steal it. Detach it from the other Worker first (Cloudflare dashboard → Workers & Pages → Custom Domains, or redeploy that Worker without the route), or choose a different hostname. Re-running for the *same* Worker is idempotent and safe. |
| `pnpm setup:routing --catch-all --apply` asks for `CLOUDFLARE_API_TOKEN` | Wrangler can enable routing and create explicit-address worker rules, but its catch-all command rejects `worker` client-side | Set a token with Zone Read + Email Routing Write for the zone and rerun. The script uses Cloudflare's REST `catch_all` endpoint, which supports `worker`. |
| `pnpm setup:sending --apply` only prints DKIM/MX records instead of adding them | No `CLOUDFLARE_API_TOKEN` is set, or `--skip-provider-records` was passed | Set `CLOUDFLARE_API_TOKEN` (DNS edit) and re-run `--apply` to auto-add the provider-generated DKIM TXT + MX records parsed from `wrangler email sending dns get `. Drop `--skip-provider-records` if you passed it and want the script to manage them after all. |
| `setup:cloud` aborts because the queue is already consumed by another Worker | Cloudflare can leave the Queue consumer attached to the old Worker name after a rename | Run the exact `pnpm wrangler queues consumer remove ` command that `setup:cloud` prints, then rerun `setup:cloud`. Queues support one Worker consumer, so this is safer than letting deploy fail at trigger registration. |
| `wrangler --env dev` still uses the placeholder D1 id | `--env` alone still reads the tracked `wrangler.jsonc`, which intentionally keeps the public placeholder id | Use `pnpm setup:cloud`, which writes the real id into `wrangler.generated.dev.json`; `pnpm run deploy:dev` then overlays it onto the build. Raw `wrangler deploy --env dev` does not swap in the generated id. |
| `pnpm setup:mailbox --apply` still fails after you already inserted domain rows manually | Older/manual seed data can contain conflicting mailbox, alias, or routing rows even though the current script reuses the existing `domains.id` by domain name | Inspect `domains`, `mailboxes`, `aliases`, and `routing_rules` for that address. The script binds the alias and catch-all rule to whatever `mailbox_id` D1 already stores for that `primary_address`, so a stale row is corrected by cleaning the pre-live D1 data, not by rerunning with different inputs. |
| Telegram bot token is set but no new-mail card ever arrives | The bridge is configured but not yet delivering: no operator is linked, and/or nobody has sent `/start` so no chat is adopted, and/or the worker has not observed its public origin yet, so the cron cannot register the webhook | `GET /api/health` → `dependencies.telegram` reports `mode: "partial"` and lists exactly what is missing. Read the live pairing code out of `owner_pairing_codes` and send `/start `, load the authenticated UI once on the custom domain to teach the worker its origin, then wait for the hourly cron to register the webhook. |
| Local large-MIME smoke (`pnpm smoke:email:large`) fails around 1 MiB | Cloudflare's local Email Routing test path enforces a much lower size limit (~1 MiB) than the 25 MiB production inbound limit | Expected local-tooling behavior, not a bug — generate a fixture under ~1 MiB for local smoke (`pnpm generate:large-mime`), and trust the documented 25 MiB production limit (see [`docs/validation/PHASE0_VALIDATION.md`](docs/validation/PHASE0_VALIDATION.md)). |
For actual operating procedures, rollback, DLQ handling, and current retention/export limitations,
use [`docs/OPERATIONS.md`](docs/OPERATIONS.md). That document is the current-state runbook; this
README stays at deploy/setup depth.
## Learn more
- [`SECURITY.md`](SECURITY.md) — security model, hardening defaults, and how to report a
vulnerability.
- [`CONTRIBUTING.md`](CONTRIBUTING.md) and [`AGENTS.md`](AGENTS.md) — dev setup, PR expectations,
and the operating guide for AI coding agents working in this repo.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — accepted architecture, component responsibilities,
and tradeoffs.
- [`docs/IMPLEMENTATION.md`](docs/IMPLEMENTATION.md) — executable implementation runbook.
- [`docs/EMAIL-DELIVERABILITY.md`](docs/EMAIL-DELIVERABILITY.md) — recommended inbound/outbound
domain split, reputation isolation, DMARC ramp, and sender warm-up strategy.
- [`docs/OPERATIONS.md`](docs/OPERATIONS.md) — current-state ops reference (bindings, runbook,
data model).
- [`CHANGELOG.md`](CHANGELOG.md) — what shipped and when.