Email service surfaces real Resend errors instead of silent false
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
The sendEmail / sendCampaignEmail / sendWelcomeEmail /
sendOrderReceiptEmail / sendPasswordResetEmail / sendOperationalAlert
helpers all returned Promise<boolean> 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 <status>'.
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.
This commit is contained in:
+38
-10
@@ -7,11 +7,23 @@
|
||||
* Env vars required:
|
||||
* RESEND_API_KEY — Resend API key
|
||||
* FROM_EMAIL — e.g. "Tuxedo Corn <no-reply@tuxedocorn.com>"
|
||||
*
|
||||
* 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<boolean>` and swallowed every error.
|
||||
*/
|
||||
|
||||
const FROM_EMAIL = process.env.FROM_EMAIL ?? "Tuxedo Corn <no-reply@tuxedocorn.com>";
|
||||
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<boolean> {
|
||||
}): Promise<EmailSendResult> {
|
||||
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<boolean> {
|
||||
export async function sendCampaignEmail(data: CampaignEmailData): Promise<EmailSendResult> {
|
||||
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<boolean> {
|
||||
export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<EmailSendResult> {
|
||||
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<boolean> {
|
||||
export async function sendWelcomeEmail(data: WelcomeEmailData): Promise<EmailSendResult> {
|
||||
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<boolean> {
|
||||
export async function sendPasswordResetEmail(data: PasswordResetData): Promise<EmailSendResult> {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -453,7 +481,7 @@ export type OperationalAlertData = {
|
||||
actionLabel?: string;
|
||||
};
|
||||
|
||||
export async function sendOperationalAlert(data: OperationalAlertData): Promise<boolean> {
|
||||
export async function sendOperationalAlert(data: OperationalAlertData): Promise<EmailSendResult> {
|
||||
const severityConfig = {
|
||||
info: { bg: "#1e3a5f", label: "Notice" },
|
||||
warning: { bg: "#b45309", label: "Warning" },
|
||||
|
||||
Reference in New Issue
Block a user