-
Notifications
You must be signed in to change notification settings - Fork 10
feat(emails): send one-time welcome email on account creation #774
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2224a55
263654e
03b0014
c375e62
002197b
75f7038
58ec6eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Nex | |
|
|
||
| await initializeAccountCredits(newAccount.id); | ||
|
|
||
| if (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: newAccount.id, email }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Signup now waits for the Resend request and email-log write before returning, so an external email or database slowdown can make account creation slow or time out even though sending is best-effort. Dispatching through a background job/outbox would keep account creation independent of email-provider latency. Prompt for AI agentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Accounts whose later provisioning step fails can permanently miss the welcome email: the handler returns an error, but the next attempt sees the persisted email link and skips the creation branch. Trigger the welcome workflow after the email link is confirmed, using an outbox or retryable job so it is independent of later provisioning failures. Prompt for AI agents |
||
| } | ||
|
Comment on lines
+97
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Both account-creation paths synchronously await email delivery. This makes account creation latency and capacity dependent on external Resend/Supabase operations, contrary to the best-effort objective.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| const newAccountData: AccountDataResponse = { | ||
| id: newAccount.id, | ||
| account_id: newAccount.id, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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("<footer>reply note</footer>"); | ||
| }); | ||
|
|
||
| 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("<footer>reply note</footer>"); | ||
| }); | ||
|
|
||
| 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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof vi.spyOn>; | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| mockSelectEmailSendLog.mockResolvedValue([]); | ||
| mockBuildWelcomeEmail.mockReturnValue({ subject: "Welcome to Recoup", html: "<p>hi</p>" }); | ||
| 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: "<p>hi</p>", | ||
| }); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 = `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f7f7f7;padding:24px 0"><tr><td align="center"> | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Future email-template changes can diverge because this renderer duplicates the complete card/header shell already implemented in Prompt for AI agents |
||||||
| <table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border:1px solid #e8e8e8;border-radius:16px"> | ||||||
| <tr><td style="padding:32px 32px 28px;font-family:${FONT}"> | ||||||
| <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px"><tr> | ||||||
| <td valign="top"> | ||||||
| <p style="margin:0 0 6px;font-size:12px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#6b6b6b">Welcome to Recoup</p> | ||||||
| <h1 style="margin:0;font-size:24px;line-height:1.2;letter-spacing:-0.02em;color:#0a0a0a">You're in. Let's build your catalog value.</h1> | ||||||
| </td> | ||||||
| <td valign="top" align="right" width="44"><a href="${WEBSITE_URL}"><img src="${RECOUP_LOGO_URL}" width="36" height="36" alt="Recoup" style="display:block;width:36px;height:36px;border-radius:8px"/></a></td> | ||||||
| </tr></table> | ||||||
| <p style="margin:0 0 24px;font-size:14px;line-height:1.6;color:#0a0a0a">Recoup is your AI team for the music business. Here is the five step path from a new account to a catalog you measure, grow, and automate.</p> | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The welcome copy renders Prompt for AI agents
Suggested change
|
||||||
| ${renderWelcomeSteps(CHAT_APP_URL)} | ||||||
| <table role="presentation" cellpadding="0" cellspacing="0" style="margin:16px 0 8px"><tr><td style="background:#0a0a0a;border-radius:8px"><a href="${CHAT_APP_URL}/setup" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:12px 22px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none">Confirm your roster →</a></td></tr></table> | ||||||
| ${footer} | ||||||
| </td></tr></table> | ||||||
| </td></tr></table>`; | ||||||
|
|
||||||
| return { subject: "Welcome to Recoup", html }; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Concurrent calls can both pass the lookup before either call records its Prompt for AI agents |
||
| 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); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: Welcome emails can be sent to an email that was never linked to the new account. Checking the email-link insert result and resolving or aborting the failed create before sending would prevent orphan-account sends and duplicate welcomes from concurrent or retried requests.
Prompt for AI agents