Skip to content
28 changes: 28 additions & 0 deletions lib/accounts/__tests__/createAccountHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down
7 changes: 7 additions & 0 deletions lib/accounts/createAccountHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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 });

Copy link
Copy Markdown

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
Check if this issue is valid — if so, understand the root cause and fix it. At lib/accounts/createAccountHandler.ts, line 100:

<comment>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.</comment>

<file context>
@@ -93,6 +94,12 @@ export async function createAccountHandler(body: CreateAccountBody): Promise<Nex
+    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 });
+    }
+
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/accounts/createAccountHandler.ts, line 100:

<comment>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.</comment>

<file context>
@@ -93,6 +94,12 @@ export async function createAccountHandler(body: CreateAccountBody): Promise<Nex
+    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 });
+    }
+
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At lib/accounts/createAccountHandler.ts, line 100:

<comment>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.</comment>

<file context>
@@ -93,6 +94,12 @@ export async function createAccountHandler(body: CreateAccountBody): Promise<Nex
+    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 });
+    }
+
</file context>

}
Comment on lines +97 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

  • lib/accounts/createAccountHandler.ts#L97-L101: dispatch welcome delivery asynchronously or through an outbox before returning the account response.
  • lib/privy/getOrCreateAccountIdByAuthToken.ts#L27-L33: apply the same non-blocking delivery mechanism during Privy provisioning.
📍 Affects 2 files
  • lib/accounts/createAccountHandler.ts#L97-L101 (this comment)
  • lib/privy/getOrCreateAccountIdByAuthToken.ts#L27-L33
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/accounts/createAccountHandler.ts` around lines 97 - 101, Remove
synchronous email delivery waits from both account-creation paths: in
lib/accounts/createAccountHandler.ts lines 97-101 and
lib/privy/getOrCreateAccountIdByAuthToken.ts lines 27-33, dispatch
sendWelcomeEmail through the same non-blocking mechanism or an outbox before
returning. Preserve the existing email guards and best-effort, never-throw
behavior while ensuring account creation does not await Resend/Supabase
operations.


const newAccountData: AccountDataResponse = {
id: newAccount.id,
account_id: newAccount.id,
Expand Down
3 changes: 3 additions & 0 deletions lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.dev";
/** Default from address for outbound emails */
export const RECOUP_FROM_EMAIL = `Agent by Recoup <agent${OUTBOUND_EMAIL_DOMAIN}>`;

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

/**
* Generic message returned for every POST /api/agents/signup response,
* regardless of which branch (new agent+, existing account, new normal
Expand Down
87 changes: 87 additions & 0 deletions lib/emails/__tests__/buildWelcomeEmail.test.ts
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");
});
});
96 changes: 96 additions & 0 deletions lib/emails/__tests__/sendWelcomeEmail.test.ts
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();
});
});
45 changes: 45 additions & 0 deletions lib/emails/buildWelcomeEmail.ts
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">

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Future email-template changes can diverge because this renderer duplicates the complete card/header shell already implemented in renderValuationReportHtml. A shared email-shell/header renderer would keep the two templates consistent while leaving welcome-specific content here.

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

<comment>Future email-template changes can diverge because this renderer duplicates the complete card/header shell already implemented in `renderValuationReportHtml`. A shared email-shell/header renderer would keep the two templates consistent while leaving welcome-specific content here.</comment>

<file context>
@@ -1,35 +1,45 @@
-  </p>
-  ${footer}
-</div>`.trim();
+  const html = `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f7f7f7;padding:24px 0"><tr><td align="center">
+<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border:1px solid #e8e8e8;border-radius:16px">
+<tr><td style="padding:32px 32px 28px;font-family:${FONT}">
</file context>

<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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The welcome copy renders five step as an unhyphenated compound adjective. Using five-step keeps this user-facing sentence grammatically correct.

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

<comment>The welcome copy renders `five step` as an unhyphenated compound adjective. Using `five-step` keeps this user-facing sentence grammatically correct.</comment>

<file context>
@@ -1,35 +1,45 @@
+</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>
+${renderWelcomeCastStrip()}
+${renderWelcomeSteps()}
</file context>
Suggested change
<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>
<p style="margin:0 0 24px;font-size:14px;line-height:1.6;color:#0a0a0a">Recoup is your AI team for the music business. Here is the five-step path from a new account to a catalog you measure, grow, and automate.</p>

${renderWelcomeSteps(CHAT_APP_URL)}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:16px 0 8px"><tr><td style="background:#0a0a0a;border-radius:8px"><a href="${CHAT_APP_URL}/setup" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:12px 22px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none">Confirm your roster &rarr;</a></td></tr></table>
${footer}
</td></tr></table>
</td></tr></table>`;

return { subject: "Welcome to Recoup", html };
}
59 changes: 59 additions & 0 deletions lib/emails/sendWelcomeEmail.ts
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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 sent row, causing duplicate welcome emails. An atomic per-account claim/idempotency operation before sending would prevent both calls from reaching Resend.

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

<comment>Concurrent calls can both pass the lookup before either call records its `sent` row, causing duplicate welcome emails. An atomic per-account claim/idempotency operation before sending would prevent both calls from reaching Resend.</comment>

<file context>
@@ -0,0 +1,51 @@
+    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],
</file context>

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);
}
}
Loading
Loading