184 lines
8.1 KiB
TypeScript
184 lines
8.1 KiB
TypeScript
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 { emailKind } from "./browser.ts";
|
|
import { emailKind, emailHtmlHead as head } from "./browser.ts";
|
|
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<string, unknown>;
|
|
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 `<!doctype html><html amp4email data-css-strict lang="${escapeHtml(document.meta.lang)}" dir="${direction}"><head><meta charset="utf-8"><script async src="https://cdn.ampproject.org/v0.js"></script><style amp4email-boilerplate>body{visibility:hidden}</style><style amp-custom>${css}${layoutCss(document.root)}.button{background:#2563eb;border-radius:6px;color:#fff;display:inline-block;font-weight:700;padding:12px 20px;text-decoration:none}</style></head><body>${body}</body></html>`;
|
|
};
|
|
|
|
export async function validateAmpEmail(content: string): Promise<ValidationReport> {
|
|
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<string, unknown>;
|
|
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<ValidationReport>;
|
|
ampValidationTimeoutMs?: number;
|
|
} = {},
|
|
): Promise<Result<EmailRenderBundle>> {
|
|
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<typeof setTimeout> | 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<never>((_, 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 };
|
|
}
|