From 2428df6e78e3301e298c8bfcaf7c199de293a34c Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sat, 5 Sep 2026 11:11:02 +0530 Subject: [PATCH] feat(email-renderer): compile safe semantic styles --- packages/email-renderer/package.json | 2 +- packages/email-renderer/src/blocks.ts | 32 +++++++++++++++++++++- packages/email-renderer/test/email.test.ts | 17 ++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index 8446336c..7688d8b1 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.2", + "version": "0.1.3", "type": "module", "description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.", "exports": { diff --git a/packages/email-renderer/src/blocks.ts b/packages/email-renderer/src/blocks.ts index 3f86eec8..b67c6c1c 100644 --- a/packages/email-renderer/src/blocks.ts +++ b/packages/email-renderer/src/blocks.ts @@ -30,8 +30,38 @@ const html = (value: unknown) => .replaceAll('"', """) .replaceAll("'", "'"); const attrs = (node: BlockNode) => node.props as Record; +const styleBase = (node: BlockNode, key: string) => { + const value = (node.styles as Record)[key]; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record).base + : undefined; +}; +const safeCssValue = (value: unknown, pattern: RegExp) => { + const candidate = String(value ?? "").trim(); + return candidate.length <= 80 && pattern.test(candidate) ? candidate : ""; +}; +const semanticStyle = (node: BlockNode) => { + const declarations: string[] = []; + const color = safeCssValue(styleBase(node, "color"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i); + const background = safeCssValue(styleBase(node, "backgroundColor"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i); + const padding = safeCssValue(styleBase(node, "padding"), /^\d+(?:\.\d+)?(?:px|rem|em|%)?(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)?){0,3}$/); + const margin = safeCssValue(styleBase(node, "margin"), /^\d+(?:\.\d+)?(?:px|rem|em|%)?(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)?){0,3}$/); + const fontSize = safeCssValue(styleBase(node, "fontSize"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/); + const radius = safeCssValue(styleBase(node, "borderRadius"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/); + const align = safeCssValue(styleBase(node, "textAlign"), /^(left|center|right|start|end)$/); + const weight = safeCssValue(styleBase(node, "fontWeight"), /^(normal|bold|[1-9]00)$/); + if (color) declarations.push(`color:${color}`); + if (background) declarations.push(`background-color:${background}`); + if (padding) declarations.push(`padding:${padding}`); + if (margin) declarations.push(`margin:${margin}`); + if (fontSize) declarations.push(`font-size:${fontSize}`); + if (radius) declarations.push(`border-radius:${radius}`); + if (align) declarations.push(`text-align:${align}`); + if (weight) declarations.push(`font-weight:${weight}`); + return declarations.join(";"); +}; const emailCell = (body: string, node: BlockNode) => - `${body}`; + `${body}`; export const emailDocumentBlock = defineBlock({ manifest: { diff --git a/packages/email-renderer/test/email.test.ts b/packages/email-renderer/test/email.test.ts index 1be9ddf8..539cff7c 100644 --- a/packages/email-renderer/test/email.test.ts +++ b/packages/email-renderer/test/email.test.ts @@ -73,6 +73,23 @@ describe("email renderer", () => { expect((await validateAmpEmail("")).valid).toBe(false); }); + test("compiles only allowlisted semantic styles", async () => { + const styled = structuredClone(document); + styled.root.slots.content[0]!.slots.content[0]!.styles = { + color: { base: "#123456" }, + textAlign: { base: "center" }, + padding: { base: "12px 8px" }, + backgroundColor: { base: "red;position:fixed" }, + }; + const rendered = await renderEmail(styled); + expect(rendered.ok).toBe(true); + if (!rendered.ok) return; + expect(rendered.value.parts.html.content).toContain( + "color:#123456;padding:12px 8px;text-align:center", + ); + expect(rendered.value.parts.html.content).not.toContain("position:fixed"); + }); + test("rejects unsafe MJML and imports supported content", () => { expect( importMjml("x", {