feat(analyzer): phase 7 — share-via-email

sendAnalysisShareEmail() reuses the existing nodemailer SMTP transport
(same path as magic-link/invitation mail). Share route persists the
audit row first, then attempts send; on failure returns
{share, emailSent:false, emailError} at HTTP 200 so the audit log
stays intact. Modal surfaces send failures as a warning toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-04-29 11:03:11 -04:00
parent 8f8b5ab7be
commit ed3b363d02
4 changed files with 231 additions and 9 deletions

View file

@ -152,6 +152,119 @@ If you weren't expecting this invitation, you can safely ignore this email.
});
}
interface AnalysisShareEmailParams {
recipientEmail: string;
senderName: string;
senderEmail: string;
ticketNumber: string;
analysisVersion: number;
summary: string | null;
nextStep: string | null;
analysisUrl: string;
note?: string;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export async function sendAnalysisShareEmail({
recipientEmail,
senderName,
senderEmail,
ticketNumber,
analysisVersion,
summary,
nextStep,
analysisUrl,
note,
}: AnalysisShareEmailParams): Promise<void> {
const transport = getTransporter();
const subjectLine =
`Pulse analysis · ${ticketNumber} v${analysisVersion}` +
(summary ? `${summary.slice(0, 80)}` : "");
const summaryHtml = summary
? `<p>${escapeHtml(summary)}</p>`
: `<p style="color:#999;font-style:italic;">No summary available.</p>`;
const nextStepHtml = nextStep
? `<h3 style="margin-bottom:4px;">Next step</h3><p>${escapeHtml(nextStep)}</p>`
: "";
const noteHtml = note
? `<div style="background:#f6f8fa;border-left:3px solid #667eea;padding:12px 16px;margin:20px 0;">
<strong>${escapeHtml(senderName)} added a note:</strong>
<p style="margin:8px 0 0;white-space:pre-wrap;">${escapeHtml(note)}</p>
</div>`
: "";
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(subjectLine)}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
<p style="color: rgba(255,255,255,0.9); margin: 4px 0 0;">Ticket analysis · ${escapeHtml(ticketNumber)} · v${analysisVersion}</p>
</div>
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
<p>${escapeHtml(senderName)} (${escapeHtml(senderEmail)}) shared an analysis with you.</p>
${noteHtml}
<h3 style="margin-bottom:4px;">Summary</h3>
${summaryHtml}
${nextStepHtml}
<div style="text-align: center; margin: 30px 0;">
<a href="${analysisUrl}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
Open analysis in Pulse
</a>
</div>
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
<p style="color: #999; font-size: 12px;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="${analysisUrl}" style="color: #667eea; word-break: break-all;">${analysisUrl}</a>
</p>
</div>
</body>
</html>
`;
const textParts = [
`${senderName} (${senderEmail}) shared a Pulse ticket analysis with you.`,
`Ticket: ${ticketNumber} (analysis v${analysisVersion})`,
"",
];
if (note) {
textParts.push(`Note from ${senderName}:`, note, "");
}
textParts.push(
"Summary:",
summary ?? "(no summary available)",
""
);
if (nextStep) {
textParts.push("Next step:", nextStep, "");
}
textParts.push(`Open in Pulse: ${analysisUrl}`);
await transport.sendMail({
from: fromAddress,
to: recipientEmail,
replyTo: senderEmail,
subject: subjectLine,
text: textParts.join("\n"),
html,
});
}
// Verify SMTP connection
export async function verifyEmailConnection(): Promise<boolean> {
try {