diff --git a/lib/emails/__tests__/buildWelcomeEmail.test.ts b/lib/emails/__tests__/buildWelcomeEmail.test.ts
index ef4cdc9f..f077a6bf 100644
--- a/lib/emails/__tests__/buildWelcomeEmail.test.ts
+++ b/lib/emails/__tests__/buildWelcomeEmail.test.ts
@@ -75,6 +75,16 @@ describe("buildWelcomeEmail", () => {
}
});
+ it("renders inside the shared house layout (consistency pass)", () => {
+ const { html } = buildWelcomeEmail();
+
+ // Shared wrapper markers: the house footer tagline + shadow-as-border card.
+ expect(html).toContain("the AI agent platform for the music industry");
+ expect(html).toContain("box-shadow: 0px 0px 0px 1px #e8e8e8");
+ // The email no longer ships its own outer page/card chrome.
+ expect(html).not.toContain("background:#f7f7f7;padding:24px 0");
+ });
+
it("uses only durable image hosts (no expiring social CDNs)", () => {
const { html } = buildWelcomeEmail();
diff --git a/lib/emails/__tests__/processAndSendEmail.test.ts b/lib/emails/__tests__/processAndSendEmail.test.ts
index f4940b13..774998a3 100644
--- a/lib/emails/__tests__/processAndSendEmail.test.ts
+++ b/lib/emails/__tests__/processAndSendEmail.test.ts
@@ -94,6 +94,24 @@ describe("processAndSendEmail", () => {
);
});
+ it("wraps the email in the shared house-style layout (weekly-report consistency pass)", async () => {
+ mockSendEmailWithResend.mockResolvedValue({ id: "email-layout" });
+
+ await processAndSendEmail({
+ to: ["user@example.com"],
+ subject: "Weekly report",
+ html: "
This week
",
+ });
+
+ const sent = mockSendEmailWithResend.mock.calls[0][0] as { html: string };
+ // Body preserved…
+ expect(sent.html).toContain("This week
");
+ // …inside the shared layout: Recoup wordmark header + shadow-as-border card.
+ expect(sent.html).toContain("Recoup");
+ expect(sent.html).toContain("box-shadow");
+ expect(sent.html).toContain("Plus Jakarta Sans");
+ });
+
it("returns error when Resend fails", async () => {
const errorResponse = NextResponse.json(
{ error: { message: "Rate limited" } },
diff --git a/lib/emails/__tests__/renderEmailLayout.test.ts b/lib/emails/__tests__/renderEmailLayout.test.ts
new file mode 100644
index 00000000..9ae510b7
--- /dev/null
+++ b/lib/emails/__tests__/renderEmailLayout.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect } from "vitest";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
+
+describe("renderEmailLayout", () => {
+ it("wraps the body HTML inside the layout", () => {
+ const html = renderEmailLayout({ bodyHtml: "Hello world
" });
+ expect(html).toContain("Hello world
");
+ });
+
+ it("includes the footer HTML when provided", () => {
+ const html = renderEmailLayout({
+ bodyHtml: "Body
",
+ footerHtml: "Footer bits
",
+ });
+ expect(html).toContain("Footer bits
");
+ });
+
+ it("omits the footer region when no footer is provided", () => {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+ expect(html).not.toContain("Footer bits");
+ });
+
+ it("renders a CTA button with the given label and url when provided", () => {
+ const html = renderEmailLayout({
+ bodyHtml: "Body
",
+ cta: { label: "Open Recoup", url: "https://chat.recoupable.dev" },
+ });
+ expect(html).toContain("Open Recoup");
+ expect(html).toContain('href="https://chat.recoupable.dev"');
+ });
+
+ it("does not render a CTA when none is provided", () => {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+ expect(html).not.toContain(" {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+ // Header wordmark.
+ expect(html).toContain("Recoup");
+ // Achromatic near-black brand ink (DESIGN.md --foreground).
+ expect(html).toContain("#0a0a0a");
+ // Shadow-as-border card outline (DESIGN.md), not a CSS `border` on the card.
+ expect(html).toContain("box-shadow");
+ // Brand font stack (Plus Jakarta Sans for UI, per DESIGN.md).
+ expect(html).toContain("Plus Jakarta Sans");
+ // Constrained, centered container.
+ expect(html).toContain("max-width");
+ });
+
+ it("never breaks a style attribute with quotes in the font stack", () => {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+
+ // Regression: double-quoted font names inside a double-quoted style="…"
+ // attribute terminate it early, silently dropping the font (clients fall
+ // back to serif) and every declaration after it. Font names must be
+ // single-quoted so the attribute stays intact.
+ expect(html).not.toContain('"Plus Jakarta Sans"');
+ expect(html).toContain("'Plus Jakarta Sans'");
+ // No style attribute may contain a stray double quote before its close.
+ for (const style of html.match(/style="[^"]*"/g) ?? []) {
+ expect(style).not.toContain('font-family:"');
+ }
+ });
+
+ it("returns a single HTML string", () => {
+ expect(typeof renderEmailLayout({ bodyHtml: "x
" })).toBe("string");
+ });
+});
diff --git a/lib/emails/buildWelcomeEmail.ts b/lib/emails/buildWelcomeEmail.ts
index 642295ca..476c3a33 100644
--- a/lib/emails/buildWelcomeEmail.ts
+++ b/lib/emails/buildWelcomeEmail.ts
@@ -1,9 +1,8 @@
-import { CHAT_APP_URL, RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const";
+import { CHAT_APP_URL } from "@/lib/const";
import { getEmailFooter } from "@/lib/emails/getEmailFooter";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
import { renderWelcomeSteps } from "@/lib/emails/welcome/renderWelcomeSteps";
-const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
-
/**
* Builds the welcome email sent to every new account. Instead of a generic
* greeting, it walks the signup through Recoup's onboarding flow in five steps
@@ -11,9 +10,10 @@ const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
* catalog, see baseline valuation, automate with tasks), illustrated with art
* from the house cast of artists (PFPs + album covers, stable Spotify CDN URLs).
*
- * House style follows DESIGN.md / the valuation email: achromatic chrome
- * (#0a0a0a on #ffffff, #e8e8e8 borders, #6b6b6b muted), tables + inline styles
- * only, system font stack. Copy avoids em/en dashes.
+ * Chrome comes from the shared `renderEmailLayout` wrapper (consistency pass,
+ * chat#1885) — the same header / card / CTA / footer the valuation and
+ * weekly-report emails use — so this builder only owns its body content, CTA
+ * target, and footer. Copy avoids em/en dashes.
*
* Deep links use the fixed `CHAT_APP_URL` (never a derived deployment URL, same
* as the valuation email) so `/setup/*` always resolves to the real chat app,
@@ -24,22 +24,16 @@ const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
export function buildWelcomeEmail(): { subject: string; html: string } {
const footer = getEmailFooter();
- const html = `
-
-
-
-|
- Welcome to Recoup
-You're in. Let's build your catalog value.
- |
- |
-
+ const bodyHtml = `Welcome to Recoup
+You're in. Let's build your catalog value.
Recoup is your AI team for the music business. Here is the five step path from a new account to a catalog you measure, grow, and automate.
-${renderWelcomeSteps(CHAT_APP_URL)}
-
-${footer}
- |
- |
`;
+${renderWelcomeSteps(CHAT_APP_URL)}`;
+
+ const html = renderEmailLayout({
+ bodyHtml,
+ cta: { label: "Confirm your roster →", url: `${CHAT_APP_URL}/setup` },
+ footerHtml: footer,
+ });
return { subject: "Welcome to Recoup", html };
}
diff --git a/lib/emails/processAndSendEmail.ts b/lib/emails/processAndSendEmail.ts
index 16402cd5..f3e4a445 100644
--- a/lib/emails/processAndSendEmail.ts
+++ b/lib/emails/processAndSendEmail.ts
@@ -1,5 +1,6 @@
import { sendEmailWithResend } from "@/lib/emails/sendEmail";
import { getEmailFooter } from "@/lib/emails/getEmailFooter";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
import { selectRoomWithArtist } from "@/lib/supabase/rooms/selectRoomWithArtist";
import { RECOUP_FROM_EMAIL } from "@/lib/const";
import { NextResponse } from "next/server";
@@ -43,14 +44,19 @@ export async function processAndSendEmail(
const roomData = room_id ? await selectRoomWithArtist(room_id) : null;
const footer = getEmailFooter(room_id, roomData?.artist_name || undefined);
const bodyHtml = html || (text ? await marked(text) : "");
- const htmlWithFooter = `${bodyHtml}\n\n${footer}`;
+ // Wrap in the shared house-style layout so every outbound email — including
+ // the live weekly-report send that flows through here — shares one visual
+ // language with the welcome/valuation emails (recoupable/chat#1885
+ // consistency pass): Recoup wordmark header, achromatic shadow-as-border
+ // card, DESIGN.md font stack, and the existing footer as the layout footer.
+ const htmlWithLayout = renderEmailLayout({ bodyHtml, footerHtml: footer });
const result = await sendEmailWithResend({
from: RECOUP_FROM_EMAIL,
to,
cc: cc.length > 0 ? cc : undefined,
subject,
- html: htmlWithFooter,
+ html: htmlWithLayout,
headers,
});
diff --git a/lib/emails/renderEmailLayout.ts b/lib/emails/renderEmailLayout.ts
new file mode 100644
index 00000000..4d911a12
--- /dev/null
+++ b/lib/emails/renderEmailLayout.ts
@@ -0,0 +1,88 @@
+export type EmailLayoutCta = {
+ /** Button label. */
+ label: string;
+ /** Absolute URL the button links to. */
+ url: string;
+};
+
+export type RenderEmailLayoutParams = {
+ /** The email's main content, already rendered to HTML. */
+ bodyHtml: string;
+ /** Optional footer HTML (reply note, chat link, artist attribution). */
+ footerHtml?: string;
+ /** Optional primary call-to-action rendered as a button below the body. */
+ cta?: EmailLayoutCta;
+};
+
+// Brand tokens (DESIGN.md §3) — kept literal because email clients can't read
+// CSS custom properties. Achromatic chrome; color comes from content.
+const INK = "#0a0a0a"; // --foreground
+const MUTED_INK = "#6b6b6b"; // --muted-foreground
+const PAGE_BG = "#f7f7f7"; // --muted (page canvas)
+const CARD_BG = "#ffffff"; // --card
+const BORDER = "#e8e8e8"; // --border
+const CTA_BG = "#0a0a0a"; // --primary
+const CTA_FG = "#ffffff"; // --primary-foreground
+
+// UI font stack — Plus Jakarta Sans (DESIGN.md four-font system) with system
+// fallbacks for clients that can't load a webfont.
+//
+// Font names are SINGLE-quoted on purpose: this stack is interpolated into
+// double-quoted `style="…"` attributes, so double quotes here would terminate
+// the attribute early — silently dropping the font (clients fall back to
+// serif) and every declaration after it.
+const FONT_STACK =
+ "'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
+
+// Shadow-as-border (DESIGN.md) — a hairline outline + subtle elevation via
+// box-shadow instead of a CSS `border` on the card.
+const CARD_SHADOW = `box-shadow: 0px 0px 0px 1px ${BORDER}, 0px 2px 4px rgba(0,0,0,0.04);`;
+
+/**
+ * Shared house-style wrapper for all onboarding emails (welcome, valuation,
+ * weekly report — recoupable/chat#1885 consistency pass). Wraps a rendered
+ * body in one header + footer + CTA structure so every automated email reads
+ * as one family: achromatic chrome, shadow-as-border card, the Recoup wordmark
+ * header, and the DESIGN.md font stack.
+ *
+ * Email-client-safe: all styles inline, a centered fixed-max-width container,
+ * literal hex tokens (no CSS variables), and webfonts degrade to system fonts.
+ */
+export function renderEmailLayout({ bodyHtml, footerHtml, cta }: RenderEmailLayoutParams): string {
+ const header = `
+
+ Recoup
+
`.trim();
+
+ const ctaBlock = cta
+ ? `
+`.trim()
+ : "";
+
+ const footerBlock = footerHtml
+ ? `
+
+ ${footerHtml}
+
`.trim()
+ : "";
+
+ return `
+
+
+ ${header}
+
+ ${bodyHtml}
+
+ ${ctaBlock}
+ ${footerBlock}
+
+
+ Recoup, the AI agent platform for the music industry
+
+
`.trim();
+}
diff --git a/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts
index bcde9497..38fa59c9 100644
--- a/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts
+++ b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts
@@ -106,4 +106,14 @@ describe("renderValuationReportHtml", () => {
expect(subject).not.toMatch(/[–—]/);
expect(html).not.toMatch(/[–—]/);
});
+
+ it("renders inside the shared house layout (consistency pass)", () => {
+ const { html } = renderValuationReportHtml(baseParams);
+
+ // Shared wrapper markers: the house footer tagline + shadow-as-border card.
+ expect(html).toContain("the AI agent platform for the music industry");
+ expect(html).toContain("box-shadow: 0px 0px 0px 1px #e8e8e8");
+ // The email no longer ships its own outer page/card chrome.
+ expect(html).not.toContain("background:#f7f7f7;padding:24px 0");
+ });
});
diff --git a/lib/emails/valuationReport/renderValuationReportHtml.ts b/lib/emails/valuationReport/renderValuationReportHtml.ts
index 3325f7e0..60cd5188 100644
--- a/lib/emails/valuationReport/renderValuationReportHtml.ts
+++ b/lib/emails/valuationReport/renderValuationReportHtml.ts
@@ -1,5 +1,5 @@
-import { RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const";
import { escapeHtml } from "@/lib/emails/escapeHtml";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
import { renderArtistHeader } from "@/lib/emails/valuationReport/renderArtistHeader";
import { renderValuationBlock } from "@/lib/emails/valuationReport/renderValuationBlock";
import { renderStatRow } from "@/lib/emails/valuationReport/renderStatRow";
@@ -7,17 +7,19 @@ import { renderReleasesTable } from "@/lib/emails/valuationReport/renderReleases
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
+ * a per-release table with album art + proportional-share value.
+ *
+ * Chrome comes from the shared `renderEmailLayout` wrapper (consistency pass,
+ * chat#1885) — the same header / card / CTA / footer every automated email
+ * uses — so this renderer only owns its body content, CTA target, and footer
+ * note. Deep link is the fixed CHAT_APP_URL (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.
@@ -32,25 +34,22 @@ export function renderValuationReportHtml(params: ValuationReportEmailParams): {
? `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}
- |
- |
-
+ const bodyHtml = `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.
- |
- |
`;
+${disclaimer}`;
+
+ const html = renderEmailLayout({
+ bodyHtml,
+ cta: {
+ label: "Get the full report with Recoup →",
+ url: params.deepLinkUrl,
+ },
+ footerHtml: `You're receiving this because you ran a catalog valuation on Recoup.
`,
+ });
return { subject: SUBJECT, html };
}