Skip to content

feat(email): headless authoring — projection, lens on the row, rule surface, scope hooks - #97

Open
agreenspan wants to merge 11 commits into
refactor/email-lookup-owner-tablefrom
feat/email-authoring-headless
Open

feat(email): headless authoring — projection, lens on the row, rule surface, scope hooks#97
agreenspan wants to merge 11 commits into
refactor/email-lookup-owner-tablefrom
feat/email-authoring-headless

Conversation

@agreenspan

@agreenspan agreenspan commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Stacked on #96 (→ #91#74). Headless only: no forms, no components.

The vocabulary this PR implements (rulings 2026-09-09)

  • Projection — what the system provides: a synthetic-root lens (EmailRuleContextrecipient, sender, data) over the field map, no narrowing of its own; the space a lens is built from. Today the whole map; it must become what the authoring actor's perspective can reach (COMM-012 §1).
  • Lens — a lens has a starting point. Each slot on the row is its own lens, built from the projection by choosing an entry point and then relations: recipient (entry fixed at User), sender (entry = the sender model), data (entry chosen by the author, lens.data.model; with none chosen it is the unknown bag — a Json root addressable at any depth, validated as beneath-Json, a warning never a rejection). EmailTemplate.lens holds the three, authored on the default-tier row, inherited through the cascade.
  • SurfaceexposedSurface(lens): what the builder receives. Never carries a where; the floor is bound from the sender at send time and never stored.

packages/email/src/rules/emailProjection.ts: emailProjectionemailLensemailSurface, plus emailRuleDecoration.

Commits

  1. deps — rules-builder 0.27.0 (the blessed version Zealot consumes) into packages/ui; mjml-preset-core for the nesting table.
  2. dbEmailTemplate.lens Json?, EmailComponent.expectations String[].
  3. rules/ — projection/lens/surface/decoration + the Zealot rules layer ported (#1689/#1655/#2171): collectHydrationPaths, walkLensPath, componentExpectations (rail-provided field is system.unsubscribeUrl here), collectJsonOpacityWarnings. Component save derives expectations from the body.
  4. render/regions.ts (collect/collapse/removeSlotOverride/slotDefaultContent), mjmlNesting.ts, and the authoring barrel (Zealot #1698/#2090).
  5. apiPOST /api/admin/emailTemplate/ruleSurface (superadmin; platform-level authoring for now): projection from the registry entry, narrowed by the default row's lens, plus decoration. Recipient-only for a slug the registry does not know.
  6. uiuseEmailRuleSurface (fetch) and useEmailVariableScope (resolve through rules-builder, walk as a scope stack: values are tokens, to-many relations are loop portals, entering one re-anchors at the element model and wraps copied values in the enclosing {{#each}} blocks with collision-free kebab bindings — the COMM-010 builder-surface ruling).
  7. COMM-012 — the adhoc-template design (demarcation, perspective, sender entry point for adhoc rows, registry picks vs the row lens, entry-from-row, the model-keyed-surface caveat) and the palette/shell plan for the forms PR.

Validation

packages/email 422/422 (rules 37, regions/nesting 26, projection 11); apps/api 1035 pass / 2 fail (the lens count-scope tests #95 fixes on main); route test 3/3; typecheck clean on db/email/api/ui; biome clean.

Not in this PR

The editor shell, variable picker, condition builder, and cascade-region list components (Zealot's ~1,400 lines of admin-dashboard React) — they consume exactly the exports and hooks landed here. Save-time lens validation for tenant saves (needs the registry, so it lands with the save endpoint). The registry-then-row entry fallback in sendEmail (COMM-012 §4).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP

@agreenspan
agreenspan force-pushed the refactor/email-lookup-owner-table branch from e18b0f8 to acf6bf5 Compare September 10, 2026 03:10
agreenspan and others added 9 commits September 10, 2026 00:10
…mjml-preset-core for the nesting table

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
The authoring lens lives on the slug's default-tier row as per-slot narrowings over the
projection the system provides; tenant rows inherit it through the cascade. A component's
expectations are the absolute paths its body demands, derived at save and never authored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
emailProjection composes a synthetic-root lens (EmailRuleContext → recipient, sender, data)
over the whole field map: what is reachable for a template, with no narrowing of its own.
emailLens applies the row's per-slot narrowings (engine defaults where the row is silent:
recipient = the delivery leaf, sender and data = their scalars) and emailSurface exposes the
result for the builder. emailRuleDecoration derives one facet per root relation.

Ported from Zealot's rules layer (#1689, #1655, #2171): collectHydrationPaths, walkLensPath,
componentExpectations (system.unsubscribeUrl is the rail-provided field here, not a recipient
field), collectJsonOpacityWarnings. Component save derives expectations from the body.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…, authoring barrel

Ported from Zealot (#1698, #2090): collectComponentRegions, collapseComponentBodies,
removeSlotOverride, slotDefaultContent, canNestMjml/MJML_CHILD_TAGS, and the authoring barrel
the editor consumes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…r a slug

POST /api/admin/emailTemplate/ruleSurface returns the projection for the slug (sender model
and data shape from the registry entry; recipient-only for a slug the registry does not
know) narrowed by the lens on the slug's default-tier row, plus the decoration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
useEmailRuleSurface fetches a slug's surface. useEmailVariableScope resolves it through
rules-builder and walks it as a scope stack: values are copyable tokens, to-many relations
are loop portals; entering one re-anchors the scope at the element model and wraps every
value copied inside in the enclosing {{#each}} blocks with collision-free kebab bindings.
No components — the forms come later.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
… the lens the row holds

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…dressable at any depth

The projection had bound data to a declared shape, which is not what data is: the
per-template payload nobody can declare ahead of the event. Its root is now a Json field —
every {{data.…}} path and every rule beneath it is addressable, and validation reports it as
beneath Json (a warning), never missing. A registry entry may still overlay a declared shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…point and relations

Each slot on the row is its own lens built from the projection: recipient's entry point is
User, sender's is the sender model, and data's is whatever the author chooses (lens.data.model)
with relations beneath it (lens.data.narrowing). No entry point = the unknown bag. COMM-012
records the perspective question and the palette/shell plan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
@agreenspan
agreenspan force-pushed the feat/email-authoring-headless branch from f051ce3 to 307e5a9 Compare September 10, 2026 03:14
…ort from their new homes; registry entries are lenses

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWJqEP
@agreenspan
agreenspan force-pushed the feat/email-authoring-headless branch from 307e5a9 to 3de1e4d Compare September 10, 2026 03:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant