Files
podcastdistributiona/lib/email/index.ts
T
Leon Serfaty 51c541ad22 Security & robustness hardening pass
Cross-cutting input-validation, isolation, and DoS-resistance fixes across
the app, API, billing, queue, and infra layers.

- Runtime validation (zod) for client-supplied admin actions (role/plan/
  limits), series generation index, and all pg-boss queue payloads
- Auth: require email verification before sign-in; reject weak/placeholder/
  short BETTER_AUTH_SECRET in production
- Billing: sanitize Stripe/PayPal errors (log server-side, generic to client);
  race-safe subscription upsert; only count "processed" webhook events as
  handled; verify org membership in getEffectivePlan to block plan escalation
- Series generation: reserve usage up front and refund on failure; bill the
  owning org, not the caller's active org
- Injection defenses: HTML-escape user fields in emails, strip CR/LF from
  subject/recipient, validate ElevenLabs voiceId before URL interpolation
- Media routes: stream off disk instead of buffering whole files; rate-limit
  anonymous public audio/cover endpoints by client IP
2026-06-20 20:59:03 -04:00

78 lines
3.2 KiB
TypeScript

interface SendEmailInput {
to: string;
subject: string;
html: string;
text?: string;
}
const FROM = process.env.EMAIL_FROM ?? "Podcast Distribution AI <noreply@podcastdistributionai.com>";
/**
* 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<void> {
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** 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
? `<a href="${escapeHtml(safeUrl(cta.url))}" style="display:inline-block;background:#7c3aed;color:#fff;text-decoration:none;padding:12px 20px;border-radius:8px;font-weight:600;margin-top:16px">${escapeHtml(cta.label)}</a>`
: "";
return `
<div style="font-family:Inter,Arial,sans-serif;max-width:480px;margin:0 auto;padding:24px;color:#0a0a0a">
<h1 style="font-size:20px;margin:0 0 12px">🎙️ Podcast Distribution AI</h1>
<h2 style="font-size:18px;margin:0 0 12px">${escapeHtml(title)}</h2>
<div style="font-size:14px;line-height:1.6;color:#404040">${body}</div>
${button}
<p style="font-size:12px;color:#a3a3a3;margin-top:32px">If you didn't request this, you can ignore this email.</p>
</div>`;
}