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
19 changes: 19 additions & 0 deletions app/api/internal/cold-start-nudge/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { coldStartNudgeHandler } from "@/lib/onboarding/coldStartNudgeHandler";

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;

/**
* GET /api/internal/cold-start-nudge — daily Vercel Cron entrypoint that nudges
* accounts welcomed 1 to 14 days ago that still have no artist on the roster.
* Cron-only (CRON_SECRET bearer); deduped per account via the
* `cold_start_nudge_email` marker in `email_send_log`.
*
* @param request - The incoming Next.js request.
* @returns A NextResponse describing how many accounts were nudged.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
return coldStartNudgeHandler(request);
}
2 changes: 2 additions & 0 deletions lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export const RECOUP_FROM_EMAIL = `Agent by Recoup <agent${OUTBOUND_EMAIL_DOMAIN}

/** Marker stored in email_send_log.raw_body to identify welcome-email sends. */
export const WELCOME_EMAIL_LOG_TYPE = "welcome_email";
export const SCHEDULE_CONFIRMATION_EMAIL_LOG_TYPE = "schedule_confirmation_email";
export const COLD_START_NUDGE_EMAIL_LOG_TYPE = "cold_start_nudge_email";

/**
* Generic message returned for every POST /api/agents/signup response,
Expand Down
48 changes: 48 additions & 0 deletions lib/emails/__tests__/buildScheduleConfirmationEmail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { CHAT_APP_URL } from "@/lib/const";

const mockGetEmailFooter = vi.fn();
vi.mock("@/lib/emails/getEmailFooter", () => ({
getEmailFooter: (...args: unknown[]) => mockGetEmailFooter(...args),
}));

const { buildScheduleConfirmationEmail } = await import("../buildScheduleConfirmationEmail");

const params = {
title: "Weekly valuation + streams report",
cadence: "Mondays at 13:00 UTC",
};

describe("buildScheduleConfirmationEmail", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetEmailFooter.mockReturnValue("<footer>reply note</footer>");
});

it("names the report and when it runs, so the signup knows what to expect", () => {
const { subject, html } = buildScheduleConfirmationEmail(params);

expect(subject).toContain("Weekly valuation + streams report");
expect(html).toContain("Mondays at 13:00 UTC");
});

it("points the CTA at the account's tasks", () => {
const { html } = buildScheduleConfirmationEmail(params);

expect(html).toContain(`href="${CHAT_APP_URL}/tasks"`);
});

it("renders through the shared layout, so it reads as one family with the others", () => {
const { html } = buildScheduleConfirmationEmail(params);

expect(html).toContain("<footer>reply note</footer>");
expect(html).toContain("Recoup");
});

it("contains no em or en dashes in outward-facing copy", () => {
const { subject, html } = buildScheduleConfirmationEmail(params);

expect(subject).not.toMatch(/[–—]/);
expect(html).not.toMatch(/[–—]/);
});
});
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
25 changes: 25 additions & 0 deletions lib/emails/__tests__/describeCronCadence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { describeCronCadence } from "../describeCronCadence";

describe("describeCronCadence", () => {
it("names a weekly schedule by weekday and time", () => {
expect(describeCronCadence("0 13 * * 1")).toBe("Mondays at 13:00 UTC");
expect(describeCronCadence("30 9 * * 0")).toBe("Sundays at 09:30 UTC");
});

it("honours an explicit time zone instead of claiming UTC", () => {
expect(describeCronCadence("0 13 * * 1", "America/New_York")).toBe(
"Mondays at 13:00 America/New_York",
);
});

it("names a daily schedule", () => {
expect(describeCronCadence("0 8 * * *")).toBe("every day at 08:00 UTC");
});

it("falls back to the raw expression rather than inventing a cadence", () => {
// Better an honest cron string than a confident wrong sentence in an email.
expect(describeCronCadence("*/5 * * * *")).toBe("the schedule */5 * * * *");
expect(describeCronCadence("not a cron")).toBe("the schedule not a cron");
});
});
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");
});
});
32 changes: 32 additions & 0 deletions lib/emails/buildColdStartNudgeEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { CHAT_APP_URL } from "@/lib/const";
import { getEmailFooter } from "@/lib/emails/getEmailFooter";
import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";

/**
* Nudge for an account that was welcomed but never added an artist (chat#1889).
*
* The welcome email fires on account creation regardless of whether a valuation
* preceded it, so a cold-start signup was told to "confirm your artists" and
* "see your baseline valuation" for records that do not exist. This email asks
* for the one thing that unblocks everything else: pick the artist.
*
* Chrome comes from the shared `renderEmailLayout` (api#784). Copy avoids em/en
* dashes.
*/
export function buildColdStartNudgeEmail(): { subject: string; html: string } {
const bodyHtml = `<p style="margin:0 0 6px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b6b6b">One step left</p>
<h1 style="margin:0 0 20px;font-size:24px;line-height:1.2;letter-spacing:-0.02em;color:#0a0a0a">Add an artist and we will value their catalog.</h1>
<p style="margin:0 0 12px;font-size:14px;line-height:1.6;color:#0a0a0a">Your Recoup account is ready, but there is no artist on it yet, so there is nothing for us to measure. Search for the artist you manage and we will pull their catalog and estimate what it is worth.</p>
<p style="margin:0;font-size:14px;line-height:1.6;color:#6b6b6b">It takes one search. Everything else, the catalog, the valuation, and the weekly report, follows from it.</p>`;

const html = renderEmailLayout({
bodyHtml,
cta: {
label: "Add your artist &rarr;",
url: `${CHAT_APP_URL}/setup/artists`,
},
footerHtml: getEmailFooter(),
});

return { subject: "Add an artist to see your catalog value", html };
}
44 changes: 44 additions & 0 deletions lib/emails/buildScheduleConfirmationEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { CHAT_APP_URL } from "@/lib/const";
import { escapeHtml } from "@/lib/emails/escapeHtml";
import { getEmailFooter } from "@/lib/emails/getEmailFooter";
import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";

export interface ScheduleConfirmationEmailParams {
/** The scheduled task's title. */
title: string;
/** Human-readable cadence, e.g. "Mondays at 13:00 UTC". */
cadence: string;
}

/**
* Confirms a newly scheduled report: what it is, and when it will arrive
* (chat#1889).
*
* This is the bridge between signing up and the first report landing. Without
* it, a signup finishes onboarding and then hears nothing until a report shows
* up days later, with no record that anything was actually scheduled.
*
* Chrome comes from the shared `renderEmailLayout` (api#784), so it reads as one
* family with the welcome, valuation, and weekly-report emails. Copy avoids
* em/en dashes.
*/
export function buildScheduleConfirmationEmail({
title,
cadence,
}: ScheduleConfirmationEmailParams): { subject: string; html: string } {
const safeTitle = escapeHtml(title);
const safeCadence = escapeHtml(cadence);

const bodyHtml = `<p style="margin:0 0 6px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b6b6b">Report scheduled</p>
<h1 style="margin:0 0 20px;font-size:24px;line-height:1.2;letter-spacing:-0.02em;color:#0a0a0a">${safeTitle} is set for ${safeCadence}.</h1>
<p style="margin:0 0 12px;font-size:14px;line-height:1.6;color:#0a0a0a">Recoup will run it on that schedule and email you the result, so your catalog keeps getting measured without you asking.</p>
<p style="margin:0;font-size:14px;line-height:1.6;color:#6b6b6b">You can change the cadence, pause it, or add another report any time.</p>`;

const html = renderEmailLayout({
bodyHtml,
cta: { label: "View your reports &rarr;", url: `${CHAT_APP_URL}/tasks` },
footerHtml: getEmailFooter(),
});

return { subject: `Scheduled: ${title}`, html };
}
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 };
}
47 changes: 47 additions & 0 deletions lib/emails/describeCronCadence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const WEEKDAYS = [
"Sundays",
"Mondays",
"Tuesdays",
"Wednesdays",
"Thursdays",
"Fridays",
"Saturdays",
];

const pad = (value: number): string => String(value).padStart(2, "0");

/**
* Plain-English cadence for a cron schedule, for the schedule-confirmation
* email (chat#1889).
*
* Deliberately narrow: it only describes the two shapes onboarding actually
* creates (a fixed weekday time, and a fixed daily time). Anything else falls
* back to the raw expression, because an honest cron string in an email beats a
* confidently wrong sentence about when someone's report will arrive.
*
* @param schedule - Standard 5-field cron expression.
* @param timeZone - IANA zone the expression is interpreted in; defaults to UTC.
*/
export function describeCronCadence(schedule: string, timeZone = "UTC"): string {
const fields = schedule.trim().split(/\s+/);
const fallback = `the schedule ${schedule}`;
if (fields.length !== 5) return fallback;

const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
const minuteNum = Number(minute);
const hourNum = Number(hour);

const isFixedTime =
/^\d{1,2}$/.test(minute) && /^\d{1,2}$/.test(hour) && minuteNum < 60 && hourNum < 24;
if (!isFixedTime || dayOfMonth !== "*" || month !== "*") return fallback;

const at = `${pad(hourNum)}:${pad(minuteNum)} ${timeZone}`;

if (dayOfWeek === "*") return `every day at ${at}`;

if (/^[0-6]$/.test(dayOfWeek)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Sunday schedules written with the valid 7 cron value render as raw cron text instead of a Sunday cadence. Treat 7 as the same Sunday index as 0 so confirmations remain human-readable for both supported forms.

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

<comment>Sunday schedules written with the valid `7` cron value render as raw cron text instead of a Sunday cadence. Treat `7` as the same Sunday index as `0` so confirmations remain human-readable for both supported forms.</comment>

<file context>
@@ -0,0 +1,53 @@
+
+  if (dayOfWeek === "*") return `every day at ${at}`;
+
+  if (/^[0-6]$/.test(dayOfWeek)) {
+    return `${WEEKDAYS[Number(dayOfWeek)]} at ${at}`;
+  }
</file context>

return `${WEEKDAYS[Number(dayOfWeek)]} at ${at}`;
}

return fallback;
}
Loading
Loading