CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
All notable changes to Reccado are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project
does not yet follow Semantic Versioning strictly (pre-1.0, self-hosted, no published package) —
version numbers here track meaningful release checkpoints of the self-hosted app, not an npm
package.
## [Unreleased]
### Changed
- **The auth perimeter moved from Cloudflare Access into the worker.** The issuer is now
**Better Auth 1.7.2** running inside the Worker — observable in the worker's own code and logs,
with no unverifiable edge perimeter to take on faith. `/login` opens a web session via e-mail
OTP with **registration closed at the issuer**: an OTP is only ever sent to an address the owner
registry (`owner_identities`) vouches for, bootstrapped by the renamed secret
`OWNER_BOOTSTRAP_EMAILS` (formerly `ACCESS_ALLOWED_EMAILS`). The Access validator,
`getAccessConfigStatus`, `ACCESS_JWT_AUDIENCE` and `ACCESS_TEAM_DOMAIN` are gone. The Telegram
bridge keeps its derived-secret perimeter, so the two perimeters still fail independently.
- **The pairing-code rescue now covers the web.** The same single-use, expiring
`owner_pairing_codes` ladder Telegram uses can open a session at `/login` when the code is
minted through `wrangler d1 execute` — the only safety net for a first login before mail sending
is configured or when the registry is empty.
- **`/mcp` authenticates with OAuth** through `@better-auth/mcp` (plus `jwt()` for stable signing
keys and `/jwks`); the owner gate still applies after the token. MCP clients need to reconnect.
- **Session cookie cache enabled** (5-minute signed cookie, one fewer D1 read per request). The
confirm-send path deliberately bypasses the cache so a revoked session takes effect the moment a
send is about to happen. Better Auth's database-backed rate limiter covers `/api/auth/*`, with an
optional outer WAF rate-limiting rule.
- D1 migrations `0016`–`0018` carry the Better Auth schema (auth core, MCP plugin, FK fix).
- `pnpm setup:access` and its smoke script are deleted; **`pnpm setup:auth`** replaces them:
machine-generated `BETTER_AUTH_SECRET` uploaded as a Worker secret (dry-run by default,
`--apply` to upload), base-URL and first-login/rescue guidance, and the WAF rate-limit rule for
`/api/auth/*` created via the zone rulesets API when a zone-scoped `CLOUDFLARE_API_TOKEN` is
present — or the exact dashboard steps and curl shape printed when it is not. The script never
fails on a missing token.
- **Recipient policies are validated when a key is created.** A policy is a comma-separated list
of `@domain`, exact-address or `*`-pattern rules (each optionally `!`-negated); a malformed rule
(empty entry, whitespace inside a rule, bare `!`, `@nodot`, `foo`) used to be stored and then
silently reject every recipient. It now answers `400 validation_error` naming the offending
rule, and no key is created. Deny-only policies remain valid. Nothing is normalized.
- The template create route's per-template checks moved into a shared `templateInputProblem`
(same error codes), used by both create and the new sync route.
Security hardening and public-readiness pass on top of the Phase 1 Tier A inbox, plus the
transactional API (Phases 2–4 of the `docs/plans/transactional-api.md` plan) and the MCP
read/search/draft endpoint.
### Security
- **The transactional status route verifies the key secret.** `GET
/v1/.../transactional/messages/:requestId` checked the key id, revocation and scope but never
the secret, and ignored expiry — and key ids are public (every send response carries one). Send
and status now share one authentication path (peppered constant-time secret check, then mailbox
binding, revocation, expiry, scope). Nothing about a key's state is disclosed before its secret
verifies.
- **Dead-letter tombstones no longer copy the message body into `ops_events`.** The DLQ consumer
stored the whole dead message as `payload_json.body`, so a dead-lettered Email Sending event
wrote the recipient's full address and the rendered subject into the ops log (observed on a
live row), and a dead inbound or notification message would have written sender, recipient,
subject and snippet. The payload is now `{ queue, attempts, summary }`, where `summary` is an
allow-list of identifiers per known shape: event type, event id, provider message id and
recipient *domain* for email events; trace id, mailbox id, R2 key, hash and size for inbound;
mailbox, message and thread ids for notifications; key names only for anything unrecognised.
Identifiers containing `@` are dropped. Existing `dlq.dead_letter` rows written before this
change still hold the full body and should be purged by hand.
### Fixed
- **Sent messages no longer show "(no text content)".** The send path stored the sent copy
without its body, so the viewer had nothing to render even though the mail went out intact.
Confirming a draft now stores the draft's text on the copy, and its HTML body in R2 under the
same key shape an inbound message uses, so the existing sandboxed `/html` route serves it. If the
R2 write fails the copy keeps its text and the send still counts as sent. Mailbox schema v7
fills in the text of sent copies that already exist, once per mailbox, by following the
`sent/<draftId>` key back to the draft. It only touches outbound rows with no body, skips a draft
that was edited after it was sent, and leaves inbound mail alone. The HTML body of older sent
copies is not backfilled, because that needs an R2 write the Durable Object constructor cannot
await.
- **Delivery events for mailbox sends no longer end in the DLQ.** The email-events consumer only
correlated against `transactional_request_log`, so every lifecycle event for mail confirmed
from a mailbox (request-send → confirm-send) was logged four times as
`email_events.unresolved` and dead-lettered. It now falls back to D1 `outbound_sends` by
provider message id (bare or angle-bracketed) and hands the event to a new internal DO route,
`/outbound/delivery-event`. The DO checks the id, sender and recipient against its own outbound
message row, records the event in the idempotent delivery-event ledger, and applies hard-bounce
and complaint suppressions to the DO mirror and the D1 projection exactly as for transactional
sends. Events that match nothing still retry and dead-letter as before.
- **`smoke:transactional --wait-delivery` no longer waits for an event that cannot come.** A
recipient that is a verified Email Routing destination address on the account is delivered
through Email Routing, which emits no Email Sending lifecycle event, so the wait could only time
out (and FAIL on a live domain). The smoke now reads the account's destination addresses
(`CLOUDFLARE_API_TOKEN`) and, for such a `--to`, prints a WARN and skips the wait.
`INTEGRATING.md` §5 and `OPERATIONS.md` document why feedback liveness can read `unobserved`
(or, after 24 h, `never_observed`/`went_dark`) for those sends without anything being wrong.
- **A definite provider refusal answers `502`.** The send path wrote `permanent_failure` into a
column whose CHECK only allows `failed`, so the request errored as a `500`, stayed `pending`
with its variables, replayed as `202` and was later reconciled to `unknown`. It is now stored
as `failed` / `errorCode: "permanent_failure"`, variables dropped, and a replay answers `502`.
- **Idempotency no longer depends on variable key order or folds case.** The payload hash is
now `v2:` over key-sorted variables, case-folding only the recipient. `{b, a}` after `{a, b}`
replays instead of `409`; a reused key with a token differing only in case is a `409` instead
of silently replaying the first send. Rows stored with the legacy hash are still compared with
it, so pre-upgrade retries keep replaying.
- **`key_revoked` / `key_expired` are returned** (after the secret verifies) instead of being
unreachable behind `invalid_api_key`. Docs no longer list a `duplicate` outcome, which the API
never returns — a replay answers with the original outcome.
- **Template routes require the mailbox to be yours.** Create, list, revise and archive
(`/api/mailboxes/:id/transactional/templates[/:templateId[/archive]]`) checked only that the
caller was an owner of the deployment, while the key routes — and the docs, for both — also
require the mailbox's `owner_email` to be the caller. Templates decide what a live key sends, so
they now get the same gate (403 otherwise).
- **`senderName` is accepted when creating an API key.** The create schema had no such field, so
zod stripped it: the dashboard form's sender name was silently dropped and operators had to
create then PATCH. It now takes an optional (nullable) `senderName` with the PATCH route's
header-injection-safe validation and returns it in the response; a bad name answers `400`, not
`500`.
- **`pnpm run deploy:dev` ships what `setup:sending` wrote.** It ran `wrangler deploy --env dev`
against the tracked `wrangler.jsonc` and never read `wrangler.generated.<env>.json`, so
`MAIL_SENDING_DOMAINS` written there never reached the Worker — for any domain — and
`setup:sending`'s closing text pointed at a different deploy path (`setup:domain`). Both
`deploy` and `deploy:dev` now run `scripts/deploy.ts`: build, overlay the generated file onto
`dist/server/wrangler.json`, print every value applied, re-read the file and refuse to deploy if
it does not carry them, then `wrangler deploy --config dist/server/wrangler.json`. `--dry-run`
stops at `wrangler deploy --dry-run`. **Precedence:** the generated file wins for the fields it
owns (`vars` per variable, D1 `database_id`, `send_email[].allowed_sender_addresses`, and
`routes` + `workers_dev` when `setup:domain` wrote routes); `wrangler.jsonc` wins for everything
structural, and disagreements there are printed as ignored.
- **`setup:domain` and `setup:cloud` no longer deploy a stale snapshot's bindings.** Their patch
step copied queues, Durable Objects, migrations and vars wholesale from the generated file — a
snapshot of `wrangler.jsonc` taken whenever it was first rendered. The live dev one still had a
single queue producer, so the next `setup:domain --apply` would have deployed without
`EMAIL_EVENTS_QUEUE` and `NOTIFY_QUEUE` and reopened workers.dev. They now use the same overlay
as `deploy`, and `setup:cloud` updates the D1 id in an existing generated file instead of
re-snapshotting over what `setup:sending`/`setup:domain` wrote.
- **Creating an API key through the API no longer loses the key.** The key routes read the
Durable Object's JSON to project it into D1 and then returned the already-read Response, so every
successful create answered `500` after the key was stored — the one-time plaintext secret was
gone and the key had to be revoked. Revoke, rotate and the sender-name PATCH had the same shape.
The D1 projection was also built from snake_case fields the DO never sends, so it was never
written by these routes. Covered end to end through the router in
`tests/integration/transactional-api-key-routes.test.ts`.
- **Transactional mail now carries the key's From display name.** The send path loaded the key
through a mapper that dropped `sender_name`, so every key sent as a bare address whatever name it
was configured with (`Eccos <…>` and `Transcribo <…>` went out as the address alone).
- **Wildcard recipient policies match.** `*@example.com` compiled to `\.*@example\.com` — the
escape ran after the `*` → `.*` substitution and escaped its dot — so a wildcard rule matched no
real address and an allow-list written that way rejected everyone. `@example.com` was unaffected.
- **Signed-out pages send you to `/login`** instead of a "Failed to load mailboxes: HTTP 401" card
with no way forward and no session to sign out of.
- **Reccado no longer reads "no delivery event" as evidence about a message.** Delivery outcomes
arrive through Cloudflare Email Sending event subscriptions, and a sending domain without one
produces no events for anything — so `delivery_status` stays null forever, an `unknown` never
resolves, and no bounce or complaint reaches the suppression mirror. Three features consumed that
silence as evidence, including a runbook row that inferred "no event ever arrived, so most likely
nothing was sent"; on a domain whose feedback channel was never live, that inference inverts.
The live account had four enabled sending domains and one subscription. The system now has a
concept of **feedback liveness per sending domain**: `pnpm setup:sending` creates-or-verifies the
subscription and exits non-zero rather than reporting success on a domain incapable of its
documented feature set; `pnpm doctor --cloud` crosses two live provider lists (enabled sending
domains × subscriptions on the events queue) and checks all three of existence, full event-type
coverage and destination queue, because existence alone is a false pass; `/api/health` →
`dependencies.sendingFeedback` and each transactional status response's new `deliveryFeedback`
field answer the question no config check can — whether events actually arrive — from our own
send log, with no Cloudflare credential. `unobserved` stays healthy on purpose: too early to tell
is an absence of evidence, and calling it a fault would repeat the original error backwards.
- **`pnpm doctor --cloud` ran no cloud checks at all.** It crashed on a TDZ `ReferenceError`
before the first one, so every `--cloud` invocation died with a stack trace instead of a report —
including the checks that would have passed. The opt-in block now runs below the declarations it
reads.
- **A failed transactional send no longer answers `200`.** `permanent_failure` and `unknown` were
returned with HTTP 200 and the failure only in the JSON body, so the obvious integration — throw
on non-2xx — reported an undelivered message as sent. Nothing undelivered is 2xx any more:
`permanent_failure` → `502`, `unknown` → `504` (distinguishable without parsing the body, because
they call for different handling), `accepted` → `202`, and `quota_exceeded` → `429` rather than a
`403` that gave the caller no reason to retry. The mapping lives in one tested function,
`httpStatusForTransactionalResult`, instead of a conditional at the response site. **Breaking for
any existing client**, of which there are none — the transactional API has never been called in
production.
- **Transactional variables are no longer retained after the send.** `variables_json` was written to
the mailbox Durable Object before the provider call and never removed, so every verification link,
password reset and invitation URL a caller passed accumulated indefinitely as live credentials.
Terminal states now null the column, as does the stale-request reconciler; `payload_hash` and
status remain, so idempotent replay and conflict detection are unaffected. D1 projections never
carried the values.
- **Suppressions expire when the reason does.** `addSuppression` always wrote `expires_at` as
`null`, so the column existed and was honoured at read time but was never populated — a hard
bounce poisoned an address permanently, with an owner-gated removal as the only way back. Hard
bounces now expire after 90 days; complaints, manual and provider-rejected entries stay permanent.
The distinction is the point: a hard bounce is a fact about a mailbox at a moment in time and
mailboxes come back, while a complaint is a statement of intent by a person and intent does not
lapse on a schedule.
### Added
- **`pnpm doctor --cloud` compares the tg-hq topic registry with D1 (`cloud.telegram-topics`).**
For the bound chat, each `telegram_topics` mapping is checked against the thread ids the owner's
`tg-hq` registry (1Password) records: match, missing from the registry, or thread id mismatch.
Mailbox-looking registry entries that no D1 row delivers to get a warning. The check is
read-only and never calls Telegram, and it prints only an info line when `op` is unavailable or
the bridge is bound to a different chat.
- **Operator control of the Telegram bridge — no raw D1, no second bot.** Four owner-session
routes under `/api/telegram/*` that use the worker's own (write-only) bot token, and the
commands that drive them:
- `GET /api/telegram/status` / `pnpm operator telegram status`: the bot's id and @username
(`getMe`), the bound chat as stored and as Telegram sees it, the bot's status and
`can_manage_topics` there, the tracked webhook state, and each active mailbox's topic.
Read-only toward Telegram; topics are reported unverified, because the only probe
(`editForumTopic`) renames.
- `POST /api/telegram/rebind` / `pnpm operator telegram rebind --chat <id> [--apply]`: the
audited override of the sticky `/start` adoption. Checks the chat and the bot's rights first,
writes `telegram.chat_id`, `telegram.chat_is_forum` and a `telegram.rebound` ops_event in one
D1 batch, keeps the old chat's topic mappings.
- `GET|POST /api/telegram/topics` / `pnpm operator telegram topic <mailbox> (--name … |
--adopt <threadId>) [--replace] [--apply]`: create a named topic or adopt an existing thread,
idempotently, 409 on a different mapping unless `--replace`.
- `POST /api/telegram/test` / `pnpm smoke:telegram [--mailbox …]`: one labelled test message per
mailbox through the real card send path, reporting `delivered_to_topic |
fell_back_to_general | delivered_to_chat | failed` from the `message_thread_id` Telegram
returns. Creates no reply link and no topic.
- **Topic names independent of the mailbox display name** (migration
`0020_telegram_topic_name.sql`, nullable `telegram_topics.topic_name`). A stored name wins over
`display_name` — the From name of the mailbox's replies — and survives the self-heal that
recreates a deleted topic; NULL keeps the previous behaviour. **Apply the migration before
deploying**: the card path reads the column.
- **`pnpm onboard` maps the mailbox's forum topic** with an optional
`telegram: { topicName?, adoptThreadId? }` manifest block and a `telegram:topic` step
(idempotent; blocked with a remedy when no forum is bound; never replaces a mapping).
- **`docs/INTEGRATING.md` — the integrator's guide to the transactional API.** Written from the
code for a product team sending through Reccado: who provisions what (mailbox on the zone,
sending identity on `send.`), one endpoint per mailbox with the key selecting the environment,
the mandatory `Idempotency-Key` and its replay/conflict rules, a table of every `status`/`error`
with its HTTP code and the right reaction (branch on `error`, never on `status` alone), the
status endpoint and `deliveryFeedback`, template syntax/escaping/exact variable sets and repo
sync, key scopes/policy/quota/rotation, and suppressions. Linked from the README and
`OPERATIONS.md`.
- **`pnpm onboard` — onboard a product from one JSON manifest.** Replaces the ~12 hand-run steps
(setup:sending for `send.<zone>` and the apex, `MAIL_SENDING_DOMAINS`, Email Routing rules,
domain / mailbox / aliases, template sync, API keys, the 1Password item) with
`pnpm onboard --env <env> --manifest <file> [--host <host>] [--apply]`. The manifest is
validated with zod up front (addresses on the zone, key senders on a provisioned name, recipient
policies through `validateRecipientPolicy`, key bodies through the create route's own schema;
`templates: "all"` = every id in the templates file). **Dry-run by default**: every step reads
current state (wrangler reads, public DNS over DoH, control-plane GETs, only the `key id` /
`RECCADO_ENDPOINT` fields of 1Password items) and reports `already` / `would do: <action>` /
`blocked: <reason>` with a remedy, writing nothing. `--apply` performs the missing steps in
dependency order with provision.ts's `already | done | skipped | blocked | failed`; a step that
cannot run skips only its dependents, and a second run is all `already`. Email Sending is
applied through `setup:sending --apply`; routing only creates missing literal rules and reports a
same-matcher/different-action rule as blocked (the catch-all is never touched); keys are
idempotent through the stored `key id` — a revoked/missing stored key is blocked, never a silent
second mint, a key without a `store` is refused, the plaintext reaches `op item create --template` through a
0600 temp file deleted in `finally` (never argv) and is never printed, and a key whose storage fails is revoked. Onboard never deploys: when the
generated config changed or the live Worker lacks a sending name, the report says a deploy is
required and prints `pnpm run deploy:dev --dry-run`. Placeholder manifest in
`examples/onboard/`; see docs/OPERATIONS.md "Onboarding a product".
- Shared pieces factored out of the setup scripts for it, behaviour unchanged:
`scripts/lib/sending-config.ts` (the generated-config write `setup:sending` performs),
`scripts/lib/wrangler-cli.ts` (its token-stripping wrangler subprocess), and
`scripts/lib/routing.ts` (the literal-rule argv `setup:routing` uses, plus parsers for
`wrangler email routing rules list` / `settings`).
- **`pnpm smoke:transactional` — an end-to-end smoke test against a deployed environment.** The
four transactional bugs fixed above shipped with every unit/integration test green because those
tests call the mailbox Durable Object directly; this drives the real HTTP surface instead, as
the operator (`pnpm operator` session, `/api/*`) and as an integrator (Bearer key, `/v1/*`).
Without `--send` it only checks the session and that the mailbox answers, and prints the plan.
With `--send` it creates a throwaway template and a narrow LIVE key (recipient policy = exactly
`--to`, quota 5, expires in 1h), asserts 201 + `plaintextKey` + listed, sets `senderName` via
PATCH, sends one real message, replays it with the same `Idempotency-Key` (same `requestId` and
`providerMessageId`), proves an off-policy recipient is refused `not_allowed_by_policy`, reads
status and `deliveryFeedback` (optionally polling with `--wait-delivery N`; silence is only a
failure when feedback liveness is `live`), and always revokes the key and archives the template,
printing hand-cleanup commands if that fails. The plaintext key is never printed. Pure logic in
`scripts/lib/smoke-transactional-core.ts`, tested in `tests/unit/smoke-transactional.test.ts`.
- **`pnpm operator login|whoami|logout` — control-plane sessions from the terminal.** Automates
the pairing-code rescue: mints a single-use 256-bit code into `owner_pairing_codes` via
`wrangler d1 execute` (only if the email is already in `owner_identities`, unless
`--allow-new-owner`), spends it at `/api/auth/pairing`, verifies it with `get-session`, and
stores only the Better Auth session cookie in `~/.config/reccado/sessions/<host>.json` (0600 in
a 0700 dir). A failure after minting force-expires the code. `logout` signs out server-side and
deletes the file even if that fails. The reusable API (`login`, `loadSession`, `logout`,
`operatorFetch`, `operatorJson`, typed `OperatorAuthError` on 401) lives in
`scripts/lib/operator-session.ts` for later operator scripts. Neither the code nor the cookie is
ever printed.
- **Idempotent template sync: `PUT /api/mailboxes/:id/transactional/templates`.** A product that
owns its templates in a file sends `{ templates: [{ id, subject, body_text?, body_html? }],
archiveMissing? }` on every deploy and gets per-id outcomes — `created`, `unchanged`, `updated`
(in place), or `archived`. An archived id in the list is reported (`already_archived`) and left
alone rather than revived; `archiveMissing: true` archives active templates not in the list
(`missing_from_sync`), the default never archives. The whole batch (max 100, no duplicate ids,
the create route's per-template rules) is validated before any write, and the writes run in one
DO transaction, so a bad entry changes nothing. Session + D1 owner gated like the key routes.
- **Router-level coverage of the From display name** —
`tests/integration/transactional-send-route.test.ts` sends through `/v1/.../messages` and
asserts the provider received `from: { name, email }`.
- **`setup:sending --apex`** (or `--subdomain @`) provisions Email Sending on the zone apex, for a
mailbox on the organizational domain that replies as itself — previously rejected and done by
hand. Same enable / SPF / DKIM / MX / 6-event subscription / `MAIL_SENDING_DOMAINS` steps; the
apex DMARC governs every sender on the domain, so `--dmarc-policy` is required there, the
current record is printed next to the planned one before Email Sending auto-creates its
`p=reject`, and a `rua` the new record would drop is called out. Dry-run stays the default.
- **`pnpm doctor --cloud` checks the deployed `MAIL_SENDING_DOMAINS` against Email Sending**
(fail: an entry not enabled in the account; warn: an enabled domain subscribed to the events
queue but not declared) **and each `_dmarc.<name>`** over DNS-over-HTTPS (warn on the provider's
auto-created `v=DMARC1; p=reject;`, on `quarantine`/`reject` without `rua`, and on zero or
multiple records). Offline `pnpm doctor` gains `config.deploy-overlay`, which shows what the
generated file overrides at deploy time.
- **Telegram: one forum topic per mailbox, not per email thread.** When the bound chat is a
supergroup with forum mode, each mailbox gets a topic named after the mailbox. Previously a
topic was created per email thread, named with the email's subject — which let anyone who sent
mail write the permanent labels in the operator's Telegram sidebar, and tied topic creation to
unbounded inbound volume. Forum support is now detected via `getChat` at `/start` and cached in
`runtime_config`, replacing the `TELEGRAM_TOPICS` flag (removed). Replies resolve only through
`reply_to_message`: a topic now holds many conversations, so "the newest card in this topic"
would answer the wrong email. A deleted topic self-heals — the stale mapping is dropped, the
topic recreated, and the card re-sent once.
- **Control-plane API reaches parity with the CLI.** `PATCH` and `DELETE` on mailboxes, domains,
aliases and routing rules, and `POST /api/mailboxes` now creates the alias for its own primary
address when the domain is registered. Delete is soft where other rows or stored data depend on
the identity (mailboxes, domains) and hard where the row is pure configuration (aliases, routing
rules). See `docs/IMPLEMENTATION.md` for the reasoning.
- **A disabled mailbox stops receiving mail.** `resolveRoutingForRecipient` now treats a mailbox's
own status as authoritative over the alias and routing rows that point at it, rejecting with
`mailbox_disabled`. Previously a disabled mailbox kept accepting mail, which would have made
`DELETE /api/mailboxes/:id` a lie, and a fall-through to the domain catch-all would have quietly
diverted someone's mail into a different mailbox.
- **Cloudflare Email Sending lifecycle integration.** Email Sending event subscriptions feed the
`inbox-mcp-email-events` Queue. Delivery, deferred, bounce, rejection, complaint, and failure
events update canonical transactional delivery state; hard bounces and complaints create
mailbox-DO suppressions that block future sends. Events are idempotent and unresolved/poison
messages retry to the configured DLQ.
- **Transactional API (Phase 2 — MVP).** External REST API at `/v1/mailboxes/:id/transactional/messages`
for programmatic outbound sending with:
- HMAC-authenticated API keys (`rck_<env>_<id>_<secret>`) with scopes
(`transactional:send`, `transactional:status`, `transactional:templates:use`),
per-key quotas and rate limits, template allowlists, and recipient policies.
- API key management (create, list, revoke, rotate) via the DO's internal routes.
- DO-authoritative auth and state — D1 is only a rebuildable projection.
- Atomic idempotency via `(key_id, client_idempotency_key)` with payload hash verification.
Same payload returns original result; different payload returns `409 Conflict`.
Request record is inserted BEFORE the provider call at status `pending`.
- Template CRUD (create, list, archive) per mailbox, with variable interpolation
(HTML-escaped for `body_html`, literal for text), variable validation (missing/unknown),
and subject CR/LF rejection.
- Provider outcome classification: `sent`, `permanent_failure` (definitely not delivered),
`unknown` (ambiguous — preserved for human review, never auto-retried).
- Status endpoint (`GET /v1/mailboxes/:id/transactional/messages/:requestId`)
scoped to `transactional:status`.
- Quota enforcement with atomic per-key minute and daily counters in the DO
(no D1 dependency for quota decisions).
- Rejection: missing/invalid auth, expired/revoked keys, wrong mailbox, insufficient scope,
missing template, template not allowlisted, missing/unknown variables, CR/LF in subject,
quota exceeded, recipient policy deny, test keys in production send path.
- Security: `Cache-Control: no-store` on all responses, JSON-only content-type check,
body size limit (100 KB), no CORS, no Access dependency, no Reply-To/headers from client.
- `TRANSACTIONAL_API_KEY_PEPPER` secret required to enable transactional features.
- Test keys (`environment=test`) are rejected in the production send path — they must not
silently send real email without a test/simulation sink.
- Provider error messages are NEVER stored; only stable error categories
(`ambiguous`, `permanent_failure`) are persisted.
### Added
- **Telegram bridge (optional, off by default).** Inbound mail is pushed to a Telegram chat as a
preview card (sender, subject, snippet — never the full body, which would put 2FA codes into a
cloud chat), and replying to that card drafts an email reply. The reply still goes through the
existing draft → request-send → confirm-send flow, now surfaced as inline ✅/✖️ buttons, and
through the same send path as the web UI. Threading resolves via `reply_to_message`, or via one
forum topic per email thread when `TELEGRAM_TOPICS=1`. The webhook lives outside the `/api/*`
perimeter (Telegram can present neither an Access JWT nor an `Origin` header) and authenticates
with a constant-time secret-token check plus a Telegram user allowlist; the bridge refuses to
start half-configured. See the README for the Cloudflare Access bypass this requires.
- `MAIL_SENDING_DOMAINS`: mailboxes on a domain verified in Cloudflare Email Sending now reply as
themselves instead of as the single global `MAIL_FROM_ADDRESS`.
- The Telegram webhook registers itself: the hourly cron (`reconcileTelegramWebhook`) compares
what Telegram believes against this deployment's origin and re-registers on drift. The worker
learns that origin by observing a request that already cleared Cloudflare Access, and the
webhook secret is an HMAC of the bot token, so no URL and no secret is ever configured by hand.
- The bot adopts its chat on the first `/start` from an allowlisted user and stores it in D1
(`runtime_config`), first writer wins. `/start` now also answers *unauthorized* users with
their own user id, so a first-time operator can learn the id they need for
`TELEGRAM_ALLOWED_USER_IDS` instead of getting silence.
- **MCP endpoint (`/mcp`) — read/search/draft only.** Access-authenticated (requires
`ACCESS_ALLOWED_EMAILS`, fails closed with `503` when unset). Exposes `list_mailboxes`,
`list_threads`, `search_messages`, `read_message`, and `draft_reply`; there is deliberately no
send/cancel tool (`src/mcp/*`, with a closed-operation `McpMailboxFacade` and a security test
asserting no send path is reachable). `pnpm setup:mcp-claim` backfills `mailboxes.owner_email`
for pre-existing mailboxes so MCP can serve them.
### Known limitations
- Telegram replies go to the original sender only (no reply-all) and are sent from the mailbox's
primary address even when the mail arrived at an alias.
- Attachments cannot be sent from Telegram.
- `request-send` now refuses to move a `sent` or `cancelled` draft back to `pending_confirmation`,
so a retried Telegram update can no longer resurrect mail that already went out.
### Known limitations (transactional API)
- Cloudflare Email Sending event subscriptions must be configured separately for each sending
domain; Wrangler queue bindings alone do not create the subscription. Cloudflare's account
suppression list remains upstream authoritative; Reccado's DO suppression list is a local
enforcement mirror and does not automatically unsuppress recipients.
- Test keys (`environment=test`) are rejected in the production send path. A simulated/test sink
for test-mode delivery does not exist yet; test keys only work for creation/listing/revocation
operations.
- Stale transactional-request reconciliation remains manual-review-oriented: it flips stuck
`pending`/`sending` rows to `unknown` and never retries delivery automatically.
### Removed
Configuration surface that the system can decide for itself, deleted rather than deprecated. Every
one of these was a value a human had to invent, store and keep in sync, and every one of them had a
failure mode where the deployment looked configured and silently did nothing.
- **`MAILBOX_ID_SECRET` (worker secret).** Mailbox ids are no longer an HMAC of the primary
address: they are 16 random bytes assigned by the `INSERT` that creates the row
(`INSERT ... ON CONFLICT(primary_address) DO NOTHING` + re-read), with D1 as the only source of
truth. Nothing at runtime ever recomputed an id — ingest resolves the mailbox by D1 lookup — so
the secret only bought offline recomputation at the price of making one write-only value part of
the identity of every mailbox. The id format (`mbx_` + 26 base32url chars) is unchanged, so
existing rows, R2 keys and Durable Object names stay valid and no migration is required. Gone
with it: `wrangler secret put MAILBOX_ID_SECRET`, `setup:cloud --reset-secret`,
`setup:mailbox --secret`, the `mailboxSecretFingerprint` field in `.reccado/setup.<env>.json`
(the manifest keeps every other field), the `devvars.mailbox-secret`, `cloud.secret.mailbox` and
`cloud.mailbox-secret-recovery` doctor checks, and the whole "orphaned secret" / "rotating the
secret invalidates every derived mailbox id" class of failure, which can no longer occur.
- **`TELEGRAM_WEBHOOK_SECRET` (worker secret).** Derived instead of configured:
`HMAC-SHA256(TELEGRAM_BOT_TOKEN, "reccado:telegram:webhook-secret:v1")` (`deriveWebhookSecret`,
`src/telegram/api.ts`). The only two parties that need this value are the worker and Telegram,
so asking a human to invent one, store it as a secret and re-supply it to a script was three
manual steps for a number with no human meaning — and the step everyone forgot, which is how the
bridge stayed dead in production without a word. It is deliberately not persisted in D1: it is
what stops a forged update from sending mail, and the allowlist such an update must satisfy is
itself a D1 row. Rotating the bot token now rotates it automatically.
- **`TELEGRAM_CHAT_ID` (var).** Auto-adopted instead of declared: the first `/start` from an
allowlisted user writes the chat to `runtime_config` in D1, first writer wins — a later `/start`
from another chat does not move the binding, and the bot says so. The value always arrived inside
the update, so the old flow had the bot print the chat id and ask the human to copy it into a
config file and redeploy: the system observing a value, telling a human, and asking that human to
tell it back.
- **`scripts/setup-telegram.ts` / `pnpm setup:telegram`.** Replaced by the hourly cron
reconciliation (`reconcileTelegramWebhook`, `src/telegram/registration.ts`). Telegram onboarding
is now: create the bot and set `TELEGRAM_BOT_TOKEN`, put your user id in
`TELEGRAM_ALLOWED_USER_IDS`, deploy, load the authenticated UI once (that is how the worker
learns its own public origin — only ever from a request that cleared Access, because the `Host`
header is forgeable and that value decides where Telegram delivers), then send `/start`.
`GET /api/health` reports `dependencies.telegram` with `mode: off | partial | on` and a `missing`
list, so a bridge that is not yet delivering says what it is waiting for.
The allowlist (`TELEGRAM_ALLOWED_USER_IDS`) stays configuration on purpose: declaring whom you
trust is not derivable, and whoever can drive the bot can send mail as the operator.
### Fixed
- **Outbound replies now thread.** `confirm-send` sent no `In-Reply-To`/`References`, so a reply
appeared as a loose message in the recipient's client; and it minted a fresh thread id per send,
so the reply also detached from its own conversation inside Reccado. Replies now reuse the
draft's thread, carry the RFC 5322 reply headers, and upsert the thread row instead of inserting
a duplicate.
- Sent messages now store the `Message-ID` Cloudflare assigned (it is a platform-controlled
header — a caller-supplied one is rejected as restricted) in the mailbox and the D1 index.
Without it, the answer to a reply matched nothing in `resolveThreadId` and forked a new thread on
every round trip.
- Outbound thread subjects are normalized with `normalizeSubject` rather than a raw `toLowerCase`,
so `Re:`-prefixed replies no longer defeat subject-based thread matching.
- A reply sent from a mailbox on an unverified domain now sets `Reply-To` to the mailbox address,
so responses come back to the address the human wrote to.
### Security
- Debug endpoints (`/api/debug/phase0/*`) now fail closed: they are unreachable unless
`PHASE0_DEBUG_TOKEN` is explicitly configured, and requests are compared against it in
constant time. Previously, an unset token left these endpoints open.
- Attachment and raw-message downloads are now served with `Content-Disposition: attachment`,
`X-Content-Type-Options: nosniff`, and a sandboxing Content-Security-Policy, preventing stored
attachment content (including inbound HTML/script content) from executing in the browser
origin.
- `seedDevData` (the dev-only `test@example.com` mailbox/domain/alias scaffolding) is no longer
invoked implicitly from the production inbound email path; seeding now requires an explicit
opt-in and is a no-op by default.
- Added an optional `ACCESS_ALLOWED_EMAILS` owner allowlist, enforced on top of Cloudflare
Access, so a shared Access org doesn't implicitly grant every authenticated identity full
mailbox access.
- Added an inbound email size cap (~25 MiB, matching Cloudflare's Email Routing inbound limit)
enforced before the raw message is buffered/stored.
- Added baseline security response headers (`X-Content-Type-Options: nosniff`,
`X-Frame-Options: DENY`, `Referrer-Policy`) and an Origin-check CSRF defense on mutating
`/api/*` routes.
- Added stale outbound-send reconciliation in the scheduled (Cron) handler: `outbound_sends` rows
stuck in `sending` past a timeout are flipped to `unknown` with
`error_code='stale_sending_timeout_needs_review'` and a clear `ops_events` trail instead of
blocking idempotent retries indefinitely (manual review required).
### Tooling & docs
- Pinned wrangler moved 4.106.0 → 4.127.1, which is the version that first exposes
`--source email.sending` on `queues subscription create` (plus `--zone-id`/`--domain`) and a
`--json` mode on `queues subscription list`. That turns the per-domain Email Sending event
subscription from a dashboard-only step into something the setup scripts can create and verify.
`pnpm setup:sending` still probes the CLI's own `--help` before shelling out, so an older
wrangler yields "unresolved, here is the exact command" rather than an opaque failure.
- Added `PRODUCTION-READINESS.md` to declare the current production claim honestly and record the
remaining gaps that still keep Reccado at `READY-WITH-CAVEATS` rather than an unqualified
production-ready status.
- Updated `docs/ARCHITECTURE.md` status language so it reflects the current repo state: Tier A and
the minimal MCP read/search/draft endpoint are implemented, while the rest of Tier B
(Workflows, EmailAgent drafting, RAG/Vectorize, AI Gateway) remains roadmap-only.
- Docs now reflect the actual state of the transactional API (Phases 2–4 of
`docs/plans/transactional-api.md`): HMAC/pepper API keys, Access+owner-gated admin routes,
versioned template CRUD, `POST/GET /v1/.../transactional/...` with Bearer +
`Idempotency-Key` + scopes/limits/quota, test-key rejection for sending, `unknown` → no
auto-retry, non-authoritative D1 projections, log redaction, no CORS/query/cookies, and the
Cloudflare Email Sending event subscription setup and no simulated delivery sink for test keys.
`SECURITY.md`, `README.md`, `docs/OPERATIONS.md`, `AGENTS.md`, and
`PRODUCTION-READINESS.md` were updated; the MCP/UI/Telegram human-confirm gate is unchanged and
Tier B (Workflows, EmailAgent, RAG) is not claimed as implemented.
- Added Biome for linting/formatting (`pnpm lint`, `pnpm format`, `pnpm format:check`) and a
combined `pnpm check` (typecheck + lint + test) script; wired into CI alongside a Worker
bundle dry-run deploy check and a check that generated artifacts
(`worker-configuration.d.ts`, `src/routeTree.gen.ts`) are committed and up to date.
- Pinned a minimum Node engine (`>=22.12.0`) in `package.json`.
- Added a `d1:migrate:prod` script and split production D1/R2/Queue resource names from the `dev`
environment's; the default (production) environment now sets `workers_dev: false` so it isn't
reachable on the shared `*.workers.dev` subdomain.
- Restructured `README.md` into a full product README (features, architecture diagram,
quickstart, deploy guide, configuration reference, compatibility, troubleshooting).
- Added `SECURITY.md`, `CONTRIBUTING.md`, `SKILL.md`, `CHANGELOG.md`, and `docs/OPERATIONS.md`.
- Split `AGENTS.md` into durable invariants vs. one-time build/process notes, and scrubbed
maintainer-specific Cloudflare resource literals (D1 database ID, account/worker URLs) in favor
of placeholders.
- Marked `docs/validation/PHASE0_VALIDATION.md` and `docs/validation/PHASE1_VALIDATION.md` as historical
build-validation logs rather than current operating docs.
- Fixed a stale "Inbox MCP" UI string (the product was renamed to Reccado).
### Self-host setup hardening
- `setup:cloud` now checks the inbound queue's consumer before deploying and aborts with the exact
`wrangler queues consumer remove <queue> <old-worker>` fix if a different Worker (e.g. left over
from a rename) is still registered as its consumer, instead of letting deploy fail with a
generic error.
- `setup:mailbox` resolves the domain row by name and reuses it instead of assuming a fresh insert,
so reseeding an env with pre-existing (including foreign-key-owning) domain rows is idempotent
rather than failing.
- `setup:routing` adds `--catch-all`, which configures `*@<domain> -> Worker` via Cloudflare's
Email Routing REST API (`PUT .../rules/catch_all`) because Wrangler currently rejects that rule
shape client-side even though the platform endpoint supports it.
- Added `setup:domain`: attaches a custom domain to a Worker env through a generated, gitignored
Wrangler config (never edits the tracked `wrangler.jsonc`). Idempotent for the same Worker;
refuses to reattach a hostname already claimed by a **different** Worker, checked up front via
the Workers Custom Domains API when `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID` are available,
falling back to an error-string guard around `wrangler deploy` otherwise.
- Added `setup:sending`: provisions a dedicated Cloudflare Email Sending subdomain (`send.<domain>`
by default) and writes `MAIL_FROM_ADDRESS` + `allowed_sender_addresses` into the generated
config. Prints a prominent Workers Paid preflight (arbitrary-recipient sending needs a paid plan;
free-plan accounts are limited to verified destinations). DMARC defaults to `p=none` (monitor
mode, relaxed alignment) as the start of a documented none → quarantine → reject ramp
(`--dmarc-policy`, `--dmarc-alignment relaxed|strict`, `--dmarc-rua`). With
`CLOUDFLARE_API_TOKEN` it also auto-adds the provider-generated DKIM + MX records (parsed from
`wrangler email sending dns get`, an open-beta command with no `--json` mode) unless
`--skip-provider-records` is passed; SPF and DMARC remain exclusively script-owned, so the
provider's own suggested DMARC record (typically `p=reject`) is never applied.
- Aligned the `workers.dev` vs. custom-domain narrative across `doctor`, `setup:domain`, and
`setup:access`: the `dev` environment keeps `workers_dev: true` only for local-to-cloud smoke
tests, and Access verification is only ever treated as valid against a custom domain.
## [0.1.0] — Phase 1: Tier A inbox complete
Initial senior-validated milestone: a usable self-hosted inbox without any AI/agent layer.
### Added
- **Inbound hot path**: Cloudflare Email Routing → R2 (raw MIME) → Queue (metadata-only,
<128 KiB) → mailbox Durable Object, with idempotent ingest (Message-ID + raw-hash dedupe),
Message-ID conflict detection, and a Dead Letter Queue for poison messages.
- **Mailbox Durable Object store**: one Durable Object (SQLite storage) per mailbox owns
messages, threads, labels, contacts, drafts, outbox, idempotency records, and a SQLite FTS5
full-text search index.
- **HTTP API + UI**: Hono API under `/api/*` and a TanStack Start inbox UI (mailbox list, thread
view, message detail, search, raw view, attachments, compose).
- **Realtime**: hibernatable WebSocket connections (`/api/mailboxes/{mailboxId}/ws`) push new
mail and state changes into open UI sessions without polling.
- **Human-confirmed outbound sending**: drafts go through an explicit
`request-send` → `confirm-send` flow with an idempotency key before `env.EMAIL.send()` is
called; sent messages are indexed and audited in D1 (`outbound_sends`).
- **Multi-domain routing**: per-domain store/forward/reject routing rules, with isolated
mailboxes per address; validated against two real Cloudflare-managed domains.
- **Backup and ops**: scheduled (Cron) backup sweep writing per-mailbox manifests to R2, plus
admin endpoints for ops events, DLQ inspection, and D1 reindexing from Durable Object state.
- **D1 control-plane index**: `domains`, `mailboxes`, `aliases`, `routing_rules`, `message_index`,
`ingest_events`, `outbound_sends`, and `ops_events` — a rebuildable cross-mailbox index, not the
source of truth (the mailbox Durable Object is).
- D1 migrations `migrations/d1/0001_initial.sql` and `migrations/d1/0002_message_index.sql`.
### Notes for self-hosters
- Schema/migration changes in this and future releases matter for anyone running their own
instance: always run the `wrangler d1 migrations apply` step (`d1:migrate:local` /
`d1:migrate:dev` package scripts) after pulling a release that touches `migrations/d1/`, and
review new migrations before applying them to a database with real mail in it.
- Validated end-to-end against real Cloudflare resources in a `dev` environment; see
`docs/validation/PHASE0_VALIDATION.md` and `docs/validation/PHASE1_VALIDATION.md` for the historical validation
record (debug tokens, deployment names, and account identifiers in those files are the
maintainer's own and have since been redacted/placeholder'd where they leaked into current
docs).
[Unreleased]: https://github.com/santigamo/reccado/compare/main...HEAD
[0.1.0]: https://github.com/santigamo/reccado/releases/tag/v0.1.0