import { createRequire } from "node:module"; import { DEFAULT_LIMITS, defineDocumentKind, escapeHtml, object, registryForKind, resolveDocumentVariables, renderTree, string, type BlockDocument, type Diagnostic, type Result, } from "@wrnexus/blocks"; import { emailCoreBlocks, ampLayoutCss } from "./blocks.ts"; import { validateMjml } from "./mjml.ts"; interface AmpValidationError { severity: string; line: number; col: number; message: string; } const amphtmlValidator = createRequire(import.meta.url)("amphtml-validator") as { getInstance(): Promise<{ validateString( value: string, format: string, ): { status: string; errors: AmpValidationError[]; }; }>; }; export interface ValidationReport { valid: boolean; diagnostics: Diagnostic[]; } export interface EmailRenderBundle { kind: "email"; subject: string; preheader?: string; parts: { text: { contentType: "text/plain; charset=utf-8"; content: string }; amp?: { contentType: "text/x-amp-html; charset=utf-8"; content: string; validation: ValidationReport; }; html: { contentType: "text/html; charset=utf-8"; content: string }; }; exports?: { mjml?: string }; warnings: Diagnostic[]; assets: string[]; } export const emailKind = defineDocumentKind({ kind: "email", version: 1, metadata: object({ subject: string({ min: 1, max: 998 }), preheader: string({ max: 500, default: "" }), lang: string({ max: 35, default: "en" }), }) as never, rootTypes: ["layout.email-document"], blocks: emailCoreBlocks, variables: { recipientName: "string", unsubscribeUrl: "string", organizationAddress: "string" }, breakpoints: { compact: 480 }, capabilities: ["email-html", "plain-text", "amp-email", "mjml"], targets: ["emailHtml", "plainText", "ampEmail", "mjml"], limits: { ...DEFAULT_LIMITS, maxOutputBytes: 2_000_000 }, urlSchemes: ["https", "mailto"], }); const head = (document: BlockDocument, body: string, direction: "ltr" | "rtl") => { const props = document.root.props as Record; const textColor = escapeHtml(props.textColor ?? "#111827"); const linkColor = escapeHtml(props.linkColor ?? "#2563eb"); 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") => { const layoutCss = (node: BlockDocument["root"]): string => ampLayoutCss(node, String(document.root.props.linkColor ?? "#2563eb")) + Object.values(node.slots).flat().map(layoutCss).join(""); const props = document.root.props as Record; const css = `@media only screen and (max-width:480px){.email-body .wrn-columns{display:block}.email-body .wrn-column{display:block;width:100%}}.marketing-image img{object-fit:contain}body{margin:0;color:${props.textColor ?? "#111827"};font-family:${props.fontFamily ?? "Arial, sans-serif"}}a{color:${props.linkColor ?? "#2563eb"}}.brand-logo{max-width:${props.logoWidth ?? "200"}px;width:100%;margin:20px auto}.email-bg{table-layout:fixed;width:100%;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(); const result = validator.validateString(content, "AMP4EMAIL"); return { valid: result.status === "PASS", diagnostics: result.errors.map((error) => ({ code: "amp.validation", message: error.message, severity: error.severity === "ERROR" ? "error" : "warning", path: `${error.line}:${error.col}`, })), }; } export async function renderEmail( document: BlockDocument, options: { amp?: boolean; mjml?: boolean; locale?: string; timezone?: string; direction?: "ltr" | "rtl"; variables?: Record; requireVariables?: boolean; /** Preserve allowlisted tokens and fallbacks for template export, never for final delivery. */ preserveVariables?: boolean; countdownImageUrl?: string; /** Trusted host injection; never sourced from the editable document. */ ampValidator?: (content: string) => Promise; ampValidationTimeoutMs?: number; } = {}, ): Promise> { const personalized = resolveDocumentVariables(document, emailKind.variables, options.variables, { requireValues: options.requireVariables, preserveTokens: options.preserveVariables, }); 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, brandLinkColor: String(source.root.props.linkColor ?? "#2563eb"), emailSubject: String(source.meta.subject), emailPreheader: String(source.meta.preheader ?? ""), rootBlockIds: Object.values(source.root.slots).flat().map(node => node.id), }; const [htmlResult, textResult, mjmlResult] = await Promise.all([ renderTree(source, emailKind, registry, { ...base, target: "emailHtml", data: options.variables }), renderTree(source, emailKind, registry, { ...base, target: "plainText", data: options.variables }), options.mjml ? renderTree(source, emailKind, registry, { ...base, target: "mjml", data: options.variables }) : Promise.resolve(undefined), ]); if (!htmlResult.ok) return htmlResult; if (!textResult.ok) return textResult; const warnings: Diagnostic[] = [...htmlResult.value.warnings, ...textResult.value.warnings]; const bundle: EmailRenderBundle = { kind: "email", subject: String(source.meta.subject), preheader: String(source.meta.preheader || "") || undefined, parts: { text: { contentType: "text/plain; charset=utf-8", content: textResult.value.content.trimEnd() + "\n", }, html: { contentType: "text/html; charset=utf-8", content: head(source, htmlResult.value.content, base.direction), }, }, warnings, assets: [], }; if (mjmlResult?.ok) { const diagnostics = await validateMjml(mjmlResult.value.content); if (!diagnostics.length) bundle.exports = { mjml: mjmlResult.value.content }; else warnings.push(...diagnostics.map(diagnostic => ({ ...diagnostic, severity: "warning" as const }))); } else if (mjmlResult) { warnings.push(...mjmlResult.errors.map(diagnostic => ({ ...diagnostic, severity: "warning" as const }))); } if (options.amp) { const amp = await renderTree(source, emailKind, registry, { ...base, target: "ampEmail", data: options.variables }); if (amp.ok) { const content = ampHead(source, amp.value.content, base.direction); let timer: ReturnType | undefined; let validation: ValidationReport; const requestedTimeout = options.ampValidationTimeoutMs ?? 2000; const timeout = Number.isFinite(requestedTimeout) ? Math.min(10000, Math.max(10, requestedTimeout)) : 2000; try { validation = await Promise.race([ Promise.resolve().then(() => (options.ampValidator ?? validateAmpEmail)(content)), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("AMP validation timed out.")), timeout); }), ]); } catch { validation = { valid: false, diagnostics: [{code:"amp.validatorUnavailable",message:"AMP validation was unavailable or timed out. HTML and plain text remain available.",severity:"warning"}] }; } finally { if (timer !== undefined) clearTimeout(timer); } if (validation.valid) bundle.parts.amp = { contentType: "text/x-amp-html; charset=utf-8", content, validation }; else warnings.push(...validation.diagnostics.map(diagnostic => ({...diagnostic,severity:"warning" as const}))); } else warnings.push(...amp.errors.map((error) => ({ ...error, severity: "warning" as const }))); } return { ok: true, value: bundle }; }