Skip to content
Open
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
17 changes: 14 additions & 3 deletions lib/emails/__tests__/buildWelcomeEmail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,16 @@ describe("buildWelcomeEmail", () => {
expect(html).not.toMatch(/[–—]/);
});

it("walks the five onboarding steps in order", () => {
it("walks the app's four derived checkpoints in order", () => {
const { html } = buildWelcomeEmail();

// Four, not five: the app derives exactly these checkpoints, and the
// baseline valuation is the payoff rather than a numbered step (chat#1889).
const order = [
"1. Confirm your artists",
"2. Verify their socials",
"3. Claim your catalog",
"4. See your baseline valuation",
"5. Automate with tasks",
"4. Automate with tasks",
];
let cursor = -1;
for (const label of order) {
Expand All @@ -75,6 +76,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");
});
});
48 changes: 23 additions & 25 deletions lib/emails/buildWelcomeEmail.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
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 { renderValuationPayoff } from "@/lib/emails/welcome/renderValuationPayoff";
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).
* greeting, it walks the signup through Recoup's onboarding flow in four steps
* (mirroring chat's four derived checkpoints: confirm artists, verify socials,
* claim catalog, automate with tasks), illustrated with art from the house cast
* of artists (PFPs + album covers, stable Spotify CDN URLs), then presents the
* baseline valuation as the payoff those steps unlock (chat#1889) rather than as
* a fifth chore the product can't count.
*
* 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 +27,17 @@ 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>
<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>
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 four 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>`;
${renderValuationPayoff(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

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: Custom agent: Flag AI Slop and Fabricated Changes

The comments cite 'DESIGN.md §3' and 'DESIGN.md four-font system' as the source for brand tokens and the font stack, but a full-repo search confirms no DESIGN.md exists. Remove the fabricated document references from the comments so they describe the actual intent locally without pointing to a non-existent file.

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 19:

<comment>The comments cite 'DESIGN.md §3' and 'DESIGN.md four-font system' as the source for brand tokens and the font stack, but a full-repo search confirms no DESIGN.md exists. Remove the fabricated document references from the comments so they describe the actual intent locally without pointing to a non-existent file.</comment>

<file context>
@@ -0,0 +1,88 @@
+
+// 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)
</file context>

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

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 labels containing markup are emitted as HTML, so any non-static caller can alter the email content. Treat EmailLayoutCta.label as text and HTML-escape it before interpolation; attribute-escape cta.url as well.

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>CTA labels containing markup are emitted as HTML, so any non-static caller can alter the email content. Treat `EmailLayoutCta.label` as text and HTML-escape it before interpolation; attribute-escape `cta.url` as well.</comment>

<file context>
@@ -0,0 +1,88 @@
+<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}
+  </a>
+</div>`.trim()
</file context>

Comment on lines +60 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Escape CTA URLs and labels at the layout boundary.

cta.url and cta.label are interpolated as raw HTML. A quote-bearing or data-driven CTA can inject attributes/markup; escape both fields before rendering and restrict CTA URLs to approved absolute HTTP(S) URLs. Change existing &rarr; labels to a literal when escaping text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/emails/renderEmailLayout.ts` around lines 60 - 62, Update the CTA
rendering in renderEmailLayout to HTML-escape both cta.url and cta.label before
interpolation, and validate URLs as approved absolute HTTP(S) URLs before
placing them in the href attribute. Replace any existing &rarr; CTA label values
with the literal → character so escaped text preserves the intended label.

</a>
</div>`.trim()
: "";

const footerBlock = footerHtml
? `
<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");
});
});
Loading
Loading