diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index dd62ef9b..77114d5f 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.21", + "version": "0.1.22", "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 960b3832..15e3105c 100644 --- a/packages/email-renderer/src/blocks.ts +++ b/packages/email-renderer/src/blocks.ts @@ -49,13 +49,13 @@ export function ampLayoutCss(node: BlockNode): string { return ""; } const multilineText = (value: unknown) => html(value).replace(/\r\n?|\n/g, "
"); -const imageDimension = (fallback: string): ValueSchema => ({ +const imageDimension = (fallback: string, minimum = 1): ValueSchema => ({ kind: "string", defaultValue: fallback, parse(value = fallback, path = "value") { - return typeof value === "string" && /^\d{1,4}$/.test(value) && Number(value) > 0 && Number(value) <= 4000 + return typeof value === "string" && /^\d{1,4}$/.test(value) && Number(value) >= minimum && Number(value) <= 4000 ? { ok: true, value } - : { ok: false, errors: [{code:"email.image-dimension",message:"Image dimensions must be between 1 and 4000 pixels.",severity:"error",path}] }; + : { ok: false, errors: [{code:"email.image-dimension",message:`Dimensions must be between ${minimum} and 4000 pixels.`,severity:"error",path}] }; }, }); const marketingImage = (node: BlockNode, target: "html" | "amp" | "mjml") => { @@ -328,8 +328,8 @@ export const imageBlock = leaf( object({ src: safeUrl({ schemes: ["https"] }), alt: string({ max: 1_000, default: "" }), - width: string({ default: "600" }), - height: string({ default: "300" }), + width: imageDimension("600"), + height: imageDimension("300"), decorative: boolean(false), }) as never, { @@ -391,7 +391,7 @@ export const spacerBlock = leaf( "content.spacer", "Spacer", "icon-[lucide--move-vertical]", - object({ height: string({ default: "24" }) }) as never, + object({ height: imageDimension("24", 0) }) as never, { emailHtml: ({ node }) => emailCell( @@ -413,8 +413,8 @@ export const videoBlock = leaf( href: safeUrl({ schemes: ["https"] }), thumbnail: safeUrl({ schemes: ["https"] }), alt: string({ max: 1_000, default: "Video thumbnail" }), - width: string({ default: "600" }), - height: string({ default: "338" }), + width: imageDimension("600"), + height: imageDimension("338"), }) as never, { emailHtml: ({ node }) => emailCell(`${html(attrs(node).alt)}`, node), diff --git a/packages/email-renderer/test/marketing-images.test.ts b/packages/email-renderer/test/marketing-images.test.ts index 9fd595ec..e41698d2 100644 --- a/packages/email-renderer/test/marketing-images.test.ts +++ b/packages/email-renderer/test/marketing-images.test.ts @@ -54,7 +54,7 @@ test("product and RSS images survive HTML, AMP and strict MJML exports", async ( const result = await renderEmail(document(type, props), { amp: true, mjml: true }); expect(result.ok).toBe(true); if (!result.ok) continue; - expect(result.value.parts.html.content).toContain('width:100%;max-width:320px;'); + expect(result.value.parts.html.content).toContain("width:100%;max-width:320px;"); for (const output of [ result.value.parts.html.content, result.value.parts.amp?.content, @@ -113,3 +113,39 @@ test("marketing image dimensions reject invalid or injected values", async () => expect(result.ok).toBe(false); } }); + +test("basic images, video thumbnails and spacers validate dimensions before rendering", async () => { + const cases: Array<{ type: string; props: Record }> = [ + { type: "content.image", props: { src: "https://example.com/image.png", alt: "Image" } }, + { + type: "content.video", + props: { + href: "https://example.com/video", + thumbnail: "https://example.com/image.png", + title: "Video", + alt: "Video", + }, + }, + { type: "content.spacer", props: {} }, + ]; + for (const block of cases) { + for (const height of ["-1", "4001", "100%", "24;position:fixed", '24" onload="bad', "1e3"]) { + const result = await renderEmail( + document(block.type, { ...block.props, height } as Record), + ); + expect(result.ok).toBe(false); + } + const valid = await renderEmail( + document(block.type, { ...block.props, height: "4000" } as Record), + ); + expect(valid.ok).toBe(true); + } + expect((await renderEmail(document("content.spacer", { height: "0" }))).ok).toBe(true); + expect( + ( + await renderEmail( + document("content.image", { src: "https://example.com/image.png", height: "0" }), + ) + ).ok, + ).toBe(false); +});