docs: propose ADR 048 — the Dev Inbox capability - #710
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
There was a problem hiding this comment.
Pull request overview
Proposes ADR 043 to define a future server-side “capture” transport for outbound email/OTP messages so end-to-end tests (via @zitadel/testing) can reliably observe verification codes / links without relying on SMTP sinks or log scraping.
Changes:
- Adds proposed ADR 043 describing a transport abstraction with an opt-in, bounded capture buffer and an operator-authenticated read API.
- Updates the ADR index to include ADR 043.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| docs/adrs/README.md | Adds ADR 043 to the ADR index table. |
| docs/adrs/043-test-visible-email-otp-capture.md | New proposed ADR specifying the test-visible capture contract for future email/OTP delivery. |
# Conflicts: # docs/adrs/README.md
livio-a
left a comment
There was a problem hiding this comment.
Review
Approve as Proposed. Strong capability ADR — composition/delivery split, one operator API for UI/CLI/kit, parallel-safe cursor semantics, and the security/handoff rules are in good shape. Greenfield timing is right.
I love that this solves several pain points we have with Zitadel v4 in one contract: no SMTP-sink / log-scraping for E2E, structured artifacts instead of parsing rendered mail, a real inbox for humans and agents over the same API, parallel-safe waits without destructive clear(), capture as a first-class delivery mode instead of a test-only side channel, and cloud/dev deployments that no longer need a default SMTP just to get a working environment — capture is the zero-config path until a real provider is required.
Accept gate
The two platform-overview amendments already named in the ADR should be scheduled (or linked as follow-ups) when this moves to Accepted:
- Claimed production must not default to Dev Inbox (
provider_required). - Scratch dashboard anonymous cookie is insufficient for inbox secrets — secret-mediated handoff.
Open questions — provisional leans (happy to discuss)
Recording these so build step 2 doesn’t thrash; fine to keep as open questions with a “lean” note:
- Composition locus — prefer an async notification path (flow emits intent; worker composes/delivers or captures). Thin enqueue-from-flow is fine; flow-owned mailer is not. Artifacts still sourced from flow/challenge state as the ADR says.
- Durable store — in-memory until shared/cloud/multi-replica (or restart must preserve unread). ADR 029 applies once durable.
- Support access — inherit #333; default deny for staff until that model exists. Don’t invent inbox-specific policy here.
- Consumed-state — expiry display is enough for correctness. Prefer an optional explicit mark-consumed API (operator plane) over auto-consume on verify (avoids flow coupling / resend races). Kit ignores it.
- Port — same operator port/API. Later optional runtime flag (or gateway rule) to disable/block the inbox path prefix wholesale — no separate debug listener.
Nice-to-haves (non-blocking)
- Normative purpose → required-artifacts table (prose “code and/or link” is soft for kit types).
- One line on how “email-dependent flows” are detected at deploy/mutation time.
…cceptance checklist
# Conflicts: # docs/adrs/README.md
Why
Flows that deliver a secret out of band — email verification, email-OTP login, password recovery (ADR 038) — cannot be tested end-to-end, and won't be when they arrive, unless tests can observe what the server sent. The server has no email delivery subsystem at all yet, which makes this the cheapest possible moment to fix the contract. And the platform overview already narrates the capability this ADR specifies: the scratch dashboard's dev inbox and the "Dev Inbox / Bring Your Own / Managed" delivery picker. This ADR is the server-side contract behind that story.
What changed
ADR 048 (Proposed): The Dev Inbox — Captured Outbound Messages. Rewritten from the initial "capture transport" draft after two review inputs — a competitor scan (Supabase, Resend, Clerk, Firebase emulator, Stytch, Better Auth) and a codex review — into a product-capability framing:
capturestores messages in the project/environment-scoped Dev Inbox (ADR 035 scoping, ADR 036 operator plane).schema_version) carrying typed, purpose-discriminatedartifacts(code/link — populated by composition from the flow engine's own state, never parsed from templates;variablesstays a non-API record of render inputs) plusrenderedforms for humans. Two timestamps with distinct meanings:expires_at= artifact/challenge validity (guaranteed shared per record),retained_until= store retention.zitadel dev-inboxJSON commands, and@zitadel/testing(cursor()+waitForMessage({after, to, purpose, artifact})— the artifact-key predicate makeswaitForCode()wait for a code-bearing record, since a purpose may produce link-only messages; keys, never values, appear in list metadata) — no test-levelclear(), which would be cross-worker destructive under the kit's shared-instance parallelism. Waits are bounded and cancellable (timeoutMs,AbortSignal, typed timeout error echoing the resolved filters), and every consumer call binds an explicit environment (InstanceHandle.environmentId; CLI--envwith deterministic resolution, echoed in the envelope).zitadel start/startLocalZitadel()enable it,connectZitadel()discovers it; no tee mode.provider_requiredenvironment policy (ADR 035 defers env-classes, so the ADR defines the enforceable bit instead of a classification) — enforced at deploy, on every provider/policy mutation, at startup, and at send time (a send without a working provider fails loudly, never falls back to capture); one deliberate divergence from the overview's current "Dev Inbox (default)" for claimed production, flagged in the ADR.zitadel dev-inbox open, with the transfer specified: fragment-carried token (never in access logs), POST exchange,history.replaceState,Referrer-Policy: no-referrer, log redaction.zitadel startnever emits a bearer-bearing URL — it reportsavailablevsconfiguredcapability state and points atopenonly via a human-facingnext_actionshint, nevernext_commands(which agents execute by contract, and which would also fail pre-setupwhen no secret exists). The scratch dashboard's anonymous first-visit cookie is called out as insufficient for inbox content — a second overview amendment.variablesalone would be theater whilerendered.*embeds the same secret; the full message is a separately authorized read. Any durable backend encrypts message content at rest (ADR 029); only the ephemeral in-memory store is exempt.@zitadel/api-mock), admin-mint (hash-only storage + flow-binding), kit-embedded SMTP listener.Also in this branch's history: renumbered as
mainclaimed numbers in flight — 042 → 043 → 044 → 046 → 047 → 048 (042 scaffolded-file ownership, 043 framework version floors #713, 044+045 scaffold posture and copy overlays #714, 046 claim lifecycle v2 #627, 047 dialect-owned identifier generation #663) — withmainmerged in at each collision; the file is now048-dev-inbox.md.Validation
Docs-only.
Review notes
Status is Proposed — a conversation starter. The first review round (livio) recorded a lean on every open question, now folded into the ADR: composition via a typed flow intent + async notification boundary (v4-shaped enqueue → compose → capture|deliver, wide-events/River as the bus); durable store only on multi-replica / shared scratch inbox / unread-survives-restart (ADR 029 bar applies equally to queue payloads); default-deny support access until #333; optional explicit
consumed_atover auto-consume; one operator port with an optional path-prefix kill-switch. The same round added a normative purpose × artifact-key table, the static email-dependence detection signal for theprovider_requiredgates, and an "On acceptance" checklist carrying both platform-overview amendments (claimed-production delivery default; scratch-inbox session acquisition). The leans harden into decisions when the ADR moves to Accepted.A later
mainmerge brought #722's ADR 036 amendment (credential exposure contracts), which governs exactly the fragment-carried handoff token this ADR specifies — the BFF section now carries an explicit conformance passage instead of a silent divergence: the exchange behaves as a credential-establishing operation (browser-attestedOriginenforced, so an exfiltrated token can't be exchanged from a foreign context), the minted session is inbox-scoped and can never become a login session, and the endpoint adopts ADR 036's PKCE-style proof binding for URL-transiting handoffs once that machinery ships.