;
+}): ValuationReleaseRow[] {
+ const { rollups, totalStreams, bandMid, artByAlbum } = params;
+ return rollups.map(rollup => ({
+ album: rollup.album,
+ artistNames: rollup.artistNames,
+ streams: rollup.streams,
+ value: totalStreams > 0 ? (bandMid * rollup.streams) / totalStreams : 0,
+ artUrl: rollup.album ? (artByAlbum.get(rollup.album.trim().toLowerCase()) ?? null) : null,
+ }));
+}
diff --git a/lib/emails/valuationReport/formatCompactNumber.ts b/lib/emails/valuationReport/formatCompactNumber.ts
new file mode 100644
index 00000000..0999f260
--- /dev/null
+++ b/lib/emails/valuationReport/formatCompactNumber.ts
@@ -0,0 +1,16 @@
+/**
+ * Deterministic compact number for the valuation email: 678, 12.3K, 1.2M,
+ * 1.4B (trailing .0 stripped). Extends the digest's K/M formatter with a B
+ * tier because lifetime catalog stream totals routinely cross a billion.
+ */
+export function formatCompactNumber(n: number): string {
+ const rounded = Math.round(n);
+ if (rounded < 1000) return String(rounded);
+ const [value, unit] =
+ rounded < 1_000_000
+ ? [rounded / 1000, "K"]
+ : rounded < 1_000_000_000
+ ? [rounded / 1_000_000, "M"]
+ : [rounded / 1_000_000_000, "B"];
+ return `${value.toFixed(1).replace(/\.0$/, "")}${unit}`;
+}
diff --git a/lib/emails/valuationReport/formatCompactUsd.ts b/lib/emails/valuationReport/formatCompactUsd.ts
new file mode 100644
index 00000000..680880d3
--- /dev/null
+++ b/lib/emails/valuationReport/formatCompactUsd.ts
@@ -0,0 +1,12 @@
+import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber";
+
+/**
+ * Format a whole-dollar amount as a compact dollar string for the valuation
+ * email, e.g. 3_569_477 → `$3.6M`. Distinct from `lib/stripe/formatUsd` (which
+ * takes cents and renders exact `$99.00` for billing) — this one takes dollars
+ * and compacts, for the large headline / per-release figures. Shared by the
+ * valuation block and per-release rows so both render money identically.
+ */
+export function formatCompactUsd(n: number): string {
+ return `$${formatCompactNumber(n)}`;
+}
diff --git a/lib/emails/valuationReport/renderArtistHeader.ts b/lib/emails/valuationReport/renderArtistHeader.ts
new file mode 100644
index 00000000..0ba3e66b
--- /dev/null
+++ b/lib/emails/valuationReport/renderArtistHeader.ts
@@ -0,0 +1,23 @@
+import { escapeHtml } from "@/lib/emails/escapeHtml";
+import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber";
+import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes";
+
+/**
+ * Render the artist header (avatar + name + follower count) for the valuation
+ * email. Returns "" when no named artist is present so the section collapses.
+ */
+export function renderArtistHeader(artist: ValuationReportEmailParams["artist"]): string {
+ if (!artist || !artist.name) return "";
+ const name = escapeHtml(artist.name);
+ const followers =
+ artist.followers != null
+ ? `${formatCompactNumber(artist.followers)} followers
`
+ : "";
+ const avatar = artist.imageUrl
+ ? `}) | `
+ : "";
+ return `
+${avatar}
+${name} ${followers} |
+
`;
+}
diff --git a/lib/emails/valuationReport/renderReleaseRow.ts b/lib/emails/valuationReport/renderReleaseRow.ts
new file mode 100644
index 00000000..72ad1d85
--- /dev/null
+++ b/lib/emails/valuationReport/renderReleaseRow.ts
@@ -0,0 +1,25 @@
+import { escapeHtml } from "@/lib/emails/escapeHtml";
+import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber";
+import { formatCompactUsd } from "@/lib/emails/valuationReport/formatCompactUsd";
+import type { ValuationReleaseRow } from "@/lib/emails/valuationReport/valuationReportTypes";
+
+/**
+ * Render one row of the per-release table: album art thumbnail, title + artists,
+ * streams, and proportional-share value. Falls back to a grey square when the
+ * release has no cover art.
+ */
+export function renderReleaseRow(release: ValuationReleaseRow): string {
+ const album = release.album ? escapeHtml(release.album) : "Untitled release";
+ const artists = release.artistNames.length
+ ? `${escapeHtml(release.artistNames.join(", "))}
`
+ : "";
+ const art = release.artUrl
+ ? `
`
+ : ``;
+ return `
+| ${art} |
+${album} ${artists} |
+${formatCompactNumber(release.streams)} |
+${formatCompactUsd(release.value)} |
+
`;
+}
diff --git a/lib/emails/valuationReport/renderReleasesTable.ts b/lib/emails/valuationReport/renderReleasesTable.ts
new file mode 100644
index 00000000..94124ee4
--- /dev/null
+++ b/lib/emails/valuationReport/renderReleasesTable.ts
@@ -0,0 +1,17 @@
+import { renderReleaseRow } from "@/lib/emails/valuationReport/renderReleaseRow";
+import type { ValuationReleaseRow } from "@/lib/emails/valuationReport/valuationReportTypes";
+
+/**
+ * Render the per-release table (header + one row per release). Returns "" when
+ * there are no releases so the section collapses.
+ */
+export function renderReleasesTable(releases: ValuationReleaseRow[] | undefined): string {
+ if (!releases || releases.length === 0) return "";
+ const header = `
+| Release |
+Streams |
+Value |
+
`;
+ const rows = releases.map(renderReleaseRow).join("");
+ return ``;
+}
diff --git a/lib/emails/valuationReport/renderStatRow.ts b/lib/emails/valuationReport/renderStatRow.ts
new file mode 100644
index 00000000..44532ddb
--- /dev/null
+++ b/lib/emails/valuationReport/renderStatRow.ts
@@ -0,0 +1,29 @@
+import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber";
+import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes";
+
+/**
+ * Render the measured-scope stat strip (lifetime streams, tracks measured,
+ * releases, catalog age). Each cell is omitted when its value is unavailable.
+ */
+export function renderStatRow(params: ValuationReportEmailParams): string {
+ const stats = [
+ params.totalStreams != null
+ ? ["Lifetime streams", formatCompactNumber(params.totalStreams)]
+ : null,
+ params.measuredSongCount != null
+ ? ["Tracks measured", formatCompactNumber(params.measuredSongCount)]
+ : null,
+ ["Releases", formatCompactNumber(params.releaseCount ?? params.albumCount)],
+ params.catalogAgeYears != null
+ ? ["Catalog age", `${params.catalogAgeYears} year${params.catalogAgeYears === 1 ? "" : "s"}`]
+ : null,
+ ].filter((s): s is [string, string] => s !== null);
+
+ const cells = stats
+ .map(
+ ([label, value]) =>
+ `${value} ${label} | `,
+ )
+ .join("");
+ return ``;
+}
diff --git a/lib/emails/valuationReport/renderValuationBlock.ts b/lib/emails/valuationReport/renderValuationBlock.ts
new file mode 100644
index 00000000..cf5c693b
--- /dev/null
+++ b/lib/emails/valuationReport/renderValuationBlock.ts
@@ -0,0 +1,15 @@
+import { formatCompactUsd } from "@/lib/emails/valuationReport/formatCompactUsd";
+import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes";
+
+/**
+ * Render the headline "Estimated catalog value" block (central value + range).
+ * Returns "" when the catalog has no valuation so the section collapses.
+ */
+export function renderValuationBlock(valuation: ValuationReportEmailParams["valuation"]): string {
+ if (!valuation) return "";
+ return `|
+ Estimated catalog value
+${formatCompactUsd(valuation.mid)}
+Range ${formatCompactUsd(valuation.low)} to ${formatCompactUsd(valuation.high)}
+ |
`;
+}
diff --git a/lib/emails/valuationReport/renderValuationReportHtml.ts b/lib/emails/valuationReport/renderValuationReportHtml.ts
new file mode 100644
index 00000000..3325f7e0
--- /dev/null
+++ b/lib/emails/valuationReport/renderValuationReportHtml.ts
@@ -0,0 +1,56 @@
+import { RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const";
+import { escapeHtml } from "@/lib/emails/escapeHtml";
+import { renderArtistHeader } from "@/lib/emails/valuationReport/renderArtistHeader";
+import { renderValuationBlock } from "@/lib/emails/valuationReport/renderValuationBlock";
+import { renderStatRow } from "@/lib/emails/valuationReport/renderStatRow";
+import { renderReleasesTable } from "@/lib/emails/valuationReport/renderReleasesTable";
+import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes";
+
+const SUBJECT = "Your catalog valuation is ready";
+const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
+
+/**
+ * Deterministic house-style renderer for the valuation-report email
+ * (recoupable/chat#1867, enriched per chat#1881): reproduces the marketing /
+ * chat catalog-report result so the email reinforces the same numbers a signup
+ * already saw — artist header, estimated value band, measured-scope stats, and
+ * a per-release table with album art + proportional-share value. Styling
+ * follows DESIGN.md: achromatic chrome (#0a0a0a on #ffffff, #e8e8e8 borders,
+ * #6b6b6b muted), tables + inline styles only, system font stack, fixed
+ * CHAT_APP_URL-based deep link (never a derived deployment URL). Copy avoids
+ * em/en dashes and uses "to" for ranges. Per-album value is a proportional
+ * share of the single headline band (value = mid x streams/total), so the rows
+ * sum to the headline and never diverge from the funnel.
+ */
+export function renderValuationReportHtml(params: ValuationReportEmailParams): {
+ subject: string;
+ html: string;
+} {
+ const name = params.catalogName ? escapeHtml(params.catalogName) : "Your catalog";
+
+ const disclaimer = params.valuation
+ ? `Directional model, not an appraisal. Based on live Spotify play counts measured today, an annual run-rate from your catalog's lifetime average, and a master-side net royalty share times a 10 to 16x market multiple. Real statements collapse the range.
`
+ : "";
+
+ const html = `
+
+
+
+|
+ Catalog valuation
+${name}
+ |
+ |
+
+${renderArtistHeader(params.artist)}
+${renderValuationBlock(params.valuation)}
+${renderStatRow(params)}
+${renderReleasesTable(params.releases)}
+${disclaimer}
+
+You're receiving this because you ran a catalog valuation on Recoup.
+ |
+ |
`;
+
+ return { subject: SUBJECT, html };
+}
diff --git a/lib/emails/valuationReport/sendValuationReportEmail.ts b/lib/emails/valuationReport/sendValuationReportEmail.ts
new file mode 100644
index 00000000..5d2a6094
--- /dev/null
+++ b/lib/emails/valuationReport/sendValuationReportEmail.ts
@@ -0,0 +1,134 @@
+import { NextResponse } from "next/server";
+import { CHAT_APP_URL, RECOUP_FROM_EMAIL } from "@/lib/const";
+import { sendEmailWithResend } from "@/lib/emails/sendEmail";
+import { logEmailAttempt } from "@/lib/emails/logEmailAttempt";
+import { renderValuationReportHtml } from "@/lib/emails/valuationReport/renderValuationReportHtml";
+import { buildReleaseRows } from "@/lib/emails/valuationReport/buildReleaseRows";
+import { selectEmailSendLog } from "@/lib/supabase/email_send_log/selectEmailSendLog";
+import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails";
+import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById";
+import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate";
+import { getCatalogEarliestReleaseDate } from "@/lib/catalog/getCatalogEarliestReleaseDate";
+import { computeValuationBand } from "@/lib/catalog/computeValuationBand";
+import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes";
+import type { SpotifyArtist } from "@/types/spotify.types";
+import type { Tables } from "@/types/database.types";
+
+export type SendValuationReportEmailResult =
+ | { sent: true; resendId: string }
+ | { sent: false; skipped: "already_sent" | "no_email" }
+ | { sent: false; error: string };
+
+/**
+ * Emails the valuation summary for a completed snapshot run to the owning
+ * account (recoupable/chat#1867, enriched per chat#1881). Reproduces the
+ * marketing / chat catalog-report result — artist header, headline band,
+ * measured-scope stats, and a per-release table with album art + proportional
+ * value — so it reinforces numbers the signup already saw. Idempotent per run
+ * twice over: a `"snapshot_id"` marker in `email_send_log.raw_body` guards
+ * re-invocations for as long as the log exists, and the Resend idempotency key
+ * (`valuation-report/`) guards racing retries within Resend's 24h window.
+ * Skips silently when the account has no email. Every enrichment step is
+ * best-effort: a failure degrades toward the link-only email rather than
+ * dropping the send. Fired from runValuationHandler after the catalog is
+ * materialized (chat#1881), so `snapshot.catalog` is set by call time.
+ *
+ * @param snapshot - The completed playcount_snapshots row (with catalog claimed)
+ * @param options.artist - The searched Spotify artist for the header (name, avatar, followers)
+ */
+export async function sendValuationReportEmail(
+ snapshot: Tables<"playcount_snapshots">,
+ options: { artist?: SpotifyArtist | null } = {},
+): Promise {
+ // Long-window idempotency: a prior successful send for this run is marked by
+ // the `"snapshot_id":""` marker in raw_body (Resend's key only covers 24h).
+ const alreadySent = await selectEmailSendLog({
+ status: "sent",
+ rawBodyLike: `"snapshot_id":"${snapshot.id}"`,
+ limit: 1,
+ });
+ if (alreadySent.length > 0) {
+ return { sent: false, skipped: "already_sent" };
+ }
+
+ const emailRows = await selectAccountEmails({ accountIds: snapshot.account });
+ const emails = [...new Set(emailRows.map(row => row.email).filter((e): e is string => !!e))];
+ if (emails.length === 0) {
+ return { sent: false, skipped: "no_email" };
+ }
+
+ const params: ValuationReportEmailParams = {
+ catalogName: null,
+ deepLinkUrl: snapshot.catalog ? `${CHAT_APP_URL}/catalogs/${snapshot.catalog}` : CHAT_APP_URL,
+ albumCount: snapshot.album_count ?? snapshot.album_ids?.length ?? 0,
+ };
+
+ if (options.artist?.name) {
+ params.artist = {
+ name: options.artist.name,
+ imageUrl: options.artist.images?.[0]?.url ?? null,
+ followers: options.artist.followers?.total ?? null,
+ };
+ }
+
+ if (snapshot.catalog) {
+ try {
+ const [catalog, aggregate, earliestReleaseDate] = await Promise.all([
+ selectCatalogById(snapshot.catalog),
+ selectCatalogMeasurementsAggregate({ catalogId: snapshot.catalog }),
+ getCatalogEarliestReleaseDate(snapshot.catalog),
+ ]);
+ params.catalogName = catalog?.name ?? null;
+ if (aggregate && aggregate.totalStreams > 0) {
+ const { valuation, catalogAgeYears } = computeValuationBand({
+ totalStreams: aggregate.totalStreams,
+ earliestReleaseDate,
+ });
+ params.valuation = valuation;
+ params.totalStreams = aggregate.totalStreams;
+ params.measuredSongCount = aggregate.measuredSongCount;
+ params.catalogAgeYears = catalogAgeYears;
+
+ const releases = await buildReleaseRows(
+ snapshot.catalog,
+ snapshot.album_ids ?? [],
+ aggregate.totalStreams,
+ valuation.mid,
+ );
+ params.releases = releases;
+ params.releaseCount = releases?.length || undefined;
+ }
+ } catch (error) {
+ console.error(`Valuation email enrichment failed for snapshot ${snapshot.id}:`, error);
+ }
+ }
+
+ const { subject, html } = renderValuationReportHtml(params);
+ const rawBody = JSON.stringify({
+ type: "valuation_report",
+ snapshot_id: snapshot.id,
+ catalog: snapshot.catalog,
+ to: emails,
+ subject,
+ });
+
+ const result = await sendEmailWithResend(
+ { from: RECOUP_FROM_EMAIL, to: emails, subject, html },
+ { idempotencyKey: `valuation-report/${snapshot.id}` },
+ );
+
+ if (result instanceof NextResponse) {
+ await logEmailAttempt({ rawBody, status: "send_failed", accountId: snapshot.account });
+ const data = await result.json().catch(() => null);
+ const message = typeof data?.error === "string" ? data.error : "Failed to send email";
+ return { sent: false, error: message };
+ }
+
+ await logEmailAttempt({
+ rawBody,
+ status: "sent",
+ accountId: snapshot.account,
+ resendId: result.id,
+ });
+ return { sent: true, resendId: result.id };
+}
diff --git a/lib/emails/valuationReport/valuationReportTypes.ts b/lib/emails/valuationReport/valuationReportTypes.ts
new file mode 100644
index 00000000..4bc4821a
--- /dev/null
+++ b/lib/emails/valuationReport/valuationReportTypes.ts
@@ -0,0 +1,27 @@
+/**
+ * Shared shapes for the valuation-report email (recoupable/chat#1867, enriched
+ * per chat#1881). Kept in their own module so the per-section render helpers and
+ * the top-level renderer can import them without a circular dependency.
+ */
+
+export type ValuationReleaseRow = {
+ album: string | null;
+ artistNames: string[];
+ streams: number;
+ /** Proportional share of the band's central value (streams / totalStreams x mid). */
+ value: number;
+ artUrl: string | null;
+};
+
+export type ValuationReportEmailParams = {
+ catalogName: string | null;
+ deepLinkUrl: string;
+ albumCount: number;
+ artist?: { name: string | null; imageUrl: string | null; followers: number | null };
+ valuation?: { low: number; mid: number; high: number };
+ totalStreams?: number;
+ measuredSongCount?: number;
+ releaseCount?: number;
+ catalogAgeYears?: number;
+ releases?: ValuationReleaseRow[];
+};
diff --git a/lib/spotify/getAlbums.ts b/lib/spotify/getAlbums.ts
index 752b2b45..b181a2b0 100644
--- a/lib/spotify/getAlbums.ts
+++ b/lib/spotify/getAlbums.ts
@@ -2,6 +2,7 @@ export type SpotifyAlbum = {
id: string;
name?: string;
release_date?: string;
+ images?: Array<{ url: string; height?: number | null; width?: number | null }>;
};
// Spotify's GET /v1/albums caps ids at 20 per request.
diff --git a/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts b/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts
new file mode 100644
index 00000000..97283808
--- /dev/null
+++ b/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts
@@ -0,0 +1,61 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { selectEmailSendLog } from "../selectEmailSendLog";
+import supabase from "../../serverClient";
+
+vi.mock("../../serverClient", () => {
+ const mockFrom = vi.fn();
+ return { default: { from: mockFrom } };
+});
+
+function mockBuilder(result: { data: unknown; error: unknown }) {
+ const builder: Record> & {
+ then?: (resolve: (v: unknown) => void) => void;
+ } = {} as never;
+ for (const m of ["select", "eq", "like", "limit"]) builder[m] = vi.fn().mockReturnValue(builder);
+ builder.then = resolve => resolve(result);
+ vi.mocked(supabase.from).mockReturnValue(builder as never);
+ return builder;
+}
+
+describe("selectEmailSendLog", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("applies the status, raw_body-substring, and limit filters", async () => {
+ const rows = [{ id: "log_1", status: "sent" }];
+ const builder = mockBuilder({ data: rows, error: null });
+
+ const result = await selectEmailSendLog({
+ status: "sent",
+ rawBodyLike: '"snapshot_id":"snap_1"',
+ limit: 1,
+ });
+
+ expect(supabase.from).toHaveBeenCalledWith("email_send_log");
+ expect(builder.eq).toHaveBeenCalledWith("status", "sent");
+ expect(builder.like).toHaveBeenCalledWith("raw_body", '%"snapshot_id":"snap_1"%');
+ expect(builder.limit).toHaveBeenCalledWith(1);
+ expect(result).toEqual(rows);
+ });
+
+ it("skips filters that aren't provided", async () => {
+ const builder = mockBuilder({ data: [], error: null });
+
+ await selectEmailSendLog();
+
+ expect(builder.eq).not.toHaveBeenCalled();
+ expect(builder.like).not.toHaveBeenCalled();
+ expect(builder.limit).not.toHaveBeenCalled();
+ });
+
+ it("returns an empty array when nothing matches", async () => {
+ mockBuilder({ data: [], error: null });
+ expect(await selectEmailSendLog({ status: "sent" })).toEqual([]);
+ });
+
+ it("returns an empty array on error", async () => {
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ mockBuilder({ data: null, error: { message: "boom" } });
+ expect(await selectEmailSendLog({ status: "sent" })).toEqual([]);
+ consoleError.mockRestore();
+ });
+});
diff --git a/lib/supabase/email_send_log/selectEmailSendLog.ts b/lib/supabase/email_send_log/selectEmailSendLog.ts
new file mode 100644
index 00000000..0c458ada
--- /dev/null
+++ b/lib/supabase/email_send_log/selectEmailSendLog.ts
@@ -0,0 +1,37 @@
+import supabase from "../serverClient";
+import type { Tables } from "@/types/database.types";
+
+/** Optional filters for a generic `email_send_log` read. */
+export interface SelectEmailSendLogFilters {
+ /** Exact match on `status` (e.g. "sent"). */
+ status?: string;
+ /** Substring matched within `raw_body` (LIKE `%value%`) — pass the marker the send was keyed on. */
+ rawBodyLike?: string;
+ /** Cap the number of rows returned. */
+ limit?: number;
+}
+
+/**
+ * Generic read of `email_send_log` with optional filters — reused for any
+ * email-type idempotency/dedup check (pass the `raw_body` marker the send was
+ * keyed on). KISS: one query builder instead of a per-email-type select.
+ *
+ * @param filters - Optional status / raw_body-substring / limit filters
+ * @returns Matching rows (empty array on error or no match)
+ */
+export async function selectEmailSendLog(
+ filters: SelectEmailSendLogFilters = {},
+): Promise[]> {
+ let query = supabase.from("email_send_log").select("*");
+
+ if (filters.status) query = query.eq("status", filters.status);
+ if (filters.rawBodyLike) query = query.like("raw_body", `%${filters.rawBodyLike}%`);
+ if (filters.limit) query = query.limit(filters.limit);
+
+ const { data, error } = await query;
+ if (error) {
+ console.error("Error fetching email_send_log:", error);
+ return [];
+ }
+ return data ?? [];
+}
diff --git a/lib/valuation/runValuationHandler.ts b/lib/valuation/runValuationHandler.ts
index 1e74d7d3..792f16dd 100644
--- a/lib/valuation/runValuationHandler.ts
+++ b/lib/valuation/runValuationHandler.ts
@@ -1,4 +1,4 @@
-import { NextRequest, NextResponse } from "next/server";
+import { NextRequest, NextResponse, after } from "next/server";
import { errorResponse } from "@/lib/networking/errorResponse";
import { successResponse } from "@/lib/networking/successResponse";
import generateAccessToken from "@/lib/spotify/generateAccessToken";
@@ -10,6 +10,7 @@ import { createSnapshotCatalog } from "@/lib/catalog/createSnapshotCatalog";
import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate";
import { getCatalogEarliestReleaseDate } from "@/lib/catalog/getCatalogEarliestReleaseDate";
import { computeValuationBand } from "@/lib/catalog/computeValuationBand";
+import { sendValuationReportEmail } from "@/lib/emails/valuationReport/sendValuationReportEmail";
import { validateRunValuationRequest } from "./validateRunValuationRequest";
import { extractValuationAlbums } from "./extractValuationAlbums";
import { waitForSnapshotMeasurements } from "./waitForSnapshotMeasurements";
@@ -140,6 +141,17 @@ export async function runValuationHandler(request: NextRequest): Promise
+ sendValuationReportEmail(
+ { ...snapshot, catalog: catalog.id },
+ { artist: searchedArtist },
+ ).catch(error => console.error("Valuation report email failed:", error)),
+ );
+
return successResponse({
catalog,
band: valuation,