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>
278 lines
9.6 KiB
TypeScript
278 lines
9.6 KiB
TypeScript
import * as nodemailer from "nodemailer";
|
|
|
|
// 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";
|
|
|
|
// Create reusable transporter
|
|
let transporter: nodemailer.Transporter | null = null;
|
|
|
|
function getTransporter(): nodemailer.Transporter {
|
|
if (!transporter) {
|
|
transporter = nodemailer.createTransport(smtpConfig);
|
|
}
|
|
return transporter;
|
|
}
|
|
|
|
// Email templates
|
|
interface MagicLinkEmailParams {
|
|
email: string;
|
|
url: string;
|
|
token: string;
|
|
}
|
|
|
|
export async function sendMagicLinkEmail({
|
|
email,
|
|
url,
|
|
}: MagicLinkEmailParams): Promise<void> {
|
|
const transport = getTransporter();
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Sign in to Pulse</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>
|
|
</div>
|
|
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
|
<h2 style="color: #333; margin-top: 0;">Sign in to your account</h2>
|
|
<p>Click the button below to sign in to Pulse. This link will expire in 5 minutes.</p>
|
|
<div style="text-align: center; margin: 30px 0;">
|
|
<a href="${url}" 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;">
|
|
Sign in to Pulse
|
|
</a>
|
|
</div>
|
|
<p style="color: #666; font-size: 14px;">If you didn't request this email, you can safely ignore it.</p>
|
|
<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="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
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: fromAddress,
|
|
to: email,
|
|
subject: "Sign in to Pulse",
|
|
text,
|
|
html,
|
|
});
|
|
}
|
|
|
|
interface InvitationEmailParams {
|
|
email: string;
|
|
inviterName: string;
|
|
url: string;
|
|
}
|
|
|
|
export async function sendInvitationEmail({
|
|
email,
|
|
inviterName,
|
|
url,
|
|
}: InvitationEmailParams): Promise<void> {
|
|
const transport = getTransporter();
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>You're invited to Pulse</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>
|
|
</div>
|
|
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
|
<h2 style="color: #333; margin-top: 0;">You're invited!</h2>
|
|
<p><strong>${inviterName}</strong> has invited you to join Pulse.</p>
|
|
<p>Click the button below to accept the invitation and set up your account.</p>
|
|
<div style="text-align: center; margin: 30px 0;">
|
|
<a href="${url}" 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;">
|
|
Accept Invitation
|
|
</a>
|
|
</div>
|
|
<p style="color: #666; font-size: 14px;">If you weren't expecting this invitation, you can safely ignore this email.</p>
|
|
<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="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
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: fromAddress,
|
|
to: email,
|
|
subject: "You're invited to Pulse",
|
|
text,
|
|
html,
|
|
});
|
|
}
|
|
|
|
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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 {
|
|
const transport = getTransporter();
|
|
await transport.verify();
|
|
return true;
|
|
} catch (error) {
|
|
console.error("SMTP connection verification failed:", error);
|
|
return false;
|
|
}
|
|
}
|