import * as nodemailer from "nodemailer"; import type { Gap } from "@/lib/types/analyzer"; // SMTP configuration from environment variables const smtpConfig = { host: process.env.SMTP_HOST || "smtp.example.com", port: parseInt(process.env.SMTP_PORT || "587"), secure: process.env.SMTP_PORT === "465", // true for 465, false for other ports auth: { user: process.env.SMTP_USER || "", pass: process.env.SMTP_PASSWORD || "", }, }; const fromAddress = process.env.SMTP_FROM || "noreply@example.com"; // Display-name + address for the From header so recipient mail clients show // "Pulse" rather than the bare mailbox. const FROM_HEADER = { name: "Pulse", address: fromAddress }; // Create reusable transporter let transporter: nodemailer.Transporter | null = null; function getTransporter(): nodemailer.Transporter { if (!transporter) { transporter = nodemailer.createTransport(smtpConfig); } return transporter; } // ============================================================================= // Magic-link sign-in // ============================================================================= interface MagicLinkEmailParams { email: string; url: string; token: string; } export async function sendMagicLinkEmail({ email, url, }: MagicLinkEmailParams): Promise { const transport = getTransporter(); const html = ` Sign in to Pulse
Pulse
Operations console

Sign in to your account

Click the button below to sign in. This link will expire in 5 minutes.

Sign in to Pulse

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


If the button doesn't work, copy and paste this link into your browser:
${url}

`; const text = ` Sign in to Pulse Click the link below to sign in to your account. This link will expire in 5 minutes. ${url} If you didn't request this email, you can safely ignore it. `; await transport.sendMail({ from: FROM_HEADER, to: email, subject: "Sign in to Pulse", text, html, }); } // ============================================================================= // Invitation // ============================================================================= interface InvitationEmailParams { email: string; inviterName: string; url: string; } export async function sendInvitationEmail({ email, inviterName, url, }: InvitationEmailParams): Promise { const transport = getTransporter(); const html = ` You're invited to Pulse
Pulse
Operations console

You're invited!

${escapeHtml(inviterName)} has invited you to join Pulse.

Click the button below to accept the invitation and set up your account.

Accept invitation

If you weren't expecting this invitation, you can safely ignore this email.


If the button doesn't work, copy and paste this link into your browser:
${url}

`; const text = ` You're invited to Pulse! ${inviterName} has invited you to join Pulse. Click the link below to accept the invitation and set up your account: ${url} If you weren't expecting this invitation, you can safely ignore this email. `; await transport.sendMail({ from: FROM_HEADER, to: email, subject: "You're invited to Pulse", text, html, }); } // ============================================================================= // Analysis share // ============================================================================= interface AnalysisShareEmailParams { recipientEmail: string; senderName: string; senderEmail: string; ticketNumber: string; ticketTitle?: string | null; analysisVersion: number; summary: string | null; nextStep: string | null; nextStepRationale?: string | null; whatWasDone?: string[] | null; whatShouldHaveBeenDone?: string[] | null; gaps?: Gap[] | null; confidenceScore?: number | null; modelTier?: "haiku" | "sonnet" | "opus" | null; analysisUrl: string; note?: string; } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } const GAP_TONES: Record = { high: { border: "#ef4444", bg: "#fef2f2", label: "HIGH" }, medium: { border: "#f59e0b", bg: "#fffbeb", label: "MEDIUM" }, low: { border: "#3b82f6", bg: "#eff6ff", label: "LOW" }, }; const MODEL_LABEL: Record, string> = { haiku: "Haiku", sonnet: "Haiku → Sonnet", opus: "Haiku → Sonnet → Opus", }; function bulletListHtml(items: string[]): string { return `
    ${items .map( (item) => `
  • ${escapeHtml(item)}
  • ` ) .join("")}
`; } function gapsHtml(gaps: Gap[]): string { return gaps .map((g) => { const tone = GAP_TONES[g.severity] ?? GAP_TONES.low; return `
${tone.label}
${escapeHtml(g.description)}
`; }) .join(""); } function sectionHeaderHtml(title: string): string { return `

${escapeHtml(title)}

`; } export async function sendAnalysisShareEmail({ recipientEmail, senderName, senderEmail, ticketNumber, ticketTitle, analysisVersion, summary, nextStep, nextStepRationale, whatWasDone, whatShouldHaveBeenDone, gaps, confidenceScore, modelTier, analysisUrl, note, }: AnalysisShareEmailParams): Promise { const transport = getTransporter(); const subjectLine = `Pulse analysis · ${ticketNumber} v${analysisVersion}` + (summary ? ` — ${summary.slice(0, 80)}` : ""); const titleLine = ticketTitle ? `
${escapeHtml(ticketTitle)}
` : ""; const summarySection = summary ? `${sectionHeaderHtml("Summary")}

${escapeHtml(summary)}

` : ""; const nextStepSection = nextStep ? `${sectionHeaderHtml("Next step")}

${escapeHtml(nextStep)}

${ nextStepRationale ? `

${escapeHtml(nextStepRationale)}

` : "" }` : ""; const whatWasDoneSection = whatWasDone && whatWasDone.length > 0 ? `${sectionHeaderHtml("What was done")}${bulletListHtml(whatWasDone)}` : ""; const whatShouldHaveBeenDoneSection = whatShouldHaveBeenDone && whatShouldHaveBeenDone.length > 0 ? `${sectionHeaderHtml("What should have been done")}${bulletListHtml(whatShouldHaveBeenDone)}` : ""; const gapsSection = gaps && gaps.length > 0 ? `${sectionHeaderHtml("Gaps")}${gapsHtml(gaps)}` : ""; const noteSection = note ? `
NOTE FROM ${escapeHtml(senderName).toUpperCase()}
${escapeHtml(note)}
` : ""; const confidenceBadge = typeof confidenceScore === "number" ? `${Math.round(confidenceScore * 100)}% confidence` : ""; const modelBadge = modelTier ? `${MODEL_LABEL[modelTier]}` : ""; const html = ` ${escapeHtml(subjectLine)}
Pulse
Ticket analysis
${escapeHtml(ticketNumber)} v${analysisVersion} ${confidenceBadge ? `${confidenceBadge}` : ""}
${titleLine}

${escapeHtml(senderName)} shared this with you.

${noteSection} ${summarySection} ${nextStepSection} ${whatWasDoneSection} ${whatShouldHaveBeenDoneSection} ${gapsSection}
${modelBadge} Reply to ${escapeHtml(senderEmail)}

${analysisUrl}

`; // Plain-text fallback — keep the same content sections so non-HTML clients // see the full analysis, not a truncated nag to "open in browser". const textParts: string[] = []; textParts.push( `${senderName} shared a Pulse ticket analysis with you.`, `Ticket: ${ticketNumber}${ticketTitle ? ` — ${ticketTitle}` : ""} (analysis v${analysisVersion})`, "" ); if (note) { textParts.push(`Note from ${senderName}:`, note, ""); } if (summary) { textParts.push("SUMMARY", summary, ""); } if (nextStep) { textParts.push("NEXT STEP", nextStep); if (nextStepRationale) textParts.push(` Rationale: ${nextStepRationale}`); textParts.push(""); } if (whatWasDone && whatWasDone.length > 0) { textParts.push("WHAT WAS DONE"); whatWasDone.forEach((i) => textParts.push(` - ${i}`)); textParts.push(""); } if (whatShouldHaveBeenDone && whatShouldHaveBeenDone.length > 0) { textParts.push("WHAT SHOULD HAVE BEEN DONE"); whatShouldHaveBeenDone.forEach((i) => textParts.push(` - ${i}`)); textParts.push(""); } if (gaps && gaps.length > 0) { textParts.push("GAPS"); gaps.forEach((g) => textParts.push(` [${g.severity.toUpperCase()}] ${g.description}`) ); textParts.push(""); } textParts.push(`Open in Pulse: ${analysisUrl}`); await transport.sendMail({ from: FROM_HEADER, to: recipientEmail, replyTo: senderEmail, subject: subjectLine, text: textParts.join("\n"), html, }); } // Verify SMTP connection export async function verifyEmailConnection(): Promise { try { const transport = getTransporter(); await transport.verify(); return true; } catch (error) { console.error("SMTP connection verification failed:", error); return false; } }