From 7e665ea43eb811d2717ec2cdf8f54e59f43028c2 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 17 Jun 2026 12:10:04 -0600 Subject: [PATCH] Email service surfaces real Resend errors instead of silent false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sendEmail / sendCampaignEmail / sendWelcomeEmail / sendOrderReceiptEmail / sendPasswordResetEmail / sendOperationalAlert helpers all returned Promise and silently returned false on any failure (missing API key, 401 invalid key, 422 unverified sender domain, network error, etc.). The caller had no way to know which problem it was. Switch the public return type to a structured result: export type EmailSendResult = { ok: true } | { ok: false; error: string }; - Missing RESEND_API_KEY: error reads 'RESEND_API_KEY is not set. Add it to .env.local (or your hosting dashboard) and restart the server.' - 4xx/5xx: error reads 'Resend 422 — validation_error: The gmail.com domain is not verified' (extracts Resend's own name + message). - Network failure: error reads the thrown message verbatim. - Non-JSON error body: falls back to 'Resend '. Callers updated: - src/actions/admin/users.ts (createAdminUser): now propagates the real emailError into the modal UI — no more generic 'sendWelcome Email returned false'. - src/actions/checkout.ts: logs the real error. - src/app/api/cron/send-scheduled/route.ts: per-recipient error logged for the scheduled-campaigns cron. Tests: - tests/unit/email-service.test.ts (new, 5 tests): covers happy path, missing key, 4xx/5xx with JSON body, 4xx/5xx with non-JSON body, and network failure. - tests/unit/create-admin-user.test.ts: updated to use the new { ok, error? } return shape; new assertion that the action surfaces the real emailError string into the result. --- src/actions/admin/users.ts | 4 +- src/actions/checkout.ts | 5 +- src/app/api/cron/send-scheduled/route.ts | 12 +- src/lib/email-service.ts | 48 ++++++-- tests/unit/create-admin-user.test.ts | 5 +- tests/unit/email-service.test.ts | 134 +++++++++++++++++++++++ 6 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 tests/unit/email-service.test.ts diff --git a/src/actions/admin/users.ts b/src/actions/admin/users.ts index efada25..6c1f00d 100644 --- a/src/actions/admin/users.ts +++ b/src/actions/admin/users.ts @@ -134,7 +134,7 @@ async function sendWelcomeEmailSafe(input: { } } - const ok = await sendWelcomeEmail({ + const result = await sendWelcomeEmail({ to: input.to, name: input.name, role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee", @@ -142,7 +142,7 @@ async function sendWelcomeEmailSafe(input: { tempPassword: input.password, logoUrl: logoUrl ?? undefined, }); - return ok ? { sent: true } : { sent: false, error: "sendWelcomeEmail returned false" }; + return result.ok ? { sent: true } : { sent: false, error: result.error }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error("[createAdminUser] Welcome email failed (non-fatal):", msg); diff --git a/src/actions/checkout.ts b/src/actions/checkout.ts index c56c92a..6cfde9b 100644 --- a/src/actions/checkout.ts +++ b/src/actions/checkout.ts @@ -128,7 +128,7 @@ export async function createOrder( } } - await sendOrderReceiptEmail({ + const result = await sendOrderReceiptEmail({ customerName, customerEmail, orderId: data.id, @@ -149,6 +149,9 @@ export async function createOrder( brandName: "Tuxedo Corn", logoUrl, }); + if (!result.ok) { + console.error("[checkout] Order receipt email failed:", result.error); + } } catch { // Email failure should not fail the order } diff --git a/src/app/api/cron/send-scheduled/route.ts b/src/app/api/cron/send-scheduled/route.ts index 16e9434..799705b 100644 --- a/src/app/api/cron/send-scheduled/route.ts +++ b/src/app/api/cron/send-scheduled/route.ts @@ -68,13 +68,19 @@ export async function GET(request: Request) { for (const contact of contacts) { if (!contact.email) continue; - const ok = await sendCampaignEmail({ + const result = await sendCampaignEmail({ to: contact.email, subject: campaign.subject ?? campaign.name, html: campaign.body_html, }); - if (ok) sent++; - else errors++; + if (result.ok) sent++; + else { + errors++; + console.error( + `[cron/send-scheduled] campaign ${campaign.id} contact ${contact.email}:`, + result.error, + ); + } } // 3. Mark campaign as sent diff --git a/src/lib/email-service.ts b/src/lib/email-service.ts index 727fa11..a310aab 100644 --- a/src/lib/email-service.ts +++ b/src/lib/email-service.ts @@ -7,11 +7,23 @@ * Env vars required: * RESEND_API_KEY — Resend API key * FROM_EMAIL — e.g. "Tuxedo Corn " + * + * Every public send function returns `{ ok, error? }` so callers can + * surface the real reason a send failed (missing API key, Resend 401 + * invalid key, 422 unverified sender domain, network error, etc.) to + * operators instead of seeing a silent `false`. Previously the + * helpers returned `Promise` and swallowed every error. */ const FROM_EMAIL = process.env.FROM_EMAIL ?? "Tuxedo Corn "; const RESEND_API_KEY = process.env.RESEND_API_KEY ?? ""; +/** Structured result for every public send function. */ +export type EmailSendResult = { ok: true } | { ok: false; error: string }; + +const OK: EmailSendResult = { ok: true }; +const fail = (error: string): EmailSendResult => ({ ok: false, error }); + // ───────────────────────────────────────────────────────────────────────────── // Shared email send function // ───────────────────────────────────────────────────────────────────────────── @@ -22,9 +34,11 @@ async function sendEmail(opts: { html: string; text?: string; from?: string; -}): Promise { +}): Promise { if (!RESEND_API_KEY) { - return false; + return fail( + "RESEND_API_KEY is not set. Add it to .env.local (or your hosting dashboard) and restart the server.", + ); } try { @@ -44,12 +58,26 @@ async function sendEmail(opts: { }); if (!res.ok) { - return false; + // Resend returns a JSON body like { "name": "...", "message": "..." } + // for non-2xx responses. Surface it verbatim so the operator + // sees the actual reason (e.g. "You can only send testing emails + // to your own email address" or "Domain is not verified"). + let detail = ""; + try { + const body = (await res.json()) as { name?: string; message?: string; statusCode?: number }; + const parts: string[] = []; + if (body.name) parts.push(body.name); + if (body.message) parts.push(body.message); + detail = parts.length > 0 ? ` — ${parts.join(": ")}` : ""; + } catch { + // body wasn't JSON; fall through + } + return fail(`Resend ${res.status}${detail}`); } - return res.ok; + return OK; } catch (e) { - return false; + return fail(e instanceof Error ? e.message : String(e)); } } @@ -64,7 +92,7 @@ export type CampaignEmailData = { from?: string; }; -export async function sendCampaignEmail(data: CampaignEmailData): Promise { +export async function sendCampaignEmail(data: CampaignEmailData): Promise { return sendEmail({ to: data.to, subject: data.subject, @@ -101,7 +129,7 @@ export type OrderReceiptData = { logoUrl?: string | null; }; -export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise { +export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise { const pickupInfo = data.fulfillment === "pickup" && data.stopDate; const itemRows = data.items .map( @@ -260,7 +288,7 @@ export type WelcomeEmailData = { logoUrl?: string | null; }; -export async function sendWelcomeEmail(data: WelcomeEmailData): Promise { +export async function sendWelcomeEmail(data: WelcomeEmailData): Promise { const roleLabel = data.role === "brand_admin" ? "Admin" : data.role === "wholesale_buyer" ? "Wholesale Buyer" @@ -380,7 +408,7 @@ export type PasswordResetData = { logoUrl?: string | null; }; -export async function sendPasswordResetEmail(data: PasswordResetData): Promise { +export async function sendPasswordResetEmail(data: PasswordResetData): Promise { const html = ` @@ -453,7 +481,7 @@ export type OperationalAlertData = { actionLabel?: string; }; -export async function sendOperationalAlert(data: OperationalAlertData): Promise { +export async function sendOperationalAlert(data: OperationalAlertData): Promise { const severityConfig = { info: { bg: "#1e3a5f", label: "Notice" }, warning: { bg: "#b45309", label: "Warning" }, diff --git a/tests/unit/create-admin-user.test.ts b/tests/unit/create-admin-user.test.ts index ff4d639..6e587be 100644 --- a/tests/unit/create-admin-user.test.ts +++ b/tests/unit/create-admin-user.test.ts @@ -110,7 +110,7 @@ beforeEach(() => { vi.clearAllMocks(); // Default: platform admin caller, no-op fetch, happy email path. mockGetAdminUser.mockResolvedValue(PLATFORM_ADMIN); - mockSendWelcomeEmail.mockResolvedValue(true); + mockSendWelcomeEmail.mockResolvedValue({ ok: true }); mockGetBrandSettings.mockResolvedValue({ success: true, settings: { brand_name: "Tuxedo Citrus", logo_url: null }, @@ -350,12 +350,13 @@ describe("createAdminUser — email is best-effort", () => { }), } as never), ); - mockSendWelcomeEmail.mockResolvedValue(false); + mockSendWelcomeEmail.mockResolvedValue({ ok: false, error: "Resend 422: domain not verified" }); const r = await createAdminUser(baseInput); expect(r.error).toBeNull(); expect(r.user).not.toBeNull(); expect(r.emailSent).toBe(false); + expect(r.emailError).toBe("Resend 422: domain not verified"); expect(r.tempPassword).toBe(baseInput.password); }); diff --git a/tests/unit/email-service.test.ts b/tests/unit/email-service.test.ts new file mode 100644 index 0000000..8c36682 --- /dev/null +++ b/tests/unit/email-service.test.ts @@ -0,0 +1,134 @@ +/** + * Unit tests for the email service's structured `{ ok, error? }` return. + * + * The shared `sendEmail` helper is private, but `sendWelcomeEmail` + * calls it and is exported, so we exercise the happy path + the two + * new error paths (no API key, Resend 4xx/5xx with JSON body) through + * the public surface. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const originalFetch = global.fetch; +const originalKey = process.env.RESEND_API_KEY; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + global.fetch = originalFetch; + if (originalKey === undefined) { + delete process.env.RESEND_API_KEY; + } else { + process.env.RESEND_API_KEY = originalKey; + } +}); + +async function importFresh() { + // Re-import the module after toggling RESEND_API_KEY / mocking fetch + // so the module-level constants pick up the new env. + vi.resetModules(); + return await import("@/lib/email-service"); +} + +describe("sendWelcomeEmail — structured error reporting", () => { + it("returns ok=true when Resend accepts the message", async () => { + process.env.RESEND_API_KEY = "re_test_key"; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: "email-abc" }), + } as Response); + const { sendWelcomeEmail } = await importFresh(); + const r = await sendWelcomeEmail({ + to: "u@example.com", + name: "U", + role: "brand_admin", + brandName: "Tuxedo", + tempPassword: "TempPass123!", + }); + expect(r.ok).toBe(true); + }); + + it("returns a clear error when RESEND_API_KEY is missing", async () => { + delete process.env.RESEND_API_KEY; + const { sendWelcomeEmail } = await importFresh(); + const r = await sendWelcomeEmail({ + to: "u@example.com", + name: "U", + role: "brand_admin", + brandName: "Tuxedo", + tempPassword: "TempPass123!", + }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toMatch(/RESEND_API_KEY is not set/i); + } + }); + + it("surfaces Resend's actual error message on 4xx/5xx", async () => { + process.env.RESEND_API_KEY = "re_test_key"; + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 422, + json: async () => ({ + name: "validation_error", + message: "The gmail.com domain is not verified", + }), + } as Response); + const { sendWelcomeEmail } = await importFresh(); + const r = await sendWelcomeEmail({ + to: "u@gmail.com", + name: "U", + role: "brand_admin", + brandName: "Tuxedo", + tempPassword: "TempPass123!", + }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain("Resend 422"); + expect(r.error).toContain("validation_error"); + expect(r.error).toContain("gmail.com domain is not verified"); + } + }); + + it("surfaces the Resend message even when the response body is not JSON", async () => { + process.env.RESEND_API_KEY = "re_test_key"; + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => { + throw new Error("not json"); + }, + } as unknown as Response); + const { sendWelcomeEmail } = await importFresh(); + const r = await sendWelcomeEmail({ + to: "u@example.com", + name: "U", + role: "brand_admin", + brandName: "Tuxedo", + tempPassword: "TempPass123!", + }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toBe("Resend 500"); + } + }); + + it("surfaces network errors verbatim", async () => { + process.env.RESEND_API_KEY = "re_test_key"; + global.fetch = vi.fn().mockRejectedValue(new Error("fetch failed: ECONNREFUSED")); + const { sendWelcomeEmail } = await importFresh(); + const r = await sendWelcomeEmail({ + to: "u@example.com", + name: "U", + role: "brand_admin", + brandName: "Tuxedo", + tempPassword: "TempPass123!", + }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toBe("fetch failed: ECONNREFUSED"); + } + }); +});