wulf-pulse/lib/services/email.ts
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target
  resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift)
- LogLift evidence pipeline (migration 078): upload webhook, B2 storage client,
  receiver/matcher, EventLogCollector PowerShell script
- IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket
  xrefs, applications/configurations browse pages + apply/revert/audit endpoints
- Link-aware analyzer bundles (migration 073) + provider toggle (migration 074):
  link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion
  panels, analyze-bundle endpoint
- Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts
  admin page, reconciler service, resolve endpoints
- Dashboard overhaul: integration-health service + alerts, overview/health endpoints
- Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:13:18 -04:00

437 lines
16 KiB
TypeScript

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<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: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
<div style="max-width: 600px; margin: 0 auto;">
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
<div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">PSA Management System</div>
</div>
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
<h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">Sign in to your account</h2>
<p style="margin: 0 0 24px; color:#334155;">Click the button below to sign in. This link will expire in 5 minutes.</p>
<div style="text-align: center; margin: 24px 0;">
<a href="${url}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
Sign in to Pulse
</a>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">If you didn't request this email, you can safely ignore it.</p>
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 12px; margin:0;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="${url}" style="color: #2563eb; word-break: break-all;">${url}</a>
</p>
</div>
</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: 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<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: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
<div style="max-width: 600px; margin: 0 auto;">
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
<div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">PSA Management System</div>
</div>
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
<h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">You're invited!</h2>
<p style="margin: 0 0 12px; color:#334155;"><strong>${escapeHtml(inviterName)}</strong> has invited you to join Pulse.</p>
<p style="margin: 0 0 24px; color:#334155;">Click the button below to accept the invitation and set up your account.</p>
<div style="text-align: center; margin: 24px 0;">
<a href="${url}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
Accept invitation
</a>
</div>
<p style="color: #64748b; font-size: 13px; margin: 0;">If you weren't expecting this invitation, you can safely ignore this email.</p>
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 12px; margin: 0;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="${url}" style="color: #2563eb; word-break: break-all;">${url}</a>
</p>
</div>
</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: 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
const GAP_TONES: Record<Gap["severity"], { border: string; bg: string; label: string }> = {
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<NonNullable<AnalysisShareEmailParams["modelTier"]>, string> = {
haiku: "Haiku",
sonnet: "Haiku → Sonnet",
opus: "Haiku → Sonnet → Opus",
};
function bulletListHtml(items: string[]): string {
return `<ul style="margin: 8px 0 16px; padding-left: 20px; color: #334155;">
${items
.map(
(item) =>
`<li style="margin: 4px 0;">${escapeHtml(item)}</li>`
)
.join("")}
</ul>`;
}
function gapsHtml(gaps: Gap[]): string {
return gaps
.map((g) => {
const tone = GAP_TONES[g.severity] ?? GAP_TONES.low;
return `<div style="border-left: 3px solid ${tone.border}; background: ${tone.bg}; padding: 10px 14px; margin: 8px 0; border-radius: 0 4px 4px 0;">
<div style="font-size: 11px; font-weight: 700; letter-spacing: 0.05em; color: ${tone.border}; margin-bottom: 4px;">${tone.label}</div>
<div style="color: #0f172a;">${escapeHtml(g.description)}</div>
</div>`;
})
.join("");
}
function sectionHeaderHtml(title: string): string {
return `<h3 style="font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; margin: 24px 0 8px; padding-bottom: 6px; border-bottom: 1px solid #e2e8f0;">${escapeHtml(title)}</h3>`;
}
export async function sendAnalysisShareEmail({
recipientEmail,
senderName,
senderEmail,
ticketNumber,
ticketTitle,
analysisVersion,
summary,
nextStep,
nextStepRationale,
whatWasDone,
whatShouldHaveBeenDone,
gaps,
confidenceScore,
modelTier,
analysisUrl,
note,
}: AnalysisShareEmailParams): Promise<void> {
const transport = getTransporter();
const subjectLine =
`Pulse analysis · ${ticketNumber} v${analysisVersion}` +
(summary ? `${summary.slice(0, 80)}` : "");
const titleLine = ticketTitle
? `<div style="color: #475569; font-size: 14px; margin-top: 4px;">${escapeHtml(ticketTitle)}</div>`
: "";
const summarySection = summary
? `${sectionHeaderHtml("Summary")}<p style="color: #0f172a; margin: 0 0 8px; font-size: 15px; line-height: 1.55;">${escapeHtml(summary)}</p>`
: "";
const nextStepSection = nextStep
? `${sectionHeaderHtml("Next step")}<p style="color: #0f172a; margin: 0 0 8px; font-weight: 500;">${escapeHtml(nextStep)}</p>${
nextStepRationale
? `<p style="color: #475569; margin: 4px 0 16px; font-size: 14px;">${escapeHtml(nextStepRationale)}</p>`
: ""
}`
: "";
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
? `<div style="background: #f8fafc; border-left: 3px solid #2563eb; padding: 12px 16px; margin: 0 0 24px; border-radius: 0 4px 4px 0;">
<div style="font-size: 11px; font-weight: 700; letter-spacing: 0.05em; color: #2563eb; margin-bottom: 4px;">NOTE FROM ${escapeHtml(senderName).toUpperCase()}</div>
<div style="color: #0f172a; white-space: pre-wrap;">${escapeHtml(note)}</div>
</div>`
: "";
const confidenceBadge =
typeof confidenceScore === "number"
? `<span style="display: inline-block; padding: 2px 8px; border-radius: 999px; background: #f1f5f9; color: #475569; font-size: 11px; font-weight: 600; letter-spacing: 0.02em;">${Math.round(confidenceScore * 100)}% confidence</span>`
: "";
const modelBadge = modelTier
? `<span style="color:#64748b; font-size: 12px;">${MODEL_LABEL[modelTier]}</span>`
: "";
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: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
<div style="max-width: 640px; margin: 0 auto;">
<!-- Header -->
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
<div style="color: #94a3b8; font-size: 12px; margin-top: 2px;">Ticket analysis</div>
</div>
<!-- Body -->
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
<!-- Ticket header -->
<div style="margin-bottom: 4px;">
<span style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 13px; color: #2563eb; font-weight: 600;">${escapeHtml(ticketNumber)}</span>
<span style="color: #94a3b8; font-size: 12px; margin-left: 8px;">v${analysisVersion}</span>
${confidenceBadge ? `<span style="margin-left: 8px;">${confidenceBadge}</span>` : ""}
</div>
${titleLine}
<!-- Sender -->
<p style="color: #475569; font-size: 14px; margin: 16px 0 24px;">
<strong style="color: #0f172a;">${escapeHtml(senderName)}</strong> shared this with you.
</p>
${noteSection}
${summarySection}
${nextStepSection}
${whatWasDoneSection}
${whatShouldHaveBeenDoneSection}
${gapsSection}
<!-- CTA -->
<div style="text-align: center; margin: 32px 0 16px;">
<a href="${analysisUrl}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
Open full analysis in Pulse
</a>
</div>
<!-- Footer -->
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0 16px;">
<table style="width: 100%; font-size: 12px; color: #94a3b8;">
<tr>
<td style="padding: 0;">${modelBadge}</td>
<td style="padding: 0; text-align: right;">Reply to <a href="mailto:${escapeHtml(senderEmail)}" style="color: #64748b;">${escapeHtml(senderEmail)}</a></td>
</tr>
</table>
<p style="color: #94a3b8; font-size: 11px; margin: 12px 0 0; word-break: break-all;">
<a href="${analysisUrl}" style="color: #94a3b8;">${analysisUrl}</a>
</p>
</div>
</div>
</body>
</html>
`;
// 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<boolean> {
try {
const transport = getTransporter();
await transport.verify();
return true;
} catch (error) {
console.error("SMTP connection verification failed:", error);
return false;
}
}