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
1 change: 1 addition & 0 deletions lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ 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";

/**
* 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");
});
});
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)) {
return `${WEEKDAYS[Number(dayOfWeek)]} at ${at}`;
}

return fallback;
}
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
Loading
Loading