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.

-
Recoup
+ 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)} -
Confirm your roster →
-${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 + ? ` +
+ + ${cta.label} + +
`.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}

-
Recoup
+ const bodyHtml = `

Catalog valuation

+

${name}

${renderArtistHeader(params.artist)} ${renderValuationBlock(params.valuation)} ${renderStatRow(params)} ${renderReleasesTable(params.releases)} -${disclaimer} -
Get the full report with Recoup →
-

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 }; }