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:
parent
8f8b5ab7be
commit
ed3b363d02
4 changed files with 231 additions and 9 deletions
|
|
@ -3,10 +3,11 @@
|
||||||
*
|
*
|
||||||
* Body: { recipientEmail: string, note?: string }
|
* Body: { recipientEmail: string, note?: string }
|
||||||
*
|
*
|
||||||
* Records a share row in analyzer_shares. Email send is deferred to phase 8
|
* Records a share row in analyzer_shares (audit log) and emails the recipient
|
||||||
* — this endpoint validates the recipient domain against ALLOWED_SHARE_DOMAINS
|
* a link to the analysis. The audit row is persisted FIRST; if the email
|
||||||
* and persists the audit row. The share is "pending delivery" until phase 8
|
* send fails the response carries `emailSent: false` + the error message,
|
||||||
* wires up nodemailer.
|
* 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';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
@ -16,6 +17,7 @@ import {
|
||||||
createShare,
|
createShare,
|
||||||
getAnalysisById,
|
getAnalysisById,
|
||||||
} from '@/lib/services/analyzer/persistence';
|
} from '@/lib/services/analyzer/persistence';
|
||||||
|
import { sendAnalysisShareEmail } from '@/lib/services/email';
|
||||||
|
|
||||||
function getAllowedDomains(): string[] {
|
function getAllowedDomains(): string[] {
|
||||||
const raw = process.env.ALLOWED_SHARE_DOMAINS ?? '';
|
const raw = process.env.ALLOWED_SHARE_DOMAINS ?? '';
|
||||||
|
|
@ -25,6 +27,14 @@ function getAllowedDomains(): string[] {
|
||||||
.filter((d) => d.length > 0);
|
.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(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
|
@ -65,20 +75,47 @@ export async function POST(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm the analysis exists.
|
|
||||||
const analysis = await getAnalysisById(id);
|
const analysis = await getAnalysisById(id);
|
||||||
if (!analysis) {
|
if (!analysis) {
|
||||||
return NextResponse.json({ error: 'Analysis not found' }, { status: 404 });
|
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({
|
const share = await createShare({
|
||||||
analysis_id: id,
|
analysis_id: id,
|
||||||
shared_by_user_id: userId,
|
shared_by_user_id: sessionUser.id,
|
||||||
shared_with_email: recipientEmail,
|
shared_with_email: recipientEmail,
|
||||||
note,
|
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({
|
return NextResponse.json({
|
||||||
share: {
|
share: {
|
||||||
id: share.id,
|
id: share.id,
|
||||||
|
|
@ -87,5 +124,7 @@ export async function POST(
|
||||||
sharedAt: share.shared_at,
|
sharedAt: share.shared_at,
|
||||||
note: note ?? null,
|
note: note ?? null,
|
||||||
},
|
},
|
||||||
|
emailSent,
|
||||||
|
emailError,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,22 @@ export function ShareModal({ analysisId }: ShareModalProps) {
|
||||||
note: note.trim() || undefined,
|
note: note.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
const data = (await res.json().catch(() => ({}))) as {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
emailSent?: boolean;
|
||||||
|
emailError?: string | null;
|
||||||
|
};
|
||||||
if (!res.ok) {
|
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}`);
|
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);
|
setOpen(false);
|
||||||
setRecipientEmail('');
|
setRecipientEmail('');
|
||||||
setNote('');
|
setNote('');
|
||||||
|
|
|
||||||
|
|
@ -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 · <ticket> v<n> — <summary
|
||||||
|
excerpt>`, 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 <id>`. 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
|
## Status after each phase
|
||||||
|
|
||||||
| Phase | Tests | tsc | Notes |
|
| Phase | Tests | tsc | Notes |
|
||||||
|
|
@ -328,3 +386,4 @@ This file is updated after each phase ships.
|
||||||
| 4 | 128 | clean | + pipeline + worker |
|
| 4 | 128 | clean | + pipeline + worker |
|
||||||
| 5 | 128 | clean | API routes (no route tests) |
|
| 5 | 128 | clean | API routes (no route tests) |
|
||||||
| 6 | 128 | clean | frontend (no FE tests) |
|
| 6 | 128 | clean | frontend (no FE tests) |
|
||||||
|
| 7 | 128 | clean | share email via existing SMTP transport |
|
||||||
|
|
|
||||||
|
|
@ -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, """)
|
||||||
|
.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
|
// Verify SMTP connection
|
||||||
export async function verifyEmailConnection(): Promise<boolean> {
|
export async function verifyEmailConnection(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue