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

@ -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
| 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 |