/** * Reusable dark-themed HTML email template. * * Email clients have limited CSS support, so everything is inlined and layout * uses tables for Outlook compatibility. The design mirrors the app's dark UI: * near-black card on neutral background, Inter typography with safe fallbacks. * * Default is monochrome (white CTA on dark). Pass `brandColor` to tint the * CTA button and inline links — Clips, for example, passes its purple. * * Usage: * const { html, text } = renderEmail({ * preheader: "…", * heading: "You're invited to join Acme", * paragraphs: ["Alice invited you to join…"], * cta: { label: "Accept invite", url: "https://…" }, * footer: "If you weren't expecting this, ignore this email.", * }); */ import { getAppName } from "./app-name.js"; export const AGENT_NATIVE_EMAIL_LOGO_CONTENT_ID = "agent-native-logo"; export interface EmailCta { label: string; url: string; } export interface RenderEmailArgs { /** Short preview text shown by email clients next to the subject. */ preheader?: string; /** Large headline at the top of the card. */ heading: string; /** Body paragraphs rendered after the heading. Plain strings — escaped. */ paragraphs: string[]; /** Primary call-to-action rendered as a real button. */ cta?: EmailCta; /** Small muted text under the CTA (e.g. expiry note). */ footer?: string; /** Optional app name shown beside the framework logo. */ brandName?: string; /** * Optional brand hex color for the CTA button and inline links. Defaults to * a monochrome near-white button with dark text. */ brandColor?: string; } export interface RenderedEmail { html: string; text: string; } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function escapeAttr(s: string): string { return escapeHtml(s); } /** * Only accept a strict `#rrggbb` hex color for `brandColor`. Anything else * could inject CSS into the inline `style` attribute (`red; background:url(…)`). */ function sanitizeHexColor(input: string | undefined): string | undefined { if (!input) return undefined; return /^#[0-9a-fA-F]{6}$/.test(input) ? input : undefined; } export function renderEmail(args: RenderEmailArgs): RenderedEmail { const preheader = args.preheader || ""; const brand = sanitizeHexColor(args.brandColor); const brandName = args.brandName?.trim() || getAppName() || "Agent Native"; // Monochrome default: near-white button with dark text. Brand override: // colored button with white text. const ctaBg = brand ?? "#fafafa"; const ctaFg = brand ? "#ffffff" : "#0a0a0c"; const linkColor = brand ?? "#a1a1aa"; const paragraphsHtml = args.paragraphs .map( (p) => `

${p}

`, ) .join(""); const ctaHtml = args.cta ? `
${escapeHtml(args.cta.label)}
` : ""; const footerHtml = args.footer ? `

${escapeHtml(args.footer)}

` : ""; const brandHeaderHtml = `
${escapeAttr(brandName)} ${escapeHtml(brandName)}
`; const html = ` ${escapeHtml(args.heading)}
${escapeHtml(preheader)}
`; const textLines: string[] = []; textLines.push(args.heading); textLines.push(""); for (const p of args.paragraphs) { textLines.push(stripTags(p)); textLines.push(""); } if (args.cta) { textLines.push(`${args.cta.label}: ${args.cta.url}`); textLines.push(""); } if (args.footer) { textLines.push(args.footer); } return { html, text: textLines.join("\n").trim() }; } function stripTags(s: string): string { return s .replace(//gi, "\n") .replace(/<[^>]+>/g, "") .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .trim(); } /** * Build an inline `` tag with consistent styling for use inside * paragraph strings passed to `renderEmail`. Escapes the content. */ export function emailStrong(text: string): string { return `${escapeHtml(text)}`; } /** * Build a labelled inline link for paragraph strings passed to `renderEmail`. * Use this instead of rendering raw URLs in the visible email body. */ export function emailLink(label: string, url: string): string { return `${escapeHtml(label)}`; }