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(`
`, 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);
+});