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"); + } + }); +});