From 371c23db4634cff87e1db911a89fd1ef7792677a Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sat, 5 Sep 2026 12:47:43 +0530 Subject: [PATCH] fix(email-renderer): validate brand settings and apply them to AMP --- packages/email-renderer/package.json | 2 +- packages/email-renderer/src/blocks.ts | 13 +++---- packages/email-renderer/src/brand.ts | 42 ++++++++++++++++++++++ packages/email-renderer/src/email.ts | 7 ++-- packages/email-renderer/test/email.test.ts | 19 ++++++++++ 5 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 packages/email-renderer/src/brand.ts diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index 518f31e7..3aa80b99 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.12", + "version": "0.1.13", "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 7a46db6b..f0a4b02b 100644 --- a/packages/email-renderer/src/blocks.ts +++ b/packages/email-renderer/src/blocks.ts @@ -10,6 +10,7 @@ import { type BlockDefinition, type BlockNode, } from "@wrnexus/blocks"; +import { brandColor, brandFont, brandWidth } from "./brand.ts"; const styles = record(json, { maxKeys: 50 }) as never; const label = (key: string, fallback: string) => ({ key, fallback }); @@ -72,12 +73,12 @@ export const emailDocumentBlock = defineBlock({ category: "layout", capabilities: ["root"], props: object({ - width: string({ default: "600" }), - background: string({ default: "#f3f4f6" }), - contentBackground: string({ default: "#ffffff" }), - fontFamily: string({ default: "Arial, sans-serif" }), - textColor: string({ default: "#111827" }), - linkColor: string({ default: "#2563eb" }), + width: brandWidth, + background: brandColor("#f3f4f6"), + contentBackground: brandColor("#ffffff"), + fontFamily: brandFont, + textColor: brandColor("#111827"), + linkColor: brandColor("#2563eb"), logoUrl: safeUrl({ schemes: ["https"], optional: true }), logoAlt: string({ max: 300, default: "" }), }) as never, diff --git a/packages/email-renderer/src/brand.ts b/packages/email-renderer/src/brand.ts new file mode 100644 index 00000000..d3c81bcc --- /dev/null +++ b/packages/email-renderer/src/brand.ts @@ -0,0 +1,42 @@ +import { string, type ValueSchema } from "@wrnexus/blocks"; + +// Brand fields are semantic values, never arbitrary CSS declarations. +export function brandValue( + defaultValue: string, + accepts: (value: string) => boolean, +): ValueSchema { + const base = string({ default: defaultValue, max: 160 }); + return { + ...base, + parse(value, path = "value") { + const result = base.parse(value, path); + if (!result.ok) return result; + return accepts(result.value) + ? result + : { + ok: false, + errors: [ + { + code: "email.brand", + message: "Unsupported brand style value.", + severity: "error", + path, + }, + ], + }; + }, + }; +} + +export const brandColor = (fallback: string) => + brandValue(fallback, (value) => + /^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|transparent)$/i.test(value), + ); +export const brandFont = brandValue( + "Arial, sans-serif", + (value) => /^[a-z0-9 ,'"-]+$/i.test(value) && /[a-z]/i.test(value), +); +export const brandWidth = brandValue( + "600", + (value) => /^\d{2,4}$/.test(value) && Number(value) >= 240 && Number(value) <= 1200, +); diff --git a/packages/email-renderer/src/email.ts b/packages/email-renderer/src/email.ts index 4fd8dbf9..cba3dc18 100644 --- a/packages/email-renderer/src/email.ts +++ b/packages/email-renderer/src/email.ts @@ -79,8 +79,11 @@ const head = (document: BlockDocument, body: string, direction: "ltr" | "rtl") = const fontFamily = escapeHtml(props.fontFamily ?? "Arial, sans-serif"); return `${escapeHtml(document.meta.subject)}${document.meta.preheader ? `
${escapeHtml(document.meta.preheader)}
` : ""}${body}`; }; -const ampHead = (document: BlockDocument, body: string, direction: "ltr" | "rtl") => - `${body}`; +const ampHead = (document: BlockDocument, body: string, direction: "ltr" | "rtl") => { + const props = document.root.props as Record; + const css = `body{margin:0;color:${props.textColor ?? "#111827"};font-family:${props.fontFamily ?? "Arial, sans-serif"}}a{color:${props.linkColor ?? "#2563eb"}}.email-bg{background:${props.background ?? "#f3f4f6"}}.email-body{max-width:${props.width ?? "600"}px;width:100%;background:${props.contentBackground ?? "#ffffff"}}`; + return `${body}`; +}; export async function validateAmpEmail(content: string): Promise { const validator = await amphtmlValidator.getInstance(); diff --git a/packages/email-renderer/test/email.test.ts b/packages/email-renderer/test/email.test.ts index 8c88139c..710e05c5 100644 --- a/packages/email-renderer/test/email.test.ts +++ b/packages/email-renderer/test/email.test.ts @@ -57,6 +57,25 @@ const document: BlockDocument = { }; describe("email renderer", () => { + test("AMP inherits the saved brand font, colors and body width", async () => { + const draft = structuredClone(document); + Object.assign(draft.root.props, {width:"720",fontFamily:"Georgia, serif",textColor:"#112233",linkColor:"#445566",background:"#eeeeee",contentBackground:"#fafafa"}); + const rendered = await renderEmail(draft, {amp:true}); + expect(rendered.ok).toBe(true); + if (!rendered.ok) return; + const amp = rendered.value.parts.amp; + expect(amp?.validation.valid).toBe(true); + for (const style of ["font-family:Georgia, serif", "color:#112233", "a{color:#445566}", "background:#eeeeee", "background:#fafafa", "max-width:720px"]) expect(amp?.content).toContain(style); + }); + test("brand fields reject arbitrary CSS and invalid widths before rendering", async () => { + for (const [key,value] of [["fontFamily","Arial;position:fixed"],["fontFamily",""],["textColor","red;background:url(https://example.com)"],["background","#fff;display:none"],["width","600;position:absolute"],["width","9999"]]) { + const draft = structuredClone(document); + draft.root.props[key!] = value!; + const rendered = await renderEmail(draft, {amp:true}); + expect(rendered.ok).toBe(false); + if (!rendered.ok) expect(rendered.errors.some(error => error.code === "email.brand")).toBe(true); + } + }); test("responsive stacking begins below the desktop email width", async () => { const rendered = await renderEmail(document); expect(rendered.ok).toBe(true);