/**
* 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
? `
`
: "";
const footerHtml = args.footer
? `${escapeHtml(args.footer)}
`
: "";
const brandHeaderHtml = `
${escapeHtml(brandName)}
|
`;
const html = `
${escapeHtml(args.heading)}
${escapeHtml(preheader)}
${brandHeaderHtml}
${escapeHtml(args.heading)}
${paragraphsHtml}
${ctaHtml}
${footerHtml}
|
|
`;
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)}`;
}