From 2224a55968e7aea9fba4d47eaaf221a42267703e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 20 Jul 2026 15:35:05 -0500 Subject: [PATCH 1/6] feat(emails): send one-time welcome email on account creation New signups have never received a single email (email_send_log has zero non-task rows), so anyone who closes the tab after signing up is unreachable. This sends a short branded welcome email, with one next step (see your valuation on chat), whenever an account is first created with an email address. - buildWelcomeEmail: subject + inline-HTML body matching the existing plain email template style, CTA links to the chat app via getFrontendBaseUrl, standard reply footer appended - sendWelcomeEmail: sends from RECOUP_FROM_EMAIL via Resend, logs sent/send_failed to email_send_log with a "type":"welcome_email" raw_body marker; best-effort (never throws into account creation) - selectWelcomeEmailLog: idempotency guard - skips the send when a sent welcome row already exists for the account; failed attempts do not match, so they can retry - Hooked into both first-creation paths: createAccountHandler (POST /api/accounts new-account branch, email signups only) and getOrCreateAccountIdByAuthToken (fresh Privy bearer-token users) Part of recoupable/chat#1867 (welcome email on signup). Co-Authored-By: Claude Fable 5 --- .../__tests__/createAccountHandler.test.ts | 28 ++++++ lib/accounts/createAccountHandler.ts | 7 ++ lib/const.ts | 3 + .../__tests__/buildWelcomeEmail.test.ts | 48 ++++++++++ lib/emails/__tests__/sendWelcomeEmail.test.ts | 91 +++++++++++++++++++ lib/emails/buildWelcomeEmail.ts | 35 +++++++ lib/emails/sendWelcomeEmail.ts | 51 +++++++++++ .../getOrCreateAccountIdByAuthToken.test.ts | 10 ++ lib/privy/getOrCreateAccountIdByAuthToken.ts | 9 +- .../__tests__/selectWelcomeEmailLog.test.ts | 58 ++++++++++++ .../email_send_log/selectWelcomeEmailLog.ts | 30 ++++++ 11 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 lib/emails/__tests__/buildWelcomeEmail.test.ts create mode 100644 lib/emails/__tests__/sendWelcomeEmail.test.ts create mode 100644 lib/emails/buildWelcomeEmail.ts create mode 100644 lib/emails/sendWelcomeEmail.ts create mode 100644 lib/supabase/email_send_log/__tests__/selectWelcomeEmailLog.test.ts create mode 100644 lib/supabase/email_send_log/selectWelcomeEmailLog.ts diff --git a/lib/accounts/__tests__/createAccountHandler.test.ts b/lib/accounts/__tests__/createAccountHandler.test.ts index e4a3b6d46..34156b6c7 100644 --- a/lib/accounts/__tests__/createAccountHandler.test.ts +++ b/lib/accounts/__tests__/createAccountHandler.test.ts @@ -42,6 +42,11 @@ vi.mock("@/lib/organizations/assignAccountToOrg", () => ({ assignAccountToOrg: (...args: unknown[]) => mockAssignAccountToOrg(...args), })); +const mockSendWelcomeEmail = vi.fn(); +vi.mock("@/lib/emails/sendWelcomeEmail", () => ({ + sendWelcomeEmail: (...args: unknown[]) => mockSendWelcomeEmail(...args), +})); + describe("createAccountHandler", () => { beforeEach(() => { vi.clearAllMocks(); @@ -129,6 +134,29 @@ describe("createAccountHandler", () => { expect(mockAssignAccountToOrg).toHaveBeenCalledWith("account-789", "new@example.com"); expect(mockInsertAccountWallet).toHaveBeenCalledWith("account-789", "0xdef"); expect(mockInitializeAccountCredits).toHaveBeenCalledWith("account-789"); + expect(mockSendWelcomeEmail).toHaveBeenCalledWith({ + accountId: "account-789", + email: "new@example.com", + }); + }); + + it("does not send a welcome email to an existing account", async () => { + mockSelectAccountByEmail.mockResolvedValue({ account_id: "account-123" }); + mockGetAccountWithDetails.mockResolvedValue({ account_id: "account-123" }); + + await createAccountHandler({ email: "user@example.com" }); + + expect(mockSendWelcomeEmail).not.toHaveBeenCalled(); + }); + + it("does not send a welcome email for a wallet-only signup", async () => { + mockSelectAccountByEmail.mockResolvedValue(null); + mockSelectAccountByWallet.mockRejectedValue(new Error("not found")); + mockInsertAccount.mockResolvedValue({ id: "account-789" }); + + await createAccountHandler({ wallet: "0xdef" }); + + expect(mockSendWelcomeEmail).not.toHaveBeenCalled(); }); it("returns 400 when account creation fails", async () => { diff --git a/lib/accounts/createAccountHandler.ts b/lib/accounts/createAccountHandler.ts index e93c5fcc5..b6ebe7ef5 100644 --- a/lib/accounts/createAccountHandler.ts +++ b/lib/accounts/createAccountHandler.ts @@ -8,6 +8,7 @@ import { insertAccountEmail } from "@/lib/supabase/account_emails/insertAccountE import { insertAccountWallet } from "@/lib/supabase/account_wallets/insertAccountWallet"; import { initializeAccountCredits } from "@/lib/credits/initializeAccountCredits"; import { assignAccountToOrg } from "@/lib/organizations/assignAccountToOrg"; +import { sendWelcomeEmail } from "@/lib/emails/sendWelcomeEmail"; import type { CreateAccountBody } from "./validateCreateAccountBody"; /** @@ -93,6 +94,12 @@ export async function createAccountHandler(body: CreateAccountBody): Promise`; +/** Marker stored in email_send_log.raw_body to identify welcome-email sends. */ +export const WELCOME_EMAIL_LOG_TYPE = "welcome_email"; + /** * Generic message returned for every POST /api/agents/signup response, * regardless of which branch (new agent+, existing account, new normal diff --git a/lib/emails/__tests__/buildWelcomeEmail.test.ts b/lib/emails/__tests__/buildWelcomeEmail.test.ts new file mode 100644 index 000000000..7507a989f --- /dev/null +++ b/lib/emails/__tests__/buildWelcomeEmail.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockGetFrontendBaseUrl = vi.fn(); +vi.mock("@/lib/composio/getFrontendBaseUrl", () => ({ + getFrontendBaseUrl: (...args: unknown[]) => mockGetFrontendBaseUrl(...args), +})); + +const mockGetEmailFooter = vi.fn(); +vi.mock("@/lib/emails/getEmailFooter", () => ({ + getEmailFooter: (...args: unknown[]) => mockGetEmailFooter(...args), +})); + +const { buildWelcomeEmail } = await import("../buildWelcomeEmail"); + +describe("buildWelcomeEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetFrontendBaseUrl.mockReturnValue("https://chat.example.com"); + mockGetEmailFooter.mockReturnValue("
reply note
"); + }); + + it("returns the welcome subject", () => { + const { subject } = buildWelcomeEmail(); + + expect(subject).toBe("Welcome to Recoup"); + }); + + it("links the one next step to the chat app", () => { + const { html } = buildWelcomeEmail(); + + expect(html).toContain('href="https://chat.example.com"'); + expect(html.toLowerCase()).toContain("valuation"); + }); + + it("appends the standard email footer without a room link", () => { + const { html } = buildWelcomeEmail(); + + expect(mockGetEmailFooter).toHaveBeenCalledWith(); + expect(html).toContain("
reply note
"); + }); + + it("contains no em or en dashes in outward-facing copy", () => { + const { subject, html } = buildWelcomeEmail(); + + expect(subject).not.toMatch(/[–—]/); + expect(html).not.toMatch(/[–—]/); + }); +}); diff --git a/lib/emails/__tests__/sendWelcomeEmail.test.ts b/lib/emails/__tests__/sendWelcomeEmail.test.ts new file mode 100644 index 000000000..a9af9e3ea --- /dev/null +++ b/lib/emails/__tests__/sendWelcomeEmail.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { RECOUP_FROM_EMAIL } from "@/lib/const"; + +const mockSelectWelcomeEmailLog = vi.fn(); +vi.mock("@/lib/supabase/email_send_log/selectWelcomeEmailLog", () => ({ + selectWelcomeEmailLog: (...args: unknown[]) => mockSelectWelcomeEmailLog(...args), +})); + +const mockBuildWelcomeEmail = vi.fn(); +vi.mock("@/lib/emails/buildWelcomeEmail", () => ({ + buildWelcomeEmail: (...args: unknown[]) => mockBuildWelcomeEmail(...args), +})); + +const mockSendEmailWithResend = vi.fn(); +vi.mock("@/lib/emails/sendEmail", () => ({ + sendEmailWithResend: (...args: unknown[]) => mockSendEmailWithResend(...args), +})); + +const mockLogEmailAttempt = vi.fn(); +vi.mock("@/lib/emails/logEmailAttempt", () => ({ + logEmailAttempt: (...args: unknown[]) => mockLogEmailAttempt(...args), +})); + +const { sendWelcomeEmail } = await import("../sendWelcomeEmail"); + +describe("sendWelcomeEmail", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockSelectWelcomeEmailLog.mockResolvedValue(null); + mockBuildWelcomeEmail.mockReturnValue({ subject: "Welcome to Recoup", html: "

hi

" }); + mockSendEmailWithResend.mockResolvedValue({ id: "re_1" }); + mockLogEmailAttempt.mockResolvedValue(undefined); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("sends the welcome email from the Recoup address and logs a sent attempt", async () => { + await sendWelcomeEmail({ accountId: "acc-1", email: "new@example.com" }); + + expect(mockSendEmailWithResend).toHaveBeenCalledWith({ + from: RECOUP_FROM_EMAIL, + to: ["new@example.com"], + subject: "Welcome to Recoup", + html: "

hi

", + }); + + expect(mockLogEmailAttempt).toHaveBeenCalledTimes(1); + const attempt = mockLogEmailAttempt.mock.calls[0][0]; + expect(attempt.status).toBe("sent"); + expect(attempt.accountId).toBe("acc-1"); + expect(attempt.resendId).toBe("re_1"); + expect(JSON.parse(attempt.rawBody)).toEqual({ + type: "welcome_email", + to: "new@example.com", + subject: "Welcome to Recoup", + }); + }); + + it("skips the send when a welcome email was already sent for the account", async () => { + mockSelectWelcomeEmailLog.mockResolvedValue({ id: "log-1" }); + + await sendWelcomeEmail({ accountId: "acc-1", email: "new@example.com" }); + + expect(mockSendEmailWithResend).not.toHaveBeenCalled(); + expect(mockLogEmailAttempt).not.toHaveBeenCalled(); + }); + + it("logs a send_failed attempt when Resend rejects the send", async () => { + mockSendEmailWithResend.mockResolvedValue( + NextResponse.json({ error: "Failed to send email" }, { status: 502 }), + ); + + await sendWelcomeEmail({ accountId: "acc-1", email: "new@example.com" }); + + const attempt = mockLogEmailAttempt.mock.calls[0][0]; + expect(attempt.status).toBe("send_failed"); + expect(attempt.accountId).toBe("acc-1"); + expect(attempt.resendId).toBeUndefined(); + }); + + it("never throws when a dependency fails", async () => { + mockSendEmailWithResend.mockRejectedValue(new Error("network down")); + + await expect( + sendWelcomeEmail({ accountId: "acc-1", email: "new@example.com" }), + ).resolves.toBeUndefined(); + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/lib/emails/buildWelcomeEmail.ts b/lib/emails/buildWelcomeEmail.ts new file mode 100644 index 000000000..71e5fab9f --- /dev/null +++ b/lib/emails/buildWelcomeEmail.ts @@ -0,0 +1,35 @@ +import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; +import { getEmailFooter } from "@/lib/emails/getEmailFooter"; + +/** + * Builds the welcome email sent to every new account: a short greeting and + * one next step (see your catalog valuation on the chat app), styled to match + * the existing plain inline-HTML email templates. + * + * @returns The email subject and HTML body. + */ +export function buildWelcomeEmail(): { subject: string; html: string } { + const chatUrl = getFrontendBaseUrl(); + const footer = getEmailFooter(); + + const html = ` +
+

Welcome to Recoup

+

+ Recoup is your AI team for the music business. Ask about your artists, + your catalog, or your next release, and it does the work. +

+

+ Your next step: see what your catalog is worth. +

+

+ + See your valuation + +

+ ${footer} +
`.trim(); + + return { subject: "Welcome to Recoup", html }; +} diff --git a/lib/emails/sendWelcomeEmail.ts b/lib/emails/sendWelcomeEmail.ts new file mode 100644 index 000000000..c8c4e8f9b --- /dev/null +++ b/lib/emails/sendWelcomeEmail.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { RECOUP_FROM_EMAIL, WELCOME_EMAIL_LOG_TYPE } from "@/lib/const"; +import { buildWelcomeEmail } from "@/lib/emails/buildWelcomeEmail"; +import { sendEmailWithResend } from "@/lib/emails/sendEmail"; +import { logEmailAttempt } from "@/lib/emails/logEmailAttempt"; +import { selectWelcomeEmailLog } from "@/lib/supabase/email_send_log/selectWelcomeEmailLog"; + +/** + * Sends the one-time welcome email to a newly created account and records the + * attempt in `email_send_log` (raw_body carries the `welcome_email` marker). + * + * Idempotent: skips the send when a sent welcome row already exists for the + * account. Best-effort: never throws, so a Resend or DB failure can never + * break account creation. + * + * @param accountId - The newly created account's id. + * @param email - The email address linked to the account. + */ +export async function sendWelcomeEmail({ + accountId, + email, +}: { + accountId: string; + email: string; +}): Promise { + try { + const alreadySent = await selectWelcomeEmailLog(accountId); + if (alreadySent) { + return; + } + + const { subject, html } = buildWelcomeEmail(); + const rawBody = JSON.stringify({ type: WELCOME_EMAIL_LOG_TYPE, to: email, subject }); + + const result = await sendEmailWithResend({ + from: RECOUP_FROM_EMAIL, + to: [email], + subject, + html, + }); + + if (result instanceof NextResponse) { + await logEmailAttempt({ rawBody, status: "send_failed", accountId }); + return; + } + + await logEmailAttempt({ rawBody, status: "sent", accountId, resendId: result.id }); + } catch (error) { + console.error("sendWelcomeEmail failed (swallowed):", error); + } +} diff --git a/lib/privy/__tests__/getOrCreateAccountIdByAuthToken.test.ts b/lib/privy/__tests__/getOrCreateAccountIdByAuthToken.test.ts index a1322da8e..baaadfbee 100644 --- a/lib/privy/__tests__/getOrCreateAccountIdByAuthToken.test.ts +++ b/lib/privy/__tests__/getOrCreateAccountIdByAuthToken.test.ts @@ -16,6 +16,11 @@ vi.mock("@/lib/agents/createAccountWithEmail", () => ({ createAccountWithEmail: vi.fn(), })); +const mockSendWelcomeEmail = vi.fn(); +vi.mock("@/lib/emails/sendWelcomeEmail", () => ({ + sendWelcomeEmail: (...args: unknown[]) => mockSendWelcomeEmail(...args), +})); + describe("getOrCreateAccountIdByAuthToken", () => { beforeEach(() => { vi.clearAllMocks(); @@ -31,6 +36,7 @@ describe("getOrCreateAccountIdByAuthToken", () => { expect(accountId).toBe("acc-existing"); expect(createAccountWithEmail).not.toHaveBeenCalled(); + expect(mockSendWelcomeEmail).not.toHaveBeenCalled(); }); it("creates an account and returns the new accountId when email is unknown", async () => { @@ -42,6 +48,10 @@ describe("getOrCreateAccountIdByAuthToken", () => { expect(createAccountWithEmail).toHaveBeenCalledWith("new@example.com"); expect(accountId).toBe("acc-new"); + expect(mockSendWelcomeEmail).toHaveBeenCalledWith({ + accountId: "acc-new", + email: "new@example.com", + }); }); it("creates an account when account_emails returns null", async () => { diff --git a/lib/privy/getOrCreateAccountIdByAuthToken.ts b/lib/privy/getOrCreateAccountIdByAuthToken.ts index d74e660b2..ebe4d7ef6 100644 --- a/lib/privy/getOrCreateAccountIdByAuthToken.ts +++ b/lib/privy/getOrCreateAccountIdByAuthToken.ts @@ -1,6 +1,7 @@ import { getEmailByAuthToken } from "./getEmailByAuthToken"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; import { createAccountWithEmail } from "@/lib/agents/createAccountWithEmail"; +import { sendWelcomeEmail } from "@/lib/emails/sendWelcomeEmail"; /** * Get account ID from a Privy auth token, creating an account on the fly @@ -23,5 +24,11 @@ export async function getOrCreateAccountIdByAuthToken(authToken: string): Promis return existingAccountId; } - return createAccountWithEmail(email); + const accountId = await createAccountWithEmail(email); + + // First creation of this account: send the one-time welcome email. + // Best-effort (never throws) and guarded by email_send_log inside. + await sendWelcomeEmail({ accountId, email }); + + return accountId; } diff --git a/lib/supabase/email_send_log/__tests__/selectWelcomeEmailLog.test.ts b/lib/supabase/email_send_log/__tests__/selectWelcomeEmailLog.test.ts new file mode 100644 index 000000000..2a748538b --- /dev/null +++ b/lib/supabase/email_send_log/__tests__/selectWelcomeEmailLog.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockMaybeSingle = vi.fn(); +const mockLimit = vi.fn(() => ({ maybeSingle: mockMaybeSingle })); +const mockLike = vi.fn(() => ({ limit: mockLimit })); +const mockEqStatus = vi.fn(() => ({ like: mockLike })); +const mockEqAccount = vi.fn(() => ({ eq: mockEqStatus })); +const mockSelect = vi.fn(() => ({ eq: mockEqAccount })); +const mockFrom = vi.fn((_table: string) => ({ select: mockSelect })); + +vi.mock("../../serverClient", () => ({ + default: { from: (table: string) => mockFrom(table) }, +})); + +const { selectWelcomeEmailLog } = await import("../selectWelcomeEmailLog"); + +describe("selectWelcomeEmailLog", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockMaybeSingle.mockResolvedValue({ data: null, error: null }); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("queries email_send_log for a sent welcome email of the account", async () => { + await selectWelcomeEmailLog("acc-1"); + + expect(mockFrom).toHaveBeenCalledWith("email_send_log"); + expect(mockEqAccount).toHaveBeenCalledWith("account_id", "acc-1"); + expect(mockEqStatus).toHaveBeenCalledWith("status", "sent"); + expect(mockLike).toHaveBeenCalledWith("raw_body", '%"type":"welcome_email"%'); + expect(mockLimit).toHaveBeenCalledWith(1); + }); + + it("returns the matching row when a welcome email was already sent", async () => { + mockMaybeSingle.mockResolvedValue({ data: { id: "log-1" }, error: null }); + + const result = await selectWelcomeEmailLog("acc-1"); + + expect(result).toEqual({ id: "log-1" }); + }); + + it("returns null when no welcome email has been sent", async () => { + const result = await selectWelcomeEmailLog("acc-1"); + + expect(result).toBeNull(); + }); + + it("returns null and logs on database error", async () => { + mockMaybeSingle.mockResolvedValue({ data: null, error: { message: "boom" } }); + + const result = await selectWelcomeEmailLog("acc-1"); + + expect(result).toBeNull(); + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/lib/supabase/email_send_log/selectWelcomeEmailLog.ts b/lib/supabase/email_send_log/selectWelcomeEmailLog.ts new file mode 100644 index 000000000..30428fb2b --- /dev/null +++ b/lib/supabase/email_send_log/selectWelcomeEmailLog.ts @@ -0,0 +1,30 @@ +import supabase from "../serverClient"; +import { WELCOME_EMAIL_LOG_TYPE } from "@/lib/const"; + +/** + * Looks up a previously *sent* welcome email for an account in + * `email_send_log` (identified by the `"type":"welcome_email"` marker in + * `raw_body`). Used as the idempotency guard so a welcome email is never + * sent twice to the same account. Failed attempts don't match, so a + * `send_failed` welcome can be retried. + * + * @param accountId - The account to check. + * @returns The matching log row id, or null when none exists (or on error). + */ +export async function selectWelcomeEmailLog(accountId: string): Promise<{ id: string } | null> { + const { data, error } = await supabase + .from("email_send_log") + .select("id") + .eq("account_id", accountId) + .eq("status", "sent") + .like("raw_body", `%"type":"${WELCOME_EMAIL_LOG_TYPE}"%`) + .limit(1) + .maybeSingle(); + + if (error) { + console.error("Error fetching welcome email log:", error); + return null; + } + + return data ?? null; +} From 03b00141910366b8862acd3d1f9ab2fb543e01e6 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Jul 2026 11:00:40 -0500 Subject: [PATCH 2/6] feat(emails): rebuild welcome email around the onboarding flow + house artists Replace the generic welcome with a 5-step onboarding walkthrough (mirroring chat's onboarding: confirm artists, verify socials, claim catalog, see baseline valuation, automate with tasks), illustrated with art from the house cast (Gatsby Grace, LA EQUIS, Sound of Fractures, Brauxelion, LATASHA): a PFP cast strip + per-step PFPs/album covers. - lib/emails/welcome/welcomeEmailCast.ts: curated cast + stable i.scdn.co art (Spotify CDN never expires, unlike signed IG/TikTok avatar URLs) - lib/emails/welcome/welcomeOnboardingSteps.ts: the 5 steps + thumbnails - lib/emails/welcome/renderWelcomeCastStrip.ts / renderWelcomeSteps.ts (+ tests) - buildWelcomeEmail.ts: house-style chrome (logo header, card, CTA) matching the valuation email; copy avoids em/en dashes 10 tests pass; tsc + lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/buildWelcomeEmail.test.ts | 38 ++++++++++++ lib/emails/buildWelcomeEmail.ts | 52 ++++++++++------- .../__tests__/renderWelcomeCastStrip.test.ts | 18 ++++++ .../__tests__/renderWelcomeSteps.test.ts | 22 +++++++ lib/emails/welcome/renderWelcomeCastStrip.ts | 20 +++++++ lib/emails/welcome/renderWelcomeSteps.ts | 25 ++++++++ lib/emails/welcome/welcomeEmailCast.ts | 51 ++++++++++++++++ lib/emails/welcome/welcomeOnboardingSteps.ts | 58 +++++++++++++++++++ 8 files changed, 263 insertions(+), 21 deletions(-) create mode 100644 lib/emails/welcome/__tests__/renderWelcomeCastStrip.test.ts create mode 100644 lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts create mode 100644 lib/emails/welcome/renderWelcomeCastStrip.ts create mode 100644 lib/emails/welcome/renderWelcomeSteps.ts create mode 100644 lib/emails/welcome/welcomeEmailCast.ts create mode 100644 lib/emails/welcome/welcomeOnboardingSteps.ts diff --git a/lib/emails/__tests__/buildWelcomeEmail.test.ts b/lib/emails/__tests__/buildWelcomeEmail.test.ts index 7507a989f..3a9f3b0e8 100644 --- a/lib/emails/__tests__/buildWelcomeEmail.test.ts +++ b/lib/emails/__tests__/buildWelcomeEmail.test.ts @@ -45,4 +45,42 @@ describe("buildWelcomeEmail", () => { expect(subject).not.toMatch(/[–—]/); expect(html).not.toMatch(/[–—]/); }); + + it("walks the five onboarding steps in order", () => { + const { html } = buildWelcomeEmail(); + + const order = [ + "1. Confirm your artists", + "2. Verify their socials", + "3. Claim your catalog", + "4. See your baseline valuation", + "5. Automate with tasks", + ]; + let cursor = -1; + for (const label of order) { + const at = html.indexOf(label); + expect(at, `"${label}" present`).toBeGreaterThan(-1); + expect(at, `"${label}" after the previous step`).toBeGreaterThan(cursor); + cursor = at; + } + }); + + it("features the full cast with stable Spotify art (no expiring social CDNs)", () => { + const { html } = buildWelcomeEmail(); + + for (const name of [ + "Gatsby Grace", + "LA EQUIS", + "Sound of Fractures", + "Brauxelion", + "LATASHA", + ]) { + expect(html).toContain(name); + } + // At least the 5 PFPs + the album covers used in the steps. + const imageCount = (html.match(/i\.scdn\.co/g) ?? []).length; + expect(imageCount).toBeGreaterThanOrEqual(8); + expect(html).not.toContain("cdninstagram.com"); + expect(html).not.toContain("tiktokcdn"); + }); }); diff --git a/lib/emails/buildWelcomeEmail.ts b/lib/emails/buildWelcomeEmail.ts index 71e5fab9f..50a70bd90 100644 --- a/lib/emails/buildWelcomeEmail.ts +++ b/lib/emails/buildWelcomeEmail.ts @@ -1,10 +1,21 @@ +import { RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const"; import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; import { getEmailFooter } from "@/lib/emails/getEmailFooter"; +import { renderWelcomeCastStrip } from "@/lib/emails/welcome/renderWelcomeCastStrip"; +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: a short greeting and - * one next step (see your catalog valuation on the chat app), styled to match - * the existing plain inline-HTML email templates. + * 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, fixed CTA to the chat app. Copy avoids em/en dashes. * * @returns The email subject and HTML body. */ @@ -12,24 +23,23 @@ export function buildWelcomeEmail(): { subject: string; html: string } { const chatUrl = getFrontendBaseUrl(); const footer = getEmailFooter(); - const html = ` -
-

Welcome to Recoup

-

- Recoup is your AI team for the music business. Ask about your artists, - your catalog, or your next release, and it does the work. -

-

- Your next step: see what your catalog is worth. -

-

- - See your valuation - -

- ${footer} -
`.trim(); + const html = `
+ +
+ + + +
+

Welcome to Recoup

+

You're in. Let's build your catalog value.

+
Recoup
+

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.

+${renderWelcomeCastStrip()} +${renderWelcomeSteps()} +
Confirm your roster →
+${footer} +
+
`; return { subject: "Welcome to Recoup", html }; } diff --git a/lib/emails/welcome/__tests__/renderWelcomeCastStrip.test.ts b/lib/emails/welcome/__tests__/renderWelcomeCastStrip.test.ts new file mode 100644 index 000000000..b3bf0c3cb --- /dev/null +++ b/lib/emails/welcome/__tests__/renderWelcomeCastStrip.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { renderWelcomeCastStrip } from "../renderWelcomeCastStrip"; +import { WELCOME_EMAIL_CAST } from "../welcomeEmailCast"; + +describe("renderWelcomeCastStrip", () => { + it("renders every cast artist's name and PFP", () => { + const html = renderWelcomeCastStrip(); + + for (const artist of WELCOME_EMAIL_CAST) { + expect(html).toContain(artist.name); + expect(html).toContain(artist.pfpUrl); + } + }); + + it("renders circular avatars", () => { + expect(renderWelcomeCastStrip()).toContain("border-radius:50%"); + }); +}); diff --git a/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts b/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts new file mode 100644 index 000000000..0335bd388 --- /dev/null +++ b/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { renderWelcomeSteps } from "../renderWelcomeSteps"; +import { WELCOME_ONBOARDING_STEPS } from "../welcomeOnboardingSteps"; + +describe("renderWelcomeSteps", () => { + it("renders one numbered row per step with its thumbnail", () => { + const html = renderWelcomeSteps(); + + WELCOME_ONBOARDING_STEPS.forEach((step, i) => { + expect(html).toContain(`${i + 1}. ${step.title}`); + expect(html).toContain(step.thumbUrl); + }); + }); + + it("uses circular thumbs for PFP steps and square for album covers", () => { + const html = renderWelcomeSteps(); + + // Step 1 (PFP) is circular, step 3 (album cover) is square. + expect(html).toContain("border-radius:50%"); + expect(html).toContain("border-radius:8px"); + }); +}); diff --git a/lib/emails/welcome/renderWelcomeCastStrip.ts b/lib/emails/welcome/renderWelcomeCastStrip.ts new file mode 100644 index 000000000..ef044e617 --- /dev/null +++ b/lib/emails/welcome/renderWelcomeCastStrip.ts @@ -0,0 +1,20 @@ +import { escapeHtml } from "@/lib/emails/escapeHtml"; +import { WELCOME_EMAIL_CAST } from "@/lib/emails/welcome/welcomeEmailCast"; + +/** + * Render the cast strip: a row of circular artist PFPs with names, showing the + * kind of roster managers run on Recoup. Table + inline styles only, for email + * client safety. + */ +export function renderWelcomeCastStrip(): string { + const cells = WELCOME_EMAIL_CAST.map(artist => { + const name = escapeHtml(artist.name); + return ` +${name} +

${name}

+`; + }).join(""); + + return `

Managers run artists like these on Recoup

+${cells}
`; +} diff --git a/lib/emails/welcome/renderWelcomeSteps.ts b/lib/emails/welcome/renderWelcomeSteps.ts new file mode 100644 index 000000000..d534f94b7 --- /dev/null +++ b/lib/emails/welcome/renderWelcomeSteps.ts @@ -0,0 +1,25 @@ +import { escapeHtml } from "@/lib/emails/escapeHtml"; +import { WELCOME_ONBOARDING_STEPS } from "@/lib/emails/welcome/welcomeOnboardingSteps"; + +/** + * Render the numbered onboarding steps: each row is an art thumbnail (artist + * PFP or album cover) beside the step number, title, and one-line description. + * Table + inline styles only, for email client safety. + */ +export function renderWelcomeSteps(): string { + const rows = WELCOME_ONBOARDING_STEPS.map((step, i) => { + const radius = step.thumbShape === "circle" ? "50%" : "8px"; + const alt = escapeHtml(step.thumbAlt); + return ` + +${alt} + + +

${i + 1}. ${escapeHtml(step.title)}

+

${escapeHtml(step.description)}

+ +`; + }).join(""); + + return `${rows}
`; +} diff --git a/lib/emails/welcome/welcomeEmailCast.ts b/lib/emails/welcome/welcomeEmailCast.ts new file mode 100644 index 000000000..eccff0696 --- /dev/null +++ b/lib/emails/welcome/welcomeEmailCast.ts @@ -0,0 +1,51 @@ +/** + * Curated cast of Recoup house artists featured in the welcome email, with + * stable Spotify CDN art (`i.scdn.co` URLs never expire, unlike the signed + * Instagram/TikTok avatar URLs). PFPs and album covers were resolved from each + * artist's linked Spotify profile (authoritative artist id, not name search). + * + * These are deliberately hard-coded: the welcome email is the same for every + * signup, so a fixed curated set keeps the send deterministic with no per-send + * DB or Spotify lookup. + */ +export type WelcomeArtist = { + name: string; + /** Spotify artist image (640px), used as the circular PFP. */ + pfpUrl: string; + /** A representative album cover (300px) from the artist's catalog. */ + albumCoverUrl: string; + albumName: string; +}; + +export const WELCOME_EMAIL_CAST: WelcomeArtist[] = [ + { + name: "Gatsby Grace", + pfpUrl: "https://i.scdn.co/image/ab6761610000e5eb5fd8fa1d768bbc789e903a3f", + albumCoverUrl: "https://i.scdn.co/image/ab67616d00001e0292639b99a324cfab10703697", + albumName: "Beautiful Tomorrow", + }, + { + name: "LA EQUIS", + pfpUrl: "https://i.scdn.co/image/ab6761610000e5eb8df3365689c6ee50ba46e700", + albumCoverUrl: "https://i.scdn.co/image/ab67616d00001e02c5e86ce5be917714de1bbe58", + albumName: "El Nino Maravilla", + }, + { + name: "Sound of Fractures", + pfpUrl: "https://i.scdn.co/image/ab6761610000e5eb5bc5bd1767967b125c58b1b2", + albumCoverUrl: "https://i.scdn.co/image/ab67616d00001e02c6882ee558afd3c4b34c6097", + albumName: "SCENES", + }, + { + name: "Brauxelion", + pfpUrl: "https://i.scdn.co/image/ab6761610000e5eb016e30bb5088f89cb038d2dc", + albumCoverUrl: "https://i.scdn.co/image/ab67616d00001e02729b19bb294efd31450039d0", + albumName: "Xpeed Gear", + }, + { + name: "LATASHA", + pfpUrl: "https://i.scdn.co/image/ab6761610000e5ebf93acfdbc34aeecb2f3bd0d1", + albumCoverUrl: "https://i.scdn.co/image/ab67616d00001e02793523735641c057708528fe", + albumName: "Tried + Tru", + }, +]; diff --git a/lib/emails/welcome/welcomeOnboardingSteps.ts b/lib/emails/welcome/welcomeOnboardingSteps.ts new file mode 100644 index 000000000..72e3ed623 --- /dev/null +++ b/lib/emails/welcome/welcomeOnboardingSteps.ts @@ -0,0 +1,58 @@ +import { WELCOME_EMAIL_CAST } from "@/lib/emails/welcome/welcomeEmailCast"; + +/** + * The five onboarding steps mirrored in the welcome email, using the product's + * real flow language (chat `lib/onboarding/getOnboardingStepContent.ts`: + * Confirm artists, Verify socials, Claim catalog, Schedule report) plus the + * baseline valuation. Each step is illustrated with art from the house cast: + * PFPs for the artist/social steps, album covers for the catalog/value/task + * steps, so all five artists appear across the email. + */ +export type WelcomeStep = { + title: string; + description: string; + thumbUrl: string; + /** "circle" for artist PFPs, "square" for album covers. */ + thumbShape: "circle" | "square"; + thumbAlt: string; +}; + +export const WELCOME_ONBOARDING_STEPS: WelcomeStep[] = [ + { + title: "Confirm your artists", + description: "Add the artists you manage to your roster so Recoup works across all of them.", + thumbUrl: WELCOME_EMAIL_CAST[0].pfpUrl, + thumbShape: "circle", + thumbAlt: WELCOME_EMAIL_CAST[0].name, + }, + { + title: "Verify their socials", + description: + "Check the social profiles matched to each artist so every report pulls the right data.", + thumbUrl: WELCOME_EMAIL_CAST[3].pfpUrl, + thumbShape: "circle", + thumbAlt: WELCOME_EMAIL_CAST[3].name, + }, + { + title: "Claim your catalog", + description: "Connect the songs you own so Recoup can measure and track their value over time.", + thumbUrl: WELCOME_EMAIL_CAST[2].albumCoverUrl, + thumbShape: "square", + thumbAlt: WELCOME_EMAIL_CAST[2].albumName, + }, + { + title: "See your baseline valuation", + description: + "Get what your catalog is worth today. It is the number every weekly report moves.", + thumbUrl: WELCOME_EMAIL_CAST[1].albumCoverUrl, + thumbShape: "square", + thumbAlt: WELCOME_EMAIL_CAST[1].albumName, + }, + { + title: "Automate with tasks", + description: "Schedule a recurring report and Recoup keeps working your catalog every week.", + thumbUrl: WELCOME_EMAIL_CAST[4].albumCoverUrl, + thumbShape: "square", + thumbAlt: WELCOME_EMAIL_CAST[4].albumName, + }, +]; From c375e62f0b565cc1b2b6fa4dc21853b25822a6bd Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Jul 2026 11:30:40 -0500 Subject: [PATCH 3/6] feat(emails): per-step art + /setup deep links in welcome onboarding email Iterate on the welcome email per feedback: - Remove the 'Managers run artists like these' cast strip. - Step 1 image is now an overlapping stack of the 5 house-artist PFPs (general social proof, no names), pre-composed on Vercel Blob. - Step 2 image is a real Instagram post thumbnail with an IG badge (pre-composed on Blob; IG CDN URLs are signed/expiring so the frame is re-hosted durably). - Steps 3/4 use specific album covers (LA EQUIS, Sound of Fractures); step 5 unchanged. - Each step gets an inline link into its /setup/ route; the primary CTA now targets /setup. Links build on getFrontendBaseUrl (env-aware). 10 welcome tests + 188 lib/emails tests pass; tsc + lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/buildWelcomeEmail.test.ts | 31 +++++---- lib/emails/buildWelcomeEmail.ts | 8 +-- .../__tests__/renderWelcomeCastStrip.test.ts | 18 ----- .../__tests__/renderWelcomeSteps.test.ts | 34 ++++++--- lib/emails/welcome/renderWelcomeCastStrip.ts | 20 ------ lib/emails/welcome/renderWelcomeSteps.ts | 37 ++++++---- lib/emails/welcome/welcomeEmailCast.ts | 51 -------------- lib/emails/welcome/welcomeOnboardingSteps.ts | 69 ++++++++++++------- 8 files changed, 117 insertions(+), 151 deletions(-) delete mode 100644 lib/emails/welcome/__tests__/renderWelcomeCastStrip.test.ts delete mode 100644 lib/emails/welcome/renderWelcomeCastStrip.ts delete mode 100644 lib/emails/welcome/welcomeEmailCast.ts diff --git a/lib/emails/__tests__/buildWelcomeEmail.test.ts b/lib/emails/__tests__/buildWelcomeEmail.test.ts index 3a9f3b0e8..ea88d0de2 100644 --- a/lib/emails/__tests__/buildWelcomeEmail.test.ts +++ b/lib/emails/__tests__/buildWelcomeEmail.test.ts @@ -25,10 +25,10 @@ describe("buildWelcomeEmail", () => { expect(subject).toBe("Welcome to Recoup"); }); - it("links the one next step to the chat app", () => { + it("points the primary CTA at the setup flow", () => { const { html } = buildWelcomeEmail(); - expect(html).toContain('href="https://chat.example.com"'); + expect(html).toContain('href="https://chat.example.com/setup"'); expect(html.toLowerCase()).toContain("valuation"); }); @@ -65,21 +65,26 @@ describe("buildWelcomeEmail", () => { } }); - it("features the full cast with stable Spotify art (no expiring social CDNs)", () => { + it("links each step into its /setup route", () => { const { html } = buildWelcomeEmail(); - for (const name of [ - "Gatsby Grace", - "LA EQUIS", - "Sound of Fractures", - "Brauxelion", - "LATASHA", + for (const path of [ + "/setup/artists", + "/setup/socials", + "/setup/catalog", + "/setup/valuation", + "/setup/tasks", ]) { - expect(html).toContain(name); + expect(html).toContain(`href="https://chat.example.com${path}"`); } - // At least the 5 PFPs + the album covers used in the steps. - const imageCount = (html.match(/i\.scdn\.co/g) ?? []).length; - expect(imageCount).toBeGreaterThanOrEqual(8); + }); + + it("uses only durable image hosts (no expiring social CDNs)", () => { + const { html } = buildWelcomeEmail(); + + // Album covers on Spotify CDN + the pre-composed step 1/2 art on Vercel Blob. + expect(html).toContain("i.scdn.co"); + expect(html).toContain("blob.vercel-storage.com"); expect(html).not.toContain("cdninstagram.com"); expect(html).not.toContain("tiktokcdn"); }); diff --git a/lib/emails/buildWelcomeEmail.ts b/lib/emails/buildWelcomeEmail.ts index 50a70bd90..81fd0398a 100644 --- a/lib/emails/buildWelcomeEmail.ts +++ b/lib/emails/buildWelcomeEmail.ts @@ -1,7 +1,6 @@ import { RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const"; import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; import { getEmailFooter } from "@/lib/emails/getEmailFooter"; -import { renderWelcomeCastStrip } from "@/lib/emails/welcome/renderWelcomeCastStrip"; import { renderWelcomeSteps } from "@/lib/emails/welcome/renderWelcomeSteps"; const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif"; @@ -20,7 +19,7 @@ const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif"; * @returns The email subject and HTML body. */ export function buildWelcomeEmail(): { subject: string; html: string } { - const chatUrl = getFrontendBaseUrl(); + const baseUrl = getFrontendBaseUrl(); const footer = getEmailFooter(); const html = `
@@ -34,9 +33,8 @@ export function buildWelcomeEmail(): { subject: string; html: string } { Recoup

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.

-${renderWelcomeCastStrip()} -${renderWelcomeSteps()} -
Confirm your roster →
+${renderWelcomeSteps(baseUrl)} +
Confirm your roster →
${footer} `; diff --git a/lib/emails/welcome/__tests__/renderWelcomeCastStrip.test.ts b/lib/emails/welcome/__tests__/renderWelcomeCastStrip.test.ts deleted file mode 100644 index b3bf0c3cb..000000000 --- a/lib/emails/welcome/__tests__/renderWelcomeCastStrip.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { renderWelcomeCastStrip } from "../renderWelcomeCastStrip"; -import { WELCOME_EMAIL_CAST } from "../welcomeEmailCast"; - -describe("renderWelcomeCastStrip", () => { - it("renders every cast artist's name and PFP", () => { - const html = renderWelcomeCastStrip(); - - for (const artist of WELCOME_EMAIL_CAST) { - expect(html).toContain(artist.name); - expect(html).toContain(artist.pfpUrl); - } - }); - - it("renders circular avatars", () => { - expect(renderWelcomeCastStrip()).toContain("border-radius:50%"); - }); -}); diff --git a/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts b/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts index 0335bd388..cc6f318bf 100644 --- a/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts +++ b/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts @@ -2,21 +2,39 @@ import { describe, it, expect } from "vitest"; import { renderWelcomeSteps } from "../renderWelcomeSteps"; import { WELCOME_ONBOARDING_STEPS } from "../welcomeOnboardingSteps"; +const BASE = "https://chat.example.com"; + describe("renderWelcomeSteps", () => { - it("renders one numbered row per step with its thumbnail", () => { - const html = renderWelcomeSteps(); + it("renders one numbered row per step with its image and link", () => { + const html = renderWelcomeSteps(BASE); WELCOME_ONBOARDING_STEPS.forEach((step, i) => { expect(html).toContain(`${i + 1}. ${step.title}`); - expect(html).toContain(step.thumbUrl); + expect(html).toContain(step.imageUrl); + expect(html).toContain(`href="${BASE}${step.linkPath}"`); + expect(html).toContain(`>${step.linkText}`); }); }); - it("uses circular thumbs for PFP steps and square for album covers", () => { - const html = renderWelcomeSteps(); + it("uses a wide strip for step 1 and fixed thumbnails for album covers", () => { + const html = renderWelcomeSteps(BASE); + + // Step 1 overlap is width-only (height auto); album covers are 56x56. + expect(html).toContain("width:132px;height:auto"); + expect(html).toContain("width:56px;height:56px"); + }); + + it("points every step at its /setup route", () => { + const html = renderWelcomeSteps(BASE); - // Step 1 (PFP) is circular, step 3 (album cover) is square. - expect(html).toContain("border-radius:50%"); - expect(html).toContain("border-radius:8px"); + for (const path of [ + "/setup/artists", + "/setup/socials", + "/setup/catalog", + "/setup/valuation", + "/setup/tasks", + ]) { + expect(html).toContain(`href="${BASE}${path}"`); + } }); }); diff --git a/lib/emails/welcome/renderWelcomeCastStrip.ts b/lib/emails/welcome/renderWelcomeCastStrip.ts deleted file mode 100644 index ef044e617..000000000 --- a/lib/emails/welcome/renderWelcomeCastStrip.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { escapeHtml } from "@/lib/emails/escapeHtml"; -import { WELCOME_EMAIL_CAST } from "@/lib/emails/welcome/welcomeEmailCast"; - -/** - * Render the cast strip: a row of circular artist PFPs with names, showing the - * kind of roster managers run on Recoup. Table + inline styles only, for email - * client safety. - */ -export function renderWelcomeCastStrip(): string { - const cells = WELCOME_EMAIL_CAST.map(artist => { - const name = escapeHtml(artist.name); - return ` -${name} -

${name}

-`; - }).join(""); - - return `

Managers run artists like these on Recoup

-${cells}
`; -} diff --git a/lib/emails/welcome/renderWelcomeSteps.ts b/lib/emails/welcome/renderWelcomeSteps.ts index d534f94b7..2f76cc468 100644 --- a/lib/emails/welcome/renderWelcomeSteps.ts +++ b/lib/emails/welcome/renderWelcomeSteps.ts @@ -1,22 +1,35 @@ import { escapeHtml } from "@/lib/emails/escapeHtml"; import { WELCOME_ONBOARDING_STEPS } from "@/lib/emails/welcome/welcomeOnboardingSteps"; +/** Inline-style the step thumbnail by kind (overlap strip vs album cover vs IG). */ +function imageTag(url: string, style: "wide" | "square" | "rounded", alt: string): string { + const src = escapeHtml(url); + const a = escapeHtml(alt); + if (style === "wide") { + // Overlapping-avatar strip (~3.9:1); fixed width, height auto keeps the ratio. + return `${a}`; + } + const radius = style === "rounded" ? "12px" : "8px"; + return `${a}`; +} + /** - * Render the numbered onboarding steps: each row is an art thumbnail (artist - * PFP or album cover) beside the step number, title, and one-line description. - * Table + inline styles only, for email client safety. + * Render the numbered onboarding steps. Each row is an art thumbnail beside the + * step number, title, one-line description, and an inline link into the matching + * `/setup/*` route (built from the frontend base URL). Table + inline styles + * only, for email client safety. + * + * @param baseUrl - Frontend base URL the step links are built on. */ -export function renderWelcomeSteps(): string { +export function renderWelcomeSteps(baseUrl: string): string { const rows = WELCOME_ONBOARDING_STEPS.map((step, i) => { - const radius = step.thumbShape === "circle" ? "50%" : "8px"; - const alt = escapeHtml(step.thumbAlt); + const href = escapeHtml(`${baseUrl}${step.linkPath}`); + const link = `${escapeHtml(step.linkText)}`; return ` - -${alt} - - -

${i + 1}. ${escapeHtml(step.title)}

-

${escapeHtml(step.description)}

+${imageTag(step.imageUrl, step.imageStyle, step.imageAlt)} + +

${i + 1}. ${escapeHtml(step.title)}

+

${escapeHtml(step.description)} ${link}

`; }).join(""); diff --git a/lib/emails/welcome/welcomeEmailCast.ts b/lib/emails/welcome/welcomeEmailCast.ts deleted file mode 100644 index eccff0696..000000000 --- a/lib/emails/welcome/welcomeEmailCast.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Curated cast of Recoup house artists featured in the welcome email, with - * stable Spotify CDN art (`i.scdn.co` URLs never expire, unlike the signed - * Instagram/TikTok avatar URLs). PFPs and album covers were resolved from each - * artist's linked Spotify profile (authoritative artist id, not name search). - * - * These are deliberately hard-coded: the welcome email is the same for every - * signup, so a fixed curated set keeps the send deterministic with no per-send - * DB or Spotify lookup. - */ -export type WelcomeArtist = { - name: string; - /** Spotify artist image (640px), used as the circular PFP. */ - pfpUrl: string; - /** A representative album cover (300px) from the artist's catalog. */ - albumCoverUrl: string; - albumName: string; -}; - -export const WELCOME_EMAIL_CAST: WelcomeArtist[] = [ - { - name: "Gatsby Grace", - pfpUrl: "https://i.scdn.co/image/ab6761610000e5eb5fd8fa1d768bbc789e903a3f", - albumCoverUrl: "https://i.scdn.co/image/ab67616d00001e0292639b99a324cfab10703697", - albumName: "Beautiful Tomorrow", - }, - { - name: "LA EQUIS", - pfpUrl: "https://i.scdn.co/image/ab6761610000e5eb8df3365689c6ee50ba46e700", - albumCoverUrl: "https://i.scdn.co/image/ab67616d00001e02c5e86ce5be917714de1bbe58", - albumName: "El Nino Maravilla", - }, - { - name: "Sound of Fractures", - pfpUrl: "https://i.scdn.co/image/ab6761610000e5eb5bc5bd1767967b125c58b1b2", - albumCoverUrl: "https://i.scdn.co/image/ab67616d00001e02c6882ee558afd3c4b34c6097", - albumName: "SCENES", - }, - { - name: "Brauxelion", - pfpUrl: "https://i.scdn.co/image/ab6761610000e5eb016e30bb5088f89cb038d2dc", - albumCoverUrl: "https://i.scdn.co/image/ab67616d00001e02729b19bb294efd31450039d0", - albumName: "Xpeed Gear", - }, - { - name: "LATASHA", - pfpUrl: "https://i.scdn.co/image/ab6761610000e5ebf93acfdbc34aeecb2f3bd0d1", - albumCoverUrl: "https://i.scdn.co/image/ab67616d00001e02793523735641c057708528fe", - albumName: "Tried + Tru", - }, -]; diff --git a/lib/emails/welcome/welcomeOnboardingSteps.ts b/lib/emails/welcome/welcomeOnboardingSteps.ts index 72e3ed623..67e0f9f4c 100644 --- a/lib/emails/welcome/welcomeOnboardingSteps.ts +++ b/lib/emails/welcome/welcomeOnboardingSteps.ts @@ -1,58 +1,79 @@ -import { WELCOME_EMAIL_CAST } from "@/lib/emails/welcome/welcomeEmailCast"; - /** * The five onboarding steps mirrored in the welcome email, using the product's * real flow language (chat `lib/onboarding/getOnboardingStepContent.ts`: * Confirm artists, Verify socials, Claim catalog, Schedule report) plus the - * baseline valuation. Each step is illustrated with art from the house cast: - * PFPs for the artist/social steps, album covers for the catalog/value/task - * steps, so all five artists appear across the email. + * baseline valuation. Each step links into the matching `/setup/*` route and is + * illustrated with art: + * - step 1: an overlapping stack of the house cast's PFPs (social proof), + * - step 2: a real Instagram post thumbnail with an IG badge, + * - steps 3-5: album covers from house artists. + * + * The step 1 + step 2 images are pre-composed PNGs on Vercel Blob (overlap and + * badge overlay can't be done reliably in email HTML), stored durably so they + * never expire like signed IG/TikTok CDN URLs. Album covers are stable Spotify + * CDN URLs used directly. */ export type WelcomeStep = { title: string; description: string; - thumbUrl: string; - /** "circle" for artist PFPs, "square" for album covers. */ - thumbShape: "circle" | "square"; - thumbAlt: string; + /** Anchor text appended after the description. */ + linkText: string; + /** Path appended to the frontend base URL for the step's link + is the CTA target root. */ + linkPath: string; + imageUrl: string; + /** "wide" = overlap strip, "square" = album cover, "rounded" = IG post thumb. */ + imageStyle: "wide" | "square" | "rounded"; + imageAlt: string; }; +const BLOB = "https://dxfamqbi5zyezrs5.public.blob.vercel-storage.com/welcome"; + export const WELCOME_ONBOARDING_STEPS: WelcomeStep[] = [ { title: "Confirm your artists", description: "Add the artists you manage to your roster so Recoup works across all of them.", - thumbUrl: WELCOME_EMAIL_CAST[0].pfpUrl, - thumbShape: "circle", - thumbAlt: WELCOME_EMAIL_CAST[0].name, + linkText: "Confirm your artists.", + linkPath: "/setup/artists", + imageUrl: `${BLOB}/step1-artists-overlap-8yfiYIyB0tye7PnZYmvqU7fAP40JDT.png`, + imageStyle: "wide", + imageAlt: "Artists on Recoup", }, { title: "Verify their socials", description: "Check the social profiles matched to each artist so every report pulls the right data.", - thumbUrl: WELCOME_EMAIL_CAST[3].pfpUrl, - thumbShape: "circle", - thumbAlt: WELCOME_EMAIL_CAST[3].name, + linkText: "Verify artist socials.", + linkPath: "/setup/socials", + imageUrl: `${BLOB}/step2-socials-ig-bdKZEyXs5vgFOgAw53ih8NbxSz2lf7.png`, + imageStyle: "rounded", + imageAlt: "Instagram post", }, { title: "Claim your catalog", description: "Connect the songs you own so Recoup can measure and track their value over time.", - thumbUrl: WELCOME_EMAIL_CAST[2].albumCoverUrl, - thumbShape: "square", - thumbAlt: WELCOME_EMAIL_CAST[2].albumName, + linkText: "Claim your catalog.", + linkPath: "/setup/catalog", + imageUrl: "https://i.scdn.co/image/ab67616d00001e028d88dae207e00a332c234837", + imageStyle: "square", + imageAlt: "Album cover", }, { title: "See your baseline valuation", description: "Get what your catalog is worth today. It is the number every weekly report moves.", - thumbUrl: WELCOME_EMAIL_CAST[1].albumCoverUrl, - thumbShape: "square", - thumbAlt: WELCOME_EMAIL_CAST[1].albumName, + linkText: "See your baseline valuation", + linkPath: "/setup/valuation", + imageUrl: "https://i.scdn.co/image/ab67616d00001e024aafdbad18bc27d7c429cdf1", + imageStyle: "square", + imageAlt: "Album cover", }, { title: "Automate with tasks", description: "Schedule a recurring report and Recoup keeps working your catalog every week.", - thumbUrl: WELCOME_EMAIL_CAST[4].albumCoverUrl, - thumbShape: "square", - thumbAlt: WELCOME_EMAIL_CAST[4].albumName, + linkText: "Setup your first task.", + linkPath: "/setup/tasks", + imageUrl: "https://i.scdn.co/image/ab67616d00001e02793523735641c057708528fe", + imageStyle: "square", + imageAlt: "Album cover", }, ]; From 002197bec68f2c52413603d5f8af0e68fc0c970d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Jul 2026 11:36:19 -0500 Subject: [PATCH 4/6] fix(emails): add trailing period to 'See your baseline valuation.' link for consistency --- lib/emails/welcome/welcomeOnboardingSteps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/emails/welcome/welcomeOnboardingSteps.ts b/lib/emails/welcome/welcomeOnboardingSteps.ts index 67e0f9f4c..a1ab6d2bf 100644 --- a/lib/emails/welcome/welcomeOnboardingSteps.ts +++ b/lib/emails/welcome/welcomeOnboardingSteps.ts @@ -61,7 +61,7 @@ export const WELCOME_ONBOARDING_STEPS: WelcomeStep[] = [ title: "See your baseline valuation", description: "Get what your catalog is worth today. It is the number every weekly report moves.", - linkText: "See your baseline valuation", + linkText: "See your baseline valuation.", linkPath: "/setup/valuation", imageUrl: "https://i.scdn.co/image/ab67616d00001e024aafdbad18bc27d7c429cdf1", imageStyle: "square", From 75f7038c362898bf915dd9a36f001459d021adbb Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Jul 2026 11:47:10 -0500 Subject: [PATCH 5/6] fix(emails): welcome links use fixed CHAT_APP_URL, not derived deployment URL getFrontendBaseUrl falls back to the deployment's own VERCEL_URL on previews, so /setup links pointed at the api preview origin. Use CHAT_APP_URL (like the valuation email) so links always resolve to chat.recoupable.dev. --- lib/emails/__tests__/buildWelcomeEmail.test.ts | 14 +++++--------- lib/emails/buildWelcomeEmail.ts | 14 ++++++++------ 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/lib/emails/__tests__/buildWelcomeEmail.test.ts b/lib/emails/__tests__/buildWelcomeEmail.test.ts index ea88d0de2..ef4cdc9f5 100644 --- a/lib/emails/__tests__/buildWelcomeEmail.test.ts +++ b/lib/emails/__tests__/buildWelcomeEmail.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; - -const mockGetFrontendBaseUrl = vi.fn(); -vi.mock("@/lib/composio/getFrontendBaseUrl", () => ({ - getFrontendBaseUrl: (...args: unknown[]) => mockGetFrontendBaseUrl(...args), -})); +import { CHAT_APP_URL } from "@/lib/const"; const mockGetEmailFooter = vi.fn(); vi.mock("@/lib/emails/getEmailFooter", () => ({ @@ -15,7 +11,6 @@ const { buildWelcomeEmail } = await import("../buildWelcomeEmail"); describe("buildWelcomeEmail", () => { beforeEach(() => { vi.clearAllMocks(); - mockGetFrontendBaseUrl.mockReturnValue("https://chat.example.com"); mockGetEmailFooter.mockReturnValue("
reply note
"); }); @@ -25,10 +20,11 @@ describe("buildWelcomeEmail", () => { expect(subject).toBe("Welcome to Recoup"); }); - it("points the primary CTA at the setup flow", () => { + it("points the primary CTA at the fixed chat-app setup flow", () => { const { html } = buildWelcomeEmail(); - expect(html).toContain('href="https://chat.example.com/setup"'); + expect(html).toContain(`href="${CHAT_APP_URL}/setup"`); + expect(CHAT_APP_URL).toBe("https://chat.recoupable.dev"); expect(html.toLowerCase()).toContain("valuation"); }); @@ -75,7 +71,7 @@ describe("buildWelcomeEmail", () => { "/setup/valuation", "/setup/tasks", ]) { - expect(html).toContain(`href="https://chat.example.com${path}"`); + expect(html).toContain(`href="${CHAT_APP_URL}${path}"`); } }); diff --git a/lib/emails/buildWelcomeEmail.ts b/lib/emails/buildWelcomeEmail.ts index 81fd0398a..642295ca7 100644 --- a/lib/emails/buildWelcomeEmail.ts +++ b/lib/emails/buildWelcomeEmail.ts @@ -1,5 +1,4 @@ -import { RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const"; -import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; +import { CHAT_APP_URL, RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const"; import { getEmailFooter } from "@/lib/emails/getEmailFooter"; import { renderWelcomeSteps } from "@/lib/emails/welcome/renderWelcomeSteps"; @@ -14,12 +13,15 @@ const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif"; * * House style follows DESIGN.md / the valuation email: achromatic chrome * (#0a0a0a on #ffffff, #e8e8e8 borders, #6b6b6b muted), tables + inline styles - * only, system font stack, fixed CTA to the chat app. Copy avoids em/en dashes. + * only, system font stack. 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, + * including from preview test sends. * * @returns The email subject and HTML body. */ export function buildWelcomeEmail(): { subject: string; html: string } { - const baseUrl = getFrontendBaseUrl(); const footer = getEmailFooter(); const html = `
@@ -33,8 +35,8 @@ export function buildWelcomeEmail(): { subject: string; html: string } { Recoup

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(baseUrl)} -
Confirm your roster →
+${renderWelcomeSteps(CHAT_APP_URL)} +
Confirm your roster →
${footer} `; From 58ec6ebfac7e50d2a804bf4a14a9ffcce9381dcb Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Jul 2026 16:03:36 -0500 Subject: [PATCH 6/6] =?UTF-8?q?refactor(emails):=20address=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20extract=20imageTag,=20reuse=20generic=20selectEm?= =?UTF-8?q?ailSendLog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SRP: move the step-thumbnail helper into lib/emails/welcome/imageTag.ts - KISS/DRY: drop the bespoke selectWelcomeEmailLog; reuse the generic selectEmailSendLog (from api#773), extended with an optional accountId filter for the per-account welcome dedup. Deletes selectWelcomeEmailLog.ts + test. 265 lib/emails+supabase+accounts+privy tests pass; tsc + lint + format clean. --- lib/emails/__tests__/sendWelcomeEmail.test.ts | 15 +++-- lib/emails/sendWelcomeEmail.ts | 14 ++++- lib/emails/welcome/imageTag.ts | 17 ++++++ lib/emails/welcome/renderWelcomeSteps.ts | 13 +---- .../__tests__/selectEmailSendLog.test.ts | 4 +- .../__tests__/selectWelcomeEmailLog.test.ts | 58 ------------------- .../email_send_log/selectEmailSendLog.ts | 3 + .../email_send_log/selectWelcomeEmailLog.ts | 30 ---------- 8 files changed, 45 insertions(+), 109 deletions(-) create mode 100644 lib/emails/welcome/imageTag.ts delete mode 100644 lib/supabase/email_send_log/__tests__/selectWelcomeEmailLog.test.ts delete mode 100644 lib/supabase/email_send_log/selectWelcomeEmailLog.ts diff --git a/lib/emails/__tests__/sendWelcomeEmail.test.ts b/lib/emails/__tests__/sendWelcomeEmail.test.ts index a9af9e3ea..ef919a446 100644 --- a/lib/emails/__tests__/sendWelcomeEmail.test.ts +++ b/lib/emails/__tests__/sendWelcomeEmail.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextResponse } from "next/server"; import { RECOUP_FROM_EMAIL } from "@/lib/const"; -const mockSelectWelcomeEmailLog = vi.fn(); -vi.mock("@/lib/supabase/email_send_log/selectWelcomeEmailLog", () => ({ - selectWelcomeEmailLog: (...args: unknown[]) => mockSelectWelcomeEmailLog(...args), +const mockSelectEmailSendLog = vi.fn(); +vi.mock("@/lib/supabase/email_send_log/selectEmailSendLog", () => ({ + selectEmailSendLog: (...args: unknown[]) => mockSelectEmailSendLog(...args), })); const mockBuildWelcomeEmail = vi.fn(); @@ -29,7 +29,7 @@ describe("sendWelcomeEmail", () => { beforeEach(() => { vi.clearAllMocks(); - mockSelectWelcomeEmailLog.mockResolvedValue(null); + mockSelectEmailSendLog.mockResolvedValue([]); mockBuildWelcomeEmail.mockReturnValue({ subject: "Welcome to Recoup", html: "

hi

" }); mockSendEmailWithResend.mockResolvedValue({ id: "re_1" }); mockLogEmailAttempt.mockResolvedValue(undefined); @@ -59,10 +59,15 @@ describe("sendWelcomeEmail", () => { }); it("skips the send when a welcome email was already sent for the account", async () => { - mockSelectWelcomeEmailLog.mockResolvedValue({ id: "log-1" }); + mockSelectEmailSendLog.mockResolvedValue([{ id: "log-1" }]); await sendWelcomeEmail({ accountId: "acc-1", email: "new@example.com" }); + expect(mockSelectEmailSendLog).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "acc-1", status: "sent" }), + ); + const filters = mockSelectEmailSendLog.mock.calls[0][0]; + expect(filters.rawBodyLike).toContain('"type":"welcome_email"'); expect(mockSendEmailWithResend).not.toHaveBeenCalled(); expect(mockLogEmailAttempt).not.toHaveBeenCalled(); }); diff --git a/lib/emails/sendWelcomeEmail.ts b/lib/emails/sendWelcomeEmail.ts index c8c4e8f9b..7fb9f3df1 100644 --- a/lib/emails/sendWelcomeEmail.ts +++ b/lib/emails/sendWelcomeEmail.ts @@ -3,7 +3,7 @@ import { RECOUP_FROM_EMAIL, WELCOME_EMAIL_LOG_TYPE } from "@/lib/const"; import { buildWelcomeEmail } from "@/lib/emails/buildWelcomeEmail"; import { sendEmailWithResend } from "@/lib/emails/sendEmail"; import { logEmailAttempt } from "@/lib/emails/logEmailAttempt"; -import { selectWelcomeEmailLog } from "@/lib/supabase/email_send_log/selectWelcomeEmailLog"; +import { selectEmailSendLog } from "@/lib/supabase/email_send_log/selectEmailSendLog"; /** * Sends the one-time welcome email to a newly created account and records the @@ -24,8 +24,16 @@ export async function sendWelcomeEmail({ email: string; }): Promise { try { - const alreadySent = await selectWelcomeEmailLog(accountId); - if (alreadySent) { + // Idempotency: a prior sent welcome for this account is marked by the + // `"type":"welcome_email"` marker in raw_body (send_failed rows don't match, + // so a failed welcome can retry). Reuses the generic email_send_log reader. + const alreadySent = await selectEmailSendLog({ + accountId, + status: "sent", + rawBodyLike: `"type":"${WELCOME_EMAIL_LOG_TYPE}"`, + limit: 1, + }); + if (alreadySent.length > 0) { return; } diff --git a/lib/emails/welcome/imageTag.ts b/lib/emails/welcome/imageTag.ts new file mode 100644 index 000000000..f01981535 --- /dev/null +++ b/lib/emails/welcome/imageTag.ts @@ -0,0 +1,17 @@ +import { escapeHtml } from "@/lib/emails/escapeHtml"; + +/** + * Inline-style a welcome-email step thumbnail by kind: + * - "wide": the overlapping-avatar strip (~3.9:1) — fixed width, height auto + * keeps the ratio; + * - "square"/"rounded": a 56x56 cover (album art vs the IG post frame). + */ +export function imageTag(url: string, style: "wide" | "square" | "rounded", alt: string): string { + const src = escapeHtml(url); + const a = escapeHtml(alt); + if (style === "wide") { + return `${a}`; + } + const radius = style === "rounded" ? "12px" : "8px"; + return `${a}`; +} diff --git a/lib/emails/welcome/renderWelcomeSteps.ts b/lib/emails/welcome/renderWelcomeSteps.ts index 2f76cc468..082dc4a33 100644 --- a/lib/emails/welcome/renderWelcomeSteps.ts +++ b/lib/emails/welcome/renderWelcomeSteps.ts @@ -1,18 +1,7 @@ import { escapeHtml } from "@/lib/emails/escapeHtml"; +import { imageTag } from "@/lib/emails/welcome/imageTag"; import { WELCOME_ONBOARDING_STEPS } from "@/lib/emails/welcome/welcomeOnboardingSteps"; -/** Inline-style the step thumbnail by kind (overlap strip vs album cover vs IG). */ -function imageTag(url: string, style: "wide" | "square" | "rounded", alt: string): string { - const src = escapeHtml(url); - const a = escapeHtml(alt); - if (style === "wide") { - // Overlapping-avatar strip (~3.9:1); fixed width, height auto keeps the ratio. - return `${a}`; - } - const radius = style === "rounded" ? "12px" : "8px"; - return `${a}`; -} - /** * Render the numbered onboarding steps. Each row is an art thumbnail beside the * step number, title, one-line description, and an inline link into the matching diff --git a/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts b/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts index 97283808d..89e178df2 100644 --- a/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts +++ b/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts @@ -20,17 +20,19 @@ function mockBuilder(result: { data: unknown; error: unknown }) { describe("selectEmailSendLog", () => { beforeEach(() => vi.clearAllMocks()); - it("applies the status, raw_body-substring, and limit filters", async () => { + it("applies the account_id, status, raw_body-substring, and limit filters", async () => { const rows = [{ id: "log_1", status: "sent" }]; const builder = mockBuilder({ data: rows, error: null }); const result = await selectEmailSendLog({ + accountId: "acc_1", status: "sent", rawBodyLike: '"snapshot_id":"snap_1"', limit: 1, }); expect(supabase.from).toHaveBeenCalledWith("email_send_log"); + expect(builder.eq).toHaveBeenCalledWith("account_id", "acc_1"); expect(builder.eq).toHaveBeenCalledWith("status", "sent"); expect(builder.like).toHaveBeenCalledWith("raw_body", '%"snapshot_id":"snap_1"%'); expect(builder.limit).toHaveBeenCalledWith(1); diff --git a/lib/supabase/email_send_log/__tests__/selectWelcomeEmailLog.test.ts b/lib/supabase/email_send_log/__tests__/selectWelcomeEmailLog.test.ts deleted file mode 100644 index 2a748538b..000000000 --- a/lib/supabase/email_send_log/__tests__/selectWelcomeEmailLog.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -const mockMaybeSingle = vi.fn(); -const mockLimit = vi.fn(() => ({ maybeSingle: mockMaybeSingle })); -const mockLike = vi.fn(() => ({ limit: mockLimit })); -const mockEqStatus = vi.fn(() => ({ like: mockLike })); -const mockEqAccount = vi.fn(() => ({ eq: mockEqStatus })); -const mockSelect = vi.fn(() => ({ eq: mockEqAccount })); -const mockFrom = vi.fn((_table: string) => ({ select: mockSelect })); - -vi.mock("../../serverClient", () => ({ - default: { from: (table: string) => mockFrom(table) }, -})); - -const { selectWelcomeEmailLog } = await import("../selectWelcomeEmailLog"); - -describe("selectWelcomeEmailLog", () => { - let errorSpy: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - mockMaybeSingle.mockResolvedValue({ data: null, error: null }); - errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - }); - - it("queries email_send_log for a sent welcome email of the account", async () => { - await selectWelcomeEmailLog("acc-1"); - - expect(mockFrom).toHaveBeenCalledWith("email_send_log"); - expect(mockEqAccount).toHaveBeenCalledWith("account_id", "acc-1"); - expect(mockEqStatus).toHaveBeenCalledWith("status", "sent"); - expect(mockLike).toHaveBeenCalledWith("raw_body", '%"type":"welcome_email"%'); - expect(mockLimit).toHaveBeenCalledWith(1); - }); - - it("returns the matching row when a welcome email was already sent", async () => { - mockMaybeSingle.mockResolvedValue({ data: { id: "log-1" }, error: null }); - - const result = await selectWelcomeEmailLog("acc-1"); - - expect(result).toEqual({ id: "log-1" }); - }); - - it("returns null when no welcome email has been sent", async () => { - const result = await selectWelcomeEmailLog("acc-1"); - - expect(result).toBeNull(); - }); - - it("returns null and logs on database error", async () => { - mockMaybeSingle.mockResolvedValue({ data: null, error: { message: "boom" } }); - - const result = await selectWelcomeEmailLog("acc-1"); - - expect(result).toBeNull(); - expect(errorSpy).toHaveBeenCalled(); - }); -}); diff --git a/lib/supabase/email_send_log/selectEmailSendLog.ts b/lib/supabase/email_send_log/selectEmailSendLog.ts index 0c458ada2..0c80bd7ab 100644 --- a/lib/supabase/email_send_log/selectEmailSendLog.ts +++ b/lib/supabase/email_send_log/selectEmailSendLog.ts @@ -3,6 +3,8 @@ import type { Tables } from "@/types/database.types"; /** Optional filters for a generic `email_send_log` read. */ export interface SelectEmailSendLogFilters { + /** Exact match on `account_id` — for per-account dedup checks. */ + accountId?: string; /** Exact match on `status` (e.g. "sent"). */ status?: string; /** Substring matched within `raw_body` (LIKE `%value%`) — pass the marker the send was keyed on. */ @@ -24,6 +26,7 @@ export async function selectEmailSendLog( ): Promise[]> { let query = supabase.from("email_send_log").select("*"); + if (filters.accountId) query = query.eq("account_id", filters.accountId); if (filters.status) query = query.eq("status", filters.status); if (filters.rawBodyLike) query = query.like("raw_body", `%${filters.rawBodyLike}%`); if (filters.limit) query = query.limit(filters.limit); diff --git a/lib/supabase/email_send_log/selectWelcomeEmailLog.ts b/lib/supabase/email_send_log/selectWelcomeEmailLog.ts deleted file mode 100644 index 30428fb2b..000000000 --- a/lib/supabase/email_send_log/selectWelcomeEmailLog.ts +++ /dev/null @@ -1,30 +0,0 @@ -import supabase from "../serverClient"; -import { WELCOME_EMAIL_LOG_TYPE } from "@/lib/const"; - -/** - * Looks up a previously *sent* welcome email for an account in - * `email_send_log` (identified by the `"type":"welcome_email"` marker in - * `raw_body`). Used as the idempotency guard so a welcome email is never - * sent twice to the same account. Failed attempts don't match, so a - * `send_failed` welcome can be retried. - * - * @param accountId - The account to check. - * @returns The matching log row id, or null when none exists (or on error). - */ -export async function selectWelcomeEmailLog(accountId: string): Promise<{ id: string } | null> { - const { data, error } = await supabase - .from("email_send_log") - .select("id") - .eq("account_id", accountId) - .eq("status", "sent") - .like("raw_body", `%"type":"${WELCOME_EMAIL_LOG_TYPE}"%`) - .limit(1) - .maybeSingle(); - - if (error) { - console.error("Error fetching welcome email log:", error); - return null; - } - - return data ?? null; -}