diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json
index ae201fab..f261e31f 100644
--- a/packages/email-renderer/package.json
+++ b/packages/email-renderer/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/email-renderer",
- "version": "0.1.10",
+ "version": "0.1.11",
"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 5767e213..7a46db6b 100644
--- a/packages/email-renderer/src/blocks.ts
+++ b/packages/email-renderer/src/blocks.ts
@@ -401,6 +401,16 @@ export const socialBlock = leaf(
({ node }) => `${String(attrs(node).label)}: ${[["Facebook",attrs(node).facebook],["Instagram",attrs(node).instagram],["LinkedIn",attrs(node).linkedin],["X",attrs(node).x]].filter(([,url])=>url).map(([name,url])=>`${name}: ${url}`).join(" · ")}\n\n`,
);
+const countdownUrl = (node: BlockNode, context: unknown): string | undefined => {
+ const timestamp = Date.parse(String(attrs(node).deadline));
+ if (!Number.isFinite(timestamp)) throw new Error("Countdown deadline must be a valid date.");
+ const endpoint = (context as { countdownImageUrl?: string })?.countdownImageUrl;
+ if (!endpoint) return undefined;
+ const url = new URL(endpoint);
+ url.searchParams.set("deadline", String(timestamp));
+ return html(url.href);
+};
+
export const countdownBlock = leaf(
"content.countdown",
"Countdown",
@@ -411,9 +421,18 @@ export const countdownBlock = leaf(
fallback: string({ max: 300, default: "This offer has ended." }),
}) as never,
{
- emailHtml: ({ node }) => emailCell(`
${html(attrs(node).label)}:
`, node),
- ampEmail: ({ node }) => emailCell(`${html(attrs(node).label)}:
`, node),
- mjml: ({ node }) => `${html(attrs(node).label)}: ${html(attrs(node).deadline)}`,
+ emailHtml: ({ node, context }) => {
+ const url = countdownUrl(node, context);
+ return emailCell(`${html(attrs(node).label)}:
${url ? `
` : ""}`, node);
+ },
+ ampEmail: ({ node, context }) => {
+ const url = countdownUrl(node, context);
+ return emailCell(`${html(attrs(node).label)}:
${url ? `` : ""}`, node);
+ },
+ mjml: ({ node, context }) => {
+ const url = countdownUrl(node, context);
+ return `${html(attrs(node).label)}: ${html(attrs(node).deadline)}${url ? `` : ""}`;
+ },
},
({ node }) => `${String(attrs(node).label)}: ${String(attrs(node).deadline)}\n${String(attrs(node).fallback)}\n\n`,
);
diff --git a/packages/email-renderer/src/email.ts b/packages/email-renderer/src/email.ts
index 6632b6cb..15abd616 100644
--- a/packages/email-renderer/src/email.ts
+++ b/packages/email-renderer/src/email.ts
@@ -106,18 +106,28 @@ export async function renderEmail(
direction?: "ltr" | "rtl";
variables?: Record;
requireVariables?: boolean;
+ countdownImageUrl?: string;
} = {},
): Promise> {
const personalized = resolveDocumentVariables(document, emailKind.variables, options.variables, {
requireValues: options.requireVariables,
});
if (!personalized.ok) return personalized;
+ if (options.countdownImageUrl) {
+ try {
+ const url = new URL(options.countdownImageUrl);
+ if (url.username || url.password || (url.protocol !== "https:" && !(url.protocol === "http:" && (url.hostname === "localhost" || url.hostname.endsWith(".localhost"))))) throw new Error();
+ } catch {
+ return { ok: false, errors: [{ code: "countdown.endpoint", message: "Countdown images require HTTPS (or a local development host).", severity: "error" }] };
+ }
+ }
const source = personalized.value;
const registry = registryForKind(emailKind).freeze();
const base = {
locale: options.locale ?? "en",
timezone: options.timezone ?? "UTC",
direction: options.direction ?? "ltr",
+ countdownImageUrl: options.countdownImageUrl,
};
const [htmlResult, textResult, mjmlResult] = await Promise.all([
renderTree(source, emailKind, registry, { ...base, target: "emailHtml", data: options.variables }),
diff --git a/packages/email-renderer/test/email.test.ts b/packages/email-renderer/test/email.test.ts
index f4955725..9718ecac 100644
--- a/packages/email-renderer/test/email.test.ts
+++ b/packages/email-renderer/test/email.test.ts
@@ -56,6 +56,20 @@ const document: BlockDocument = {
};
describe("email renderer", () => {
+ test("countdown URLs are derived for HTML AMP and MJML without mutating the document", async () => {
+ const draft = structuredClone(document);
+ draft.root.slots.content[0]!.slots.content = [{id:"timer",type:"content.countdown",version:1,props:{label:"Offer ends",deadline:"2026-10-01T00:00:00Z",fallback:"Offer ended"},styles:{},slots:{}}];
+ const before = JSON.stringify(draft);
+ const result = await renderEmail(draft, {amp:true,mjml:true,countdownImageUrl:"https://email.example.com/api/countdown"});
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ const image = "https://email.example.com/api/countdown?deadline=1790812800000";
+ expect(result.value.parts.html.content).toContain(image);
+ expect(result.value.parts.amp?.content).toContain(image);
+ expect(result.value.exports?.mjml).toContain(image);
+ expect(JSON.stringify(draft)).toBe(before);
+ expect((await renderEmail(draft, {countdownImageUrl:"javascript:alert(1)"})).ok).toBe(false);
+ });
test("content inherits the selected brand font instead of overriding it", async () => {
const branded = structuredClone(document);
branded.root.props.fontFamily = "Georgia, serif";