diff --git a/app/api/analyzer/analyses/[id]/share/route.ts b/app/api/analyzer/analyses/[id]/share/route.ts index bca7cbe..ebc982a 100644 --- a/app/api/analyzer/analyses/[id]/share/route.ts +++ b/app/api/analyzer/analyses/[id]/share/route.ts @@ -3,10 +3,11 @@ * * Body: { recipientEmail: string, note?: string } * - * Records a share row in analyzer_shares. Email send is deferred to phase 8 - * — this endpoint validates the recipient domain against ALLOWED_SHARE_DOMAINS - * and persists the audit row. The share is "pending delivery" until phase 8 - * wires up nodemailer. + * Records a share row in analyzer_shares (audit log) and emails the recipient + * a link to the analysis. The audit row is persisted FIRST; if the email + * send fails the response carries `emailSent: false` + the error message, + * but the share row stays. This matches the spec's "share is an audit log" + * framing — we'd rather record an attempted share than lose it. */ import { NextRequest, NextResponse } from 'next/server'; @@ -16,6 +17,7 @@ import { createShare, getAnalysisById, } from '@/lib/services/analyzer/persistence'; +import { sendAnalysisShareEmail } from '@/lib/services/email'; function getAllowedDomains(): string[] { const raw = process.env.ALLOWED_SHARE_DOMAINS ?? ''; @@ -25,6 +27,14 @@ function getAllowedDomains(): string[] { .filter((d) => d.length > 0); } +function getAppBaseUrl(): string { + return ( + process.env.BETTER_AUTH_URL || + process.env.NEXT_PUBLIC_BETTER_AUTH_URL || + 'http://localhost:3100' + ); +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -65,20 +75,47 @@ export async function POST( ); } - // Confirm the analysis exists. const analysis = await getAnalysisById(id); if (!analysis) { return NextResponse.json({ error: 'Analysis not found' }, { status: 404 }); } - const userId = (session?.user as { id: string }).id; + const sessionUser = session?.user as { + id: string; + email: string; + name?: string; + }; const share = await createShare({ analysis_id: id, - shared_by_user_id: userId, + shared_by_user_id: sessionUser.id, shared_with_email: recipientEmail, note, }); + const analysisUrl = `${getAppBaseUrl().replace(/\/$/, '')}/analyzer/analysis/${id}`; + let emailSent = true; + let emailError: string | null = null; + try { + await sendAnalysisShareEmail({ + recipientEmail, + senderName: sessionUser.name || sessionUser.email, + senderEmail: sessionUser.email, + ticketNumber: analysis.ticketNumber, + analysisVersion: analysis.analysisVersion, + summary: analysis.summary, + nextStep: analysis.nextStep, + analysisUrl, + note, + }); + } catch (err) { + emailSent = false; + emailError = err instanceof Error ? err.message : 'Unknown email error'; + console.error( + `[ANALYZER-SHARE] email send failed for share ${share.id}:`, + err + ); + } + return NextResponse.json({ share: { id: share.id, @@ -87,5 +124,7 @@ export async function POST( sharedAt: share.shared_at, note: note ?? null, }, + emailSent, + emailError, }); } diff --git a/components/analyzer/share-modal.tsx b/components/analyzer/share-modal.tsx index 031f399..d7e0124 100644 --- a/components/analyzer/share-modal.tsx +++ b/components/analyzer/share-modal.tsx @@ -39,11 +39,22 @@ export function ShareModal({ analysisId }: ShareModalProps) { note: note.trim() || undefined, }), }); + const data = (await res.json().catch(() => ({}))) as { + error?: string; + message?: string; + emailSent?: boolean; + emailError?: string | null; + }; if (!res.ok) { - const data = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; throw new Error(data.message ?? data.error ?? `Request failed: ${res.status}`); } - toast.success(`Shared with ${recipientEmail}`); + if (data.emailSent === false) { + toast.warning( + `Share recorded for ${recipientEmail}, but email failed: ${data.emailError ?? 'unknown error'}` + ); + } else { + toast.success(`Shared with ${recipientEmail}`); + } setOpen(false); setRecipientEmail(''); setNote(''); diff --git a/docs/wulf-pulse-ticket-analyzer-build-notes.md b/docs/wulf-pulse-ticket-analyzer-build-notes.md index 68ed47c..572c298 100644 --- a/docs/wulf-pulse-ticket-analyzer-build-notes.md +++ b/docs/wulf-pulse-ticket-analyzer-build-notes.md @@ -318,6 +318,64 @@ This file is updated after each phase ships. --- +## Phase 7 — Share-via-email integration + +**Delivered** + +- `sendAnalysisShareEmail()` added to `lib/services/email.ts` — re-uses the + existing nodemailer SMTP transport that already serves magic-link and + invitation mail. Subject `Pulse analysis · v`, gradient-header HTML body matching the other Pulse emails, plain + text fallback, `replyTo` set to the sharer so a recipient reply lands with + the right person. +- Share route `app/api/analyzer/analyses/[id]/share/route.ts` now resolves + the sharer's session, builds the analysis URL from `BETTER_AUTH_URL` + (falling back to `NEXT_PUBLIC_BETTER_AUTH_URL`, then `localhost:3100`), + persists the audit row, then attempts the email send. +- Share-modal frontend handles the new `emailSent`/`emailError` fields: + success → green toast; row-saved-but-send-failed → orange `toast.warning` + carrying the SMTP error. + +**Decisions worth flagging** + +- **Audit row persists even if email send fails.** The `analyzer_shares` + row is the audit log, not just a delivery receipt. SMTP outages should + not erase the record that the user attempted a share. +- **Response is HTTP 200 on email failure.** The route returns + `{share, emailSent: false, emailError}`. A non-2xx would imply the share + itself failed; surfacing `emailSent: false` is the more honest signal. +- **No `email_sent_at` column added.** Adding it requires a migration and + a way to retry — neither is asked for by the spec. Send state lives only + in the response and the server log line `[ANALYZER-SHARE] email send + failed for share `. If retries become a real need, a dedicated + `analyzer_share_email_attempts` table is the natural shape. +- **Used existing nodemailer SMTP, not Graph sendMail.** Spec said "via + M365 Graph using existing wulf-pulse mail integration if one exists; + otherwise use a new module" — `email.ts` *is* the existing module. The + Graph integration in this codebase is read-only (mailbox search, user + reports), not send-capable. +- **HTML escaping is hand-rolled.** Five-replace function for + amp/lt/gt/quote/apos. The user-controlled fields (sender name, note, + analysis summary, next step) all flow through it. The repo has no HTML + escaper utility and the magic-link/invitation emails don't need one + because their inputs are URLs and admin-set names. + +**Deliberately left out** + +- **No retries.** A failed send is logged once and surfaced to the user. + They can re-share if they want — that creates a fresh audit row, which + is correct behavior. +- **No tests.** `lib/services/email.ts` has no existing tests, mocking + nodemailer fully would add a non-trivial test scaffold for one + function, and the share route is route-handler thin. Consistent with + phases 5/6. +- **No `viewed_at` tracking yet.** The migration has the column but + nothing writes to it. A `/share/:id/viewed` endpoint with an + unguessable token would be the smallest addition; spec didn't ask, so + skipped. + +--- + ## Status after each phase | Phase | Tests | tsc | Notes | @@ -328,3 +386,4 @@ This file is updated after each phase ships. | 4 | 128 | clean | + pipeline + worker | | 5 | 128 | clean | API routes (no route tests) | | 6 | 128 | clean | frontend (no FE tests) | +| 7 | 128 | clean | share email via existing SMTP transport | diff --git a/lib/services/email.ts b/lib/services/email.ts index 5b4d613..85bb6c0 100644 --- a/lib/services/email.ts +++ b/lib/services/email.ts @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export async function sendAnalysisShareEmail({ + recipientEmail, + senderName, + senderEmail, + ticketNumber, + analysisVersion, + summary, + nextStep, + analysisUrl, + note, +}: AnalysisShareEmailParams): Promise { + const transport = getTransporter(); + + const subjectLine = + `Pulse analysis · ${ticketNumber} v${analysisVersion}` + + (summary ? ` — ${summary.slice(0, 80)}` : ""); + + const summaryHtml = summary + ? `

${escapeHtml(summary)}

` + : `

No summary available.

`; + const nextStepHtml = nextStep + ? `

Next step

${escapeHtml(nextStep)}

` + : ""; + const noteHtml = note + ? `
+ ${escapeHtml(senderName)} added a note: +

${escapeHtml(note)}

+
` + : ""; + + const html = ` + + + + + + ${escapeHtml(subjectLine)} + + +
+

Pulse

+

Ticket analysis · ${escapeHtml(ticketNumber)} · v${analysisVersion}

+
+
+

${escapeHtml(senderName)} (${escapeHtml(senderEmail)}) shared an analysis with you.

+ ${noteHtml} +

Summary

+ ${summaryHtml} + ${nextStepHtml} + +
+

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

+
+ + + `; + + 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 { try {