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..ef4cdc9f5 --- /dev/null +++ b/lib/emails/__tests__/buildWelcomeEmail.test.ts @@ -0,0 +1,87 @@ +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 { buildWelcomeEmail } = await import("../buildWelcomeEmail"); + +describe("buildWelcomeEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetEmailFooter.mockReturnValue(""); + }); + + it("returns the welcome subject", () => { + const { subject } = buildWelcomeEmail(); + + expect(subject).toBe("Welcome to Recoup"); + }); + + it("points the primary CTA at the fixed chat-app setup flow", () => { + const { html } = buildWelcomeEmail(); + + expect(html).toContain(`href="${CHAT_APP_URL}/setup"`); + expect(CHAT_APP_URL).toBe("https://chat.recoupable.dev"); + expect(html.toLowerCase()).toContain("valuation"); + }); + + it("appends the standard email footer without a room link", () => { + const { html } = buildWelcomeEmail(); + + expect(mockGetEmailFooter).toHaveBeenCalledWith(); + expect(html).toContain(""); + }); + + it("contains no em or en dashes in outward-facing copy", () => { + const { subject, html } = 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("links each step into its /setup route", () => { + const { html } = buildWelcomeEmail(); + + for (const path of [ + "/setup/artists", + "/setup/socials", + "/setup/catalog", + "/setup/valuation", + "/setup/tasks", + ]) { + expect(html).toContain(`href="${CHAT_APP_URL}${path}"`); + } + }); + + 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/__tests__/sendWelcomeEmail.test.ts b/lib/emails/__tests__/sendWelcomeEmail.test.ts new file mode 100644 index 000000000..ef919a446 --- /dev/null +++ b/lib/emails/__tests__/sendWelcomeEmail.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { RECOUP_FROM_EMAIL } from "@/lib/const"; + +const mockSelectEmailSendLog = vi.fn(); +vi.mock("@/lib/supabase/email_send_log/selectEmailSendLog", () => ({ + selectEmailSendLog: (...args: unknown[]) => mockSelectEmailSendLog(...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(); + mockSelectEmailSendLog.mockResolvedValue([]); + 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 () => { + 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(); + }); + + 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..642295ca7 --- /dev/null +++ b/lib/emails/buildWelcomeEmail.ts @@ -0,0 +1,45 @@ +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"; + +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. + * + * 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 footer = getEmailFooter(); + + 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.

+${renderWelcomeSteps(CHAT_APP_URL)} +
Confirm your roster →
+${footer} +
+
`; + + return { subject: "Welcome to Recoup", html }; +} diff --git a/lib/emails/sendWelcomeEmail.ts b/lib/emails/sendWelcomeEmail.ts new file mode 100644 index 000000000..7fb9f3df1 --- /dev/null +++ b/lib/emails/sendWelcomeEmail.ts @@ -0,0 +1,59 @@ +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 { selectEmailSendLog } from "@/lib/supabase/email_send_log/selectEmailSendLog"; + +/** + * 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 { + // 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; + } + + 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/emails/welcome/__tests__/renderWelcomeSteps.test.ts b/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts new file mode 100644 index 000000000..cc6f318bf --- /dev/null +++ b/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts @@ -0,0 +1,40 @@ +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 image and link", () => { + const html = renderWelcomeSteps(BASE); + + WELCOME_ONBOARDING_STEPS.forEach((step, i) => { + expect(html).toContain(`${i + 1}. ${step.title}`); + expect(html).toContain(step.imageUrl); + expect(html).toContain(`href="${BASE}${step.linkPath}"`); + expect(html).toContain(`>${step.linkText}`); + }); + }); + + 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); + + 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/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 new file mode 100644 index 000000000..082dc4a33 --- /dev/null +++ b/lib/emails/welcome/renderWelcomeSteps.ts @@ -0,0 +1,27 @@ +import { escapeHtml } from "@/lib/emails/escapeHtml"; +import { imageTag } from "@/lib/emails/welcome/imageTag"; +import { WELCOME_ONBOARDING_STEPS } from "@/lib/emails/welcome/welcomeOnboardingSteps"; + +/** + * 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(baseUrl: string): string { + const rows = WELCOME_ONBOARDING_STEPS.map((step, i) => { + const href = escapeHtml(`${baseUrl}${step.linkPath}`); + const link = `${escapeHtml(step.linkText)}`; + return ` +${imageTag(step.imageUrl, step.imageStyle, step.imageAlt)} + +

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

+

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

+ +`; + }).join(""); + + return `${rows}
`; +} diff --git a/lib/emails/welcome/welcomeOnboardingSteps.ts b/lib/emails/welcome/welcomeOnboardingSteps.ts new file mode 100644 index 000000000..a1ab6d2bf --- /dev/null +++ b/lib/emails/welcome/welcomeOnboardingSteps.ts @@ -0,0 +1,79 @@ +/** + * 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 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; + /** 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.", + 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.", + 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.", + 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.", + 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.", + linkText: "Setup your first task.", + linkPath: "/setup/tasks", + imageUrl: "https://i.scdn.co/image/ab67616d00001e02793523735641c057708528fe", + imageStyle: "square", + imageAlt: "Album cover", + }, +]; 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__/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/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);