From d2a45521ade066411b764284b3d740d1b81d2f91 Mon Sep 17 00:00:00 2001 From: Alexander Selle Date: Sat, 25 Jul 2026 11:54:26 +0200 Subject: [PATCH 01/13] feat(renderer): render Templatical documents to HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second render path alongside React Email so the renderer can turn a Templatical document into send-ready HTML, in preparation for the visual email editor. The Go backend treats `compiled_js` as an opaque string — it stores the bundle and hashes it for cache invalidation, never parsing it — so the whole branch fits inside the renderer with no change to the wire format. Bundles now carry an optional `kind`; those without one predate this and take the React Email path exactly as before, so stored templates keep working and nothing needs migrating. Templatical documents are rendered at compile time rather than per render. Their output depends only on the document: merge tags survive as literal {{ ... }} and are resolved later by the platform's Liquid pass, so props cannot change the result. Rendering once per save instead of once per recipient matters for large campaigns. Plain text is derived with html-to-text, with heading uppercasing turned off. Left on, a merge tag inside a heading becomes {{ USER.FIRST_NAME }}, which Liquid cannot resolve — that would have shipped the raw tag in the text part of every email. mjml needs permissions the service did not previously grant. Importing it pulls in env-paths, which reads the home directory and then the TMPDIR, TMP, TEMP chain, and mjml stats its `filePath` option before parsing. Deno resolves the temp dir through Deno.env.get, so an unlisted variable raises NotCapable instead of returning undefined and the chain dies on the first name it touches — all three must be granted even though none are set and the lookup ends at its /tmp default. Without these the service dies on import, so the Dockerfile and the deno tasks grant them. The test task runs on the same allowlist as production rather than a blanket --allow-env, so a missing permission fails the suite instead of the deploy. Social icon URLs default to a jsDelivr CDN, which would make every sent email depend on a third party. TEMPLATICAL_SOCIAL_ICONS_BASE_URL overrides the base URL; self-hosting the assets is left for a follow-up. --- renderer/Dockerfile | 12 +++- renderer/compiler.ts | 53 ++++++++++++++--- renderer/compiler_test.ts | 119 ++++++++++++++++++++++++++++++++++++++ renderer/deno.json | 12 +++- renderer/main.ts | 12 ++-- renderer/templatical.ts | 95 ++++++++++++++++++++++++++++++ 6 files changed, 284 insertions(+), 19 deletions(-) create mode 100644 renderer/compiler_test.ts create mode 100644 renderer/templatical.ts diff --git a/renderer/Dockerfile b/renderer/Dockerfile index fdf1e387a..ca11eab37 100644 --- a/renderer/Dockerfile +++ b/renderer/Dockerfile @@ -19,5 +19,13 @@ COPY --from=build --chown=1993:1993 /deno-dir/ /deno-dir/ USER 1993 -# Run with minimal permissions: only network access to NATS and env vars -CMD ["run", "--allow-net", "--allow-env=NATS_URL", "main.ts"] +# Run with minimal permissions. Beyond network and env, mjml needs homedir and +# a readable working directory: it resolves a config path via env-paths on +# import and stats its `filePath` option before parsing. +# +# env-paths also calls node:os tmpdir(), which tries TMPDIR, TMP and TEMP in +# turn. Deno implements that through Deno.env.get, so an unlisted variable +# raises NotCapable rather than returning undefined and the fallback chain dies +# on the first one it touches. All three must be listed even though the image +# sets none of them and tmpdir() ends up at its /tmp default. +CMD ["run", "--allow-net", "--allow-env=NATS_URL,TEMPLATICAL_SOCIAL_ICONS_BASE_URL,TMPDIR,TMP,TEMP", "--allow-sys=homedir", "--allow-read=/app", "main.ts"] diff --git a/renderer/compiler.ts b/renderer/compiler.ts index a16a5ecb4..f5cf8e05f 100644 --- a/renderer/compiler.ts +++ b/renderer/compiler.ts @@ -3,6 +3,28 @@ import React from "react"; import { jsx, jsxs, Fragment } from "react/jsx-runtime"; import { render } from "@react-email/render"; import * as ReactEmailComponents from "@react-email/components"; +import { + isTemplaticalDocument, + renderTemplaticalDocument, +} from "./templatical.ts"; + +/** + * Marks a bundle produced from a Templatical document rather than React Email + * JSX. Bundles without a `kind` predate this branch and are React Email, so + * existing templates keep working untouched. + */ +const TEMPLATICAL_KIND = "templatical"; + +/** Parse `source` as a Templatical document, or return null if it is JSX. */ +function asTemplaticalDocument(source: string) { + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + return null; + } + return isTemplaticalDocument(parsed) ? parsed : null; +} /** * The scope available to user templates at runtime. @@ -17,15 +39,24 @@ const REACT_EMAIL_SCOPE: Record = { }; /** - * Transpile and compile a React Email JSX source string into executable JS. + * Compile a template source into a bundle that render time can reuse. * - * The compiled output is a self-contained function body that: - * 1. Receives the react-email scope + any tailwind config bindings as arguments - * 2. Returns the default-exported React component + * Two kinds of source are accepted: * - * The compiled string is stored and reused at render time with different props. + * - A **Templatical document** (JSON). Rendering it depends only on the + * document — merge tags resolve downstream via Liquid, not from props — so + * the final HTML is produced here, once, and every recipient reuses it. + * - **React Email JSX**, which is transpiled to a self-contained function body + * that receives the react-email scope and returns the default export. It is + * executed per render because its output depends on props. */ -export function compile(source: string): string { +export async function compile(source: string): Promise { + const doc = asTemplaticalDocument(source); + if (doc) { + const { html, plainText } = await renderTemplaticalDocument(doc); + return JSON.stringify({ kind: TEMPLATICAL_KIND, html, plainText }); + } + const transformed = transform(source, { transforms: ["jsx", "typescript"], jsxRuntime: "automatic", @@ -77,7 +108,15 @@ export async function renderTemplate( compiledBundle: string, props: Record ): Promise<{ html: string; plainText: string }> { - const { code, tailwindConfigBindings } = JSON.parse(compiledBundle); + const bundle = JSON.parse(compiledBundle); + + // Templatical bundles were fully rendered at compile time — there is nothing + // props could change, so hand back the stored output. + if (bundle.kind === TEMPLATICAL_KIND) { + return { html: bundle.html, plainText: bundle.plainText }; + } + + const { code, tailwindConfigBindings } = bundle; const scope: Record = { ...REACT_EMAIL_SCOPE, diff --git a/renderer/compiler_test.ts b/renderer/compiler_test.ts new file mode 100644 index 000000000..c0603790f --- /dev/null +++ b/renderer/compiler_test.ts @@ -0,0 +1,119 @@ +import { + assert, + assertEquals, + assertRejects, + assertStringIncludes, +} from "@std/assert"; +import { + createButtonBlock, + createDefaultTemplateContent, + createImageBlock, + createParagraphBlock, + createSectionBlock, + createTitleBlock, +} from "@templatical/types"; +import { compile, renderTemplate } from "./compiler.ts"; + +/** A Templatical document with two columns, an image and merge tags. */ +function templaticalFixture() { + const content = createDefaultTemplateContent(); + content.blocks = [ + createSectionBlock({ + columns: "2", + children: [ + [ + createImageBlock({ + src: "https://cdn.example.com/hero.png", + alt: "Hero", + width: "full", + align: "center", + }), + ], + [ + createTitleBlock({ + content: "Hello {{ user.first_name }}", + level: 2, + }), + createParagraphBlock({ content: "

Thanks for joining.

" }), + createButtonBlock({ + text: "Open", + url: "https://example.com/{{ user.id }}", + }), + ], + ], + }), + ]; + return content; +} + +const REACT_EMAIL_FIXTURE = ` +import { Html, Text } from "@react-email/components"; + +export default function Email(props: { name: string }) { + return ( + + Hello {props.name} + + ); +} +`; + +Deno.test("templatical: compile produces a kind-tagged bundle", async () => { + const bundle = JSON.parse( + await compile(JSON.stringify(templaticalFixture())), + ); + + assertEquals(bundle.kind, "templatical"); + assert(typeof bundle.html === "string" && bundle.html.length > 0); + assert(typeof bundle.plainText === "string"); + // Rendering happens at compile time, so no document is carried forward. + assertEquals(bundle.doc, undefined); +}); + +Deno.test("templatical: renders blocks, images and merge tags", async () => { + const bundle = await compile(JSON.stringify(templaticalFixture())); + const { html, plainText } = await renderTemplate(bundle, {}); + + assertStringIncludes(html, "https://cdn.example.com/hero.png"); + assertStringIncludes(html, "{{ user.first_name }}"); + assertStringIncludes(html, "{{ user.id }}"); + // MJML output must carry the responsive and Outlook scaffolding. + assertStringIncludes(html, "@media"); + assertStringIncludes(html, "", + "

Hi {{ user.email }}

", + ].join("\n") + + const out = resolveMergeTags(html, context) + + expect(out).toContain("@media only screen and (min-width:480px)") + expect(out).toContain("