feat(email): concentric slot pass-through — fill a slot re-exposed through a nested component - #91
Open
agreenspan wants to merge 11 commits into
Conversation
agreenspan
force-pushed
the
claude/email-nested-pass-through-rfu4ic
branch
from
September 9, 2026 01:16
557422e to
252d8cd
Compare
This was referenced Sep 9, 2026
…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 ©/©/😀 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
force-pushed
the
claude/email-nested-pass-through-rfu4ic
branch
from
September 10, 2026 03:09
252d8cd to
3fc870e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 —
expandis the single guarded walk,parseBlocksthe single gate). What remains here is template-specific:settledMjml+variablesonCommunicationLog, plus the structured component version closure (CommunicationComponentVersionjunction).expandWithwith the pinned bodies as its loader.1 — Send-time delivery hardening
sanitizeSubject— reverses the interpolation escaping (lodashunescapemirrors theescapeapplied —O'Brienno longer ships asO'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 aroundhtml-to-text@10pinning 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.deliverEmailapplies both at the client handoff;SendEmailOptions/the Resend payload carrytext.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:expandresolves 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 thesendingclaim:settledMjml(post-expand, post-interpolate MJML) andvariables(the resolved lens payload, now returned onSettledTemplate) onCommunicationLog. Raw body deliberately not duplicated (the audit row carries it); HTML derivable fromsettledMjml.CommunicationComponentVersion, one row per (send, slug): theEmailComponentrow the sender's cascade actually resolved (transitively) and that component's latest audit snapshot id.composeTemplaterecords the resolutions from its own cascade loader (no hook onexpand) and returns them ascomponentResolutions; the claim transaction rewrites the rows on a re-claimed retry (unique on(communicationLogId, slug)).3 — Recompose on the slot engine
recomposeSnapshotsubstituted 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 pinnedcomponentVersionsgraph breadth-first collecting each slug's snapshot body (nearest pin wins, visited set bounds cycles), thenexpandWith(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.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
regions.ts).🤖 Generated with Claude Code
https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP