Skip to content

feat(email): concentric slot pass-through — fill a slot re-exposed through a nested component - #91

Open
agreenspan wants to merge 11 commits into
claude/email-interpolation-slots-pnnktifrom
claude/email-nested-pass-through-rfu4ic
Open

feat(email): concentric slot pass-through — fill a slot re-exposed through a nested component#91
agreenspan wants to merge 11 commits into
claude/email-interpolation-slots-pnnktifrom
claude/email-nested-pass-through-rfu4ic

Conversation

@agreenspan

@agreenspan agreenspan commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #74 (the MJML slot engine). Rebuilt 2026-09-08: the concentric pass-through, send-path cycle guard, and duplicate-exposed-slot validation moved into #74 (they are the Zealot-parity engine shape — expand is the single guarded walk, parseBlocks the single gate). What remains here is template-specific:

  1. Send-time delivery hardening — a derived text/plain part and a sanitized subject.
  2. Send-time content capturesettledMjml + variables on CommunicationLog, plus the structured component version closure (CommunicationComponentVersion junction).
  3. Recompose rebuilt on the slot engine — snapshot recomposition renders through expandWith with the pinned bodies as its loader.

1 — Send-time delivery hardening

  • sanitizeSubject — reverses the interpolation escaping (lodash unescape mirrors the escape applied — O'Brien no longer ships as O'Brien), replaces control characters (CR/LF header-injection guard), collapses whitespace.
  • deriveTextFromHtml — derives the multipart text/plain alternative from the rendered MJML output. A configured wrapper around html-to-text@10 pinning the email contract: block boundaries as newlines, label (href) links with same-target dedupe, image alt text, nbsp/blank-run normalization, and unbounded table columns so MJML layout tables never rewrap body copy.
  • deliverEmail applies both at the client handoff; SendEmailOptions/the Resend payload carry text.

2 — Send-time content capture

The existing pin (emailTemplateAuditLogId) preserves the raw stored template at its sent version, but nothing preserved what actually went out: expand resolves components through the sender's cascade at send time, and one template row serves many senders — a save-time pin structurally cannot encode per-send resolution. Two captures, both written at the sending claim:

  • The documentsettledMjml (post-expand, post-interpolate MJML) and variables (the resolved lens payload, now returned on SettledTemplate) on CommunicationLog. Raw body deliberately not duplicated (the audit row carries it); HTML derivable from settledMjml.
  • The structured closureCommunicationComponentVersion, one row per (send, slug): the EmailComponent row the sender's cascade actually resolved (transitively) and that component's latest audit snapshot id. composeTemplate records the resolutions from its own cascade loader (no hook on expand) and returns them as componentResolutions; the claim transaction rewrites the rows on a re-claimed retry (unique on (communicationLogId, slug)).

3 — Recompose on the slot engine

recomposeSnapshot substituted child snapshots with a regex that replaced the whole ref span — destroying the caller override slots the slot grammar keeps inline — and recursed unbounded on a cyclic snapshot graph. Now: walk the pinned componentVersions graph breadth-first collecting each slug's snapshot body (nearest pin wins, visited set bounds cycles), then expandWith(root.mjml, loader) with those bodies — override injection, empty-fill semantics, and the render cycle guard come from the engine.

Validation

  • packages/email: 369/369; apps/api: 1032 pass, 2 fail — the lens count-scope tests test: provide conditions for lens count-scope regressions #95 fixes on main. Typecheck clean on db/email/api; biome clean.
  • New communicationLogFactory (the closure test created its log directly, with a Space sender carrying an organization id the polymorphism axis forbids — passed alone, failed under the rules hook in the full run).

Notes

  • Commits 1 and the sanitize/text pieces have Zealot mirrors (userevidence/Zealot-Monorepo#2112); 2–3 are template-specific (Zealot has no audit system yet).
  • Remaining follow-up: builder region/decoration badging of a re-exposed slot (Zealot #1698 regions.ts).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP

claude and others added 11 commits September 10, 2026 00:08
…d time

Sends were HTML-only and the subject reached the provider raw. Interpolation
always HTML-escapes substituted values, so a recipient named O'Brien produced
O'Brien in the Subject header, and nothing stripped CR/LF from substituted
values — the header-injection vector for a value carrying a newline. A missing
text/plain part also costs spam score and accessibility.

sanitizeSubject reverses the interpolation escaping (lodash unescape mirrors
the escape applied), replaces control characters with spaces, and collapses
whitespace. deriveTextFromHtml derives the multipart alternative from the
rendered MJML output: drops head/style/script, maps block boundaries and <br>
to newlines, keeps link targets next to their labels, decodes entities, and
collapses blank-line runs. deliverEmail applies both where the payload is
handed to the client; SendEmailOptions and the Resend payload carry the new
text part.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF
The send pin (emailTemplateAuditLogId) preserves the raw stored template at
its sent version, but nothing preserved what actually went out: expand
resolves components through the sender's cascade at send time, interpolate
fills the lens variables, and both outputs lived only in job memory. A
component edit or a different sender scope made the sent email unreconstructable.

Persist both at the sending claim: settledMjml — the post-expand,
post-interpolate MJML handed to mjml2html, which embeds the send-time
component resolution — and variables, the resolved lens payload
(sender/recipient/data plus the per-kind system vars), now returned on
SettledTemplate. The raw template body is deliberately not duplicated here:
the pinned audit row's own snapshot already carries it. Rendered HTML is
likewise derivable from settledMjml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF
…bstitution

recomposeSnapshot substituted child snapshots with a regex that replaced the
entire component ref span — including the caller's override slots, which the
slot grammar deliberately keeps inline on the ref. Since the slot engine
landed, that meant recompose dropped every override and rendered defaults,
and a cyclic snapshot graph recursed without bound.

Rebuild it on renderBlocks: walk the pinned componentVersions graph breadth
first collecting each slug's snapshot body (nearest pin wins, visited set
bounds cycles), then render the root snapshot with those bodies as the
loader — override injection, empty-fill semantics, and the render cycle
guard all come from the engine instead of a parallel substitution path. The
pure core (recomposeFromSnapshots) takes an injected snapshot loader so it
is testable without a database; the db-bound wrappers stay thin. A dangling
or cyclic pin now renders empty instead of leaking raw grammar or hanging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF
…in deriveTextFromHtml

The tag pattern stopped at the first > inside a quoted attribute, so
<img alt="a > b"> leaked b"> into the plaintext part, and only a minimal
entity set decoded, so &copy;/&#169;/&#x1F600; shipped as literal source in
text while the HTML rendered them. Match tag bodies with quoted-attribute
awareness everywhere a tag is consumed, contribute an image's alt text
instead of dropping it, and decode numeric, hex, and the common named
entities (amp still last and skipped by the named pass, so double-encoded
input stays literal). Mirrors the review fix in Zealot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF
…veTextFromHtml

HTML comments and doctype declarations leaked verbatim into the text part,
a closing </style > with whitespace escaped DROPPED_SECTIONS so stylesheet
text shipped as content, and href extraction required quotes with no
attribute-name boundary — an unquoted href lost its destination and a
data-href appearing first hijacked the link target. Consume comments and
doctypes as markup, allow whitespace before > on closing tags this helper
matches, and extract href/alt with an attribute-name boundary accepting
quoted or unquoted values. Mirrors the review fixes in Zealot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF
…e-time pin

recomposeCommunication reconstructed sent mail from the pinned template
snapshot even when the row carried the recorded settledMjml. The pin is a
save-time reconstruction resolved through the template row's own owner
scope, while the send resolved components through the sender's cascade — so
for any sender-tier component override the pin replay returned a different
body than the one that shipped.

Prefer the recorded settledMjml (the sent truth) and fall back to the
pinned snapshot only for rows sent before capture existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF
Adjacent table cells concatenated and a link label containing block elements
flattened to one line. Treat td/th closers as block boundaries and make
renderLink apply the block pass itself, normalizing only spaces and tabs.
Mirrors the review fixes in Zealot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF
renderLink printed the URL twice when the label and href differed only by
entity encoding, because the no-repeat comparison ran on the raw strings
while decoding happens after link rendering. Compare decoded values.
Mirrors the review fix in Zealot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF
Parses with htmlparser2 instead of regexes, covering the attribute and
entity edge cases the regex version handled case by case plus the long
tail it did not (CDATA, script variants, nested quoting). The wrapper
keeps the same contract: block boundaries as newlines, alt text for
images, label (href) links with same-target dedupe, nbsp and blank runs
normalized, and unbounded dataTable columns so MJML layout tables never
rewrap body copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF
settledMjml records the document a send actually shipped, but nothing
recorded it structurally: which component row each slug resolved to
through the sender's cascade, at which audit version. The save-time
componentVersions pin cannot carry this — it resolves through the
template owner's scope, and one template row serves many senders.

CommunicationComponentVersion is that record: one row per (send, slug),
pointing at the resolved EmailComponent and its latest audit snapshot,
written inside the sending-claim transaction. expand surfaces the
resolutions it already performs through an optional onResolve sink;
composeTemplate returns them as componentResolutions; a re-claimed
retry rewrites the closure rather than duplicating it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPguRcG7VBE4w86Z8qBiEF
…organization id

The closure test created its log directly with senderType Space plus senderOrganizationId,
which the CommunicationLog polymorphism axis forbids (Space → senderSpaceId only). The
rules hook only enforces that when an earlier test file has registered it, so the test
passed alone and failed in the full run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
@agreenspan
agreenspan force-pushed the claude/email-nested-pass-through-rfu4ic branch from 252d8cd to 3fc870e Compare September 10, 2026 03:09
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.

2 participants