Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/emails/__tests__/buildWelcomeEmail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
18 changes: 18 additions & 0 deletions lib/emails/__tests__/processAndSendEmail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<h1>This week</h1>",
});

const sent = mockSendEmailWithResend.mock.calls[0][0] as { html: string };
// Body preserved…
expect(sent.html).toContain("<h1>This week</h1>");
// …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" } },
Expand Down
69 changes: 69 additions & 0 deletions lib/emails/__tests__/renderEmailLayout.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<p>Hello world</p>" });
expect(html).toContain("<p>Hello world</p>");
});

it("includes the footer HTML when provided", () => {
const html = renderEmailLayout({
bodyHtml: "<p>Body</p>",
footerHtml: "<div>Footer bits</div>",
});
expect(html).toContain("<div>Footer bits</div>");
});

it("omits the footer region when no footer is provided", () => {
const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" });
expect(html).not.toContain("Footer bits");
});

it("renders a CTA button with the given label and url when provided", () => {
const html = renderEmailLayout({
bodyHtml: "<p>Body</p>",
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: "<p>Body</p>" });
expect(html).not.toContain("<a ");
});

it("carries the Recoup house style — wordmark, font stack, and shadow-as-border card", () => {
const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" });
// 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: "<p>Body</p>" });

// 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: "<p>x</p>" })).toBe("string");
});
});
36 changes: 15 additions & 21 deletions lib/emails/buildWelcomeEmail.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
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
* (mirroring chat's onboarding sequence: confirm artists, verify socials, claim
* 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,
Expand All @@ -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 = `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f7f7f7;padding:24px 0"><tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border:1px solid #e8e8e8;border-radius:16px">
<tr><td style="padding:32px 32px 28px;font-family:${FONT}">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px"><tr>
<td valign="top">
<p style="margin:0 0 6px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b6b6b">Welcome to Recoup</p>
<h1 style="margin:0;font-size:24px;line-height:1.2;letter-spacing:-0.02em;color:#0a0a0a">You're in. Let's build your catalog value.</h1>
</td>
<td valign="top" align="right" width="44"><a href="${WEBSITE_URL}"><img src="${RECOUP_LOGO_URL}" width="36" height="36" alt="Recoup" style="display:block;width:36px;height:36px;border-radius:8px"/></a></td>
</tr></table>
const bodyHtml = `<p style="margin:0 0 6px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b6b6b">Welcome to Recoup</p>
<h1 style="margin:0 0 20px;font-size:24px;line-height:1.2;letter-spacing:-0.02em;color:#0a0a0a">You're in. Let's build your catalog value.</h1>
<p style="margin:0 0 24px;font-size:14px;line-height:1.6;color:#0a0a0a">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.</p>
${renderWelcomeSteps(CHAT_APP_URL)}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:16px 0 8px"><tr><td style="background:#0a0a0a;border-radius:8px"><a href="${CHAT_APP_URL}/setup" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:12px 22px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none">Confirm your roster &rarr;</a></td></tr></table>
${footer}
</td></tr></table>
</td></tr></table>`;
${renderWelcomeSteps(CHAT_APP_URL)}`;

const html = renderEmailLayout({
bodyHtml,
cta: { label: "Confirm your roster &rarr;", url: `${CHAT_APP_URL}/setup` },
footerHtml: footer,
});

return { subject: "Welcome to Recoup", html };
}
10 changes: 8 additions & 2 deletions lib/emails/processAndSendEmail.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
});

Expand Down
88 changes: 88 additions & 0 deletions lib/emails/renderEmailLayout.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div style="padding:0 0 20px;">
<span style="font-family:${FONT_STACK};font-weight:700;font-size:18px;letter-spacing:-0.02em;color:${INK};">Recoup</span>
</div>`.trim();

const ctaBlock = cta
? `
<div style="padding:24px 0 4px;">
<a href="${cta.url}" target="_blank" rel="noopener noreferrer"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: CTA label and URL are not HTML-escaped before interpolation into the email template. If these values ever come from user input, scraper data, or a database field, an attacker could inject arbitrary markup or break the href attribute. The codebase already has escapeHtml in lib/emails/escapeHtml.ts — use it here to guard cta.label and cta.url.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/renderEmailLayout.ts, line 55:

<comment>CTA label and URL are not HTML-escaped before interpolation into the email template. If these values ever come from user input, scraper data, or a database field, an attacker could inject arbitrary markup or break the href attribute. The codebase already has escapeHtml in lib/emails/escapeHtml.ts — use it here to guard cta.label and cta.url.</comment>

<file context>
@@ -0,0 +1,83 @@
+  const ctaBlock = cta
+    ? `
+<div style="padding:24px 0 4px;">
+  <a href="${cta.url}" target="_blank" rel="noopener noreferrer"
+     style="display:inline-block;background:${CTA_BG};color:${CTA_FG};font-family:${FONT_STACK};font-weight:600;font-size:14px;text-decoration:none;padding:12px 20px;border-radius:8px;">
+    ${cta.label}
</file context>

style="display:inline-block;background:${CTA_BG};color:${CTA_FG};font-family:${FONT_STACK};font-weight:600;font-size:14px;text-decoration:none;padding:12px 20px;border-radius:8px;">
${cta.label}
</a>
</div>`.trim()
: "";

const footerBlock = footerHtml

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The layout's footer wrapper adds its own padding-top:24px;margin-top:24px around the footer HTML, but getEmailFooter() already ships an <hr> with margin-top:24px. This doubles the visual gap above the footer in the final email. Consider removing padding-top and margin-top from the footer wrapper div in renderEmailLayout and letting the footer's own <hr> margin control the spacing, or removing the <hr>'s top margin if the wrapper is the sole spacer.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/renderEmailLayout.ts, line 62:

<comment>The layout's footer wrapper adds its own `padding-top:24px;margin-top:24px` around the footer HTML, but `getEmailFooter()` already ships an `<hr>` with `margin-top:24px`. This doubles the visual gap above the footer in the final email. Consider removing `padding-top` and `margin-top` from the footer wrapper div in `renderEmailLayout` and letting the footer's own `<hr>` margin control the spacing, or removing the `<hr>`'s top margin if the wrapper is the sole spacer.</comment>

<file context>
@@ -0,0 +1,83 @@
+</div>`.trim()
+    : "";
+
+  const footerBlock = footerHtml
+    ? `
+<div style="padding-top:24px;margin-top:24px;">
</file context>

? `
<div style="padding-top:24px;margin-top:24px;">
${footerHtml}
</div>`.trim()
: "";

return `
<div style="background:${PAGE_BG};padding:32px 16px;font-family:${FONT_STACK};color:${INK};">
<div style="max-width:560px;margin:0 auto;background:${CARD_BG};border-radius:12px;${CARD_SHADOW}padding:32px;">
${header}
<div style="font-family:${FONT_STACK};font-size:15px;line-height:1.6;color:${INK};">
${bodyHtml}
</div>
${ctaBlock}
${footerBlock}
</div>
<div style="max-width:560px;margin:16px auto 0;text-align:center;font-family:${FONT_STACK};font-size:11px;color:${MUTED_INK};">
Recoup, the AI agent platform for the music industry
</div>
</div>`.trim();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
41 changes: 20 additions & 21 deletions lib/emails/valuationReport/renderValuationReportHtml.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
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";
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
* 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.
Expand All @@ -32,25 +34,22 @@ export function renderValuationReportHtml(params: ValuationReportEmailParams): {
? `<p style="margin:0 0 24px;font-size:12px;line-height:1.5;color:#6b6b6b">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.</p>`
: "";

const html = `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f7f7f7;padding:24px 0"><tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border:1px solid #e8e8e8;border-radius:16px">
<tr><td style="padding:32px 32px 24px;font-family:${FONT}">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px"><tr>
<td valign="top">
<p style="margin:0 0 6px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b6b6b">Catalog valuation</p>
<h1 style="margin:0;font-size:24px;line-height:1.2;letter-spacing:-0.02em;color:#0a0a0a">${name}</h1>
</td>
<td valign="top" align="right" width="44"><a href="${WEBSITE_URL}"><img src="${RECOUP_LOGO_URL}" width="36" height="36" alt="Recoup" style="display:block;width:36px;height:36px;border-radius:8px"/></a></td>
</tr></table>
const bodyHtml = `<p style="margin:0 0 6px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b6b6b">Catalog valuation</p>
<h1 style="margin:0 0 20px;font-size:24px;line-height:1.2;letter-spacing:-0.02em;color:#0a0a0a">${name}</h1>
${renderArtistHeader(params.artist)}
${renderValuationBlock(params.valuation)}
${renderStatRow(params)}
${renderReleasesTable(params.releases)}
${disclaimer}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 8px"><tr><td style="background:#0a0a0a;border-radius:8px"><a href="${params.deepLinkUrl}" style="display:inline-block;padding:12px 22px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none">Get the full report with Recoup &rarr;</a></td></tr></table>
<p style="margin:20px 0 0;font-size:12px;color:#6b6b6b">You're receiving this because you ran a catalog valuation on Recoup.</p>
</td></tr></table>
</td></tr></table>`;
${disclaimer}`;

const html = renderEmailLayout({
bodyHtml,
cta: {
label: "Get the full report with Recoup &rarr;",
url: params.deepLinkUrl,
},
footerHtml: `<p style="margin:0;font-size:12px;color:#6b6b6b">You're receiving this because you ran a catalog valuation on Recoup.</p>`,
});

return { subject: SUBJECT, html };
}
Loading