interface SendEmailInput { to: string; subject: string; html: string; text?: string; } const FROM = process.env.EMAIL_FROM ?? "Podcast Distribution AI "; /** * Send a transactional email via Resend when configured; otherwise log to the * console (useful in local dev before RESEND_API_KEY is set). */ export async function sendEmail({ to, subject, html, text }: SendEmailInput): Promise { // Defense-in-depth header hygiene: strip CR/LF and other control chars so a // user-controlled subject/recipient can't inject extra email headers. const safeSubject = stripControlChars(subject).replace(/[\r\n]+/g, " ").trim(); const safeTo = stripControlChars(to).replace(/[\r\n]+/g, " ").trim(); const apiKey = process.env.RESEND_API_KEY; if (!apiKey) { console.info(`[email:dev] To: ${safeTo} | Subject: ${safeSubject}\n${text ?? html}`); return; } const { Resend } = await import("resend"); const resend = new Resend(apiKey); const { error } = await resend.emails.send({ from: FROM, to: safeTo, subject: safeSubject, html, text }); if (error) throw new Error(`Resend error: ${error.message}`); } /** Remove CR/LF and other ASCII control characters (header-injection defense). */ function stripControlChars(value: string): string { // eslint-disable-next-line no-control-regex return value.replace(/[\x00-\x1f\x7f]+/g, " "); } /** Escape text for safe interpolation into HTML/attribute contexts. */ export function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** Allow only http/https/mailto URLs; fall back to "#" for anything else (e.g. javascript:). */ function safeUrl(url: string): string { try { const scheme = new URL(url).protocol; if (scheme === "http:" || scheme === "https:" || scheme === "mailto:") return url; } catch { // Not a parseable absolute URL — reject. } return "#"; } /** * Minimal branded wrapper so transactional emails share a consistent look. * * NOTE: `body` is interpolated as TRUSTED raw HTML and is intentionally NOT escaped. * Callers must only ever pass static, trusted markup — never user-supplied input. * `title` and `cta.label`/`cta.url` are escaped/validated for defense in depth. */ export function emailLayout(title: string, body: string, cta?: { label: string; url: string }) { const button = cta ? `${escapeHtml(cta.label)}` : ""; return `

🎙️ Podcast Distribution AI

${escapeHtml(title)}

${body}
${button}

If you didn't request this, you can ignore this email.

`; }