/** * Resend Email Service — Route Commerce / Tuxedo Corn * * Branded transactional email templates for Tuxedo Corn storefront. * All emails use the Olathe Sweet + Tuxedo Corn brand identity. * * 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 // ───────────────────────────────────────────────────────────────────────────── async function sendEmail(opts: { to: string; subject: string; html: string; text?: string; from?: string; }): Promise { if (!RESEND_API_KEY) { return fail( "RESEND_API_KEY is not set. Add it to .env.local (or your hosting dashboard) and restart the server.", ); } try { const res = await fetch("https://api.resend.com/emails", { method: "POST", headers: { Authorization: `Bearer ${RESEND_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ from: opts.from ?? FROM_EMAIL, to: opts.to, subject: opts.subject, html: opts.html, text: opts.text ?? opts.subject, }), }); if (!res.ok) { // 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 OK; } catch (e) { return fail(e instanceof Error ? e.message : String(e)); } } // ───────────────────────────────────────────────────────────────────────────── // Generic campaign email — used by the scheduled-campaigns cron // ───────────────────────────────────────────────────────────────────────────── export type CampaignEmailData = { to: string; subject: string; html: string; from?: string; }; export async function sendCampaignEmail(data: CampaignEmailData): Promise { return sendEmail({ to: data.to, subject: data.subject, html: data.html, text: data.subject, from: data.from, }); } // ───────────────────────────────────────────────────────────────────────────── // Order Receipt — sent to customer after successful checkout // ───────────────────────────────────────────────────────────────────────────── export type OrderReceiptData = { customerName: string; customerEmail: string; orderId: string; items: Array<{ name: string; quantity: number; price: number; }>; subtotal: number; taxAmount: number; total: number; fulfillment: "pickup" | "ship"; stopCity?: string; stopState?: string; stopDate?: string; stopTime?: string; stopLocation?: string; brandName?: string; /** Public URL for the brand logo (e.g. from brand_settings.logo_url). */ logoUrl?: string | null; }; export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise { const pickupInfo = data.fulfillment === "pickup" && data.stopDate; const itemRows = data.items .map( (item) => ` ${item.name} × ${item.quantity} $${(item.price * item.quantity).toFixed(2)} ` ) .join(""); const pickupBlock = pickupInfo ? `

Pickup Details

${data.stopCity}, ${data.stopState}

${data.stopDate}${data.stopTime ? ` · ${data.stopTime}` : ""}

${data.stopLocation ? `

${data.stopLocation}

` : ""}
` : `

Shipping to your door — cooler boxes ship after the season ends.

`; const logoHeader = data.logoUrl ? `${data.brandName ?? ` : data.brandName ? `

${data.brandName}

` : ""; const logoCallout = data.logoUrl ? `${data.brandName ?? ` : ""; const html = ` Order Confirmed — ${data.brandName ?? "Tuxedo Corn"}
${logoHeader}

Order Confirmed

Order #${data.orderId.slice(0, 8).toUpperCase()}

Hi ${data.customerName.split(" ")[0]},

Great news — your order is confirmed and we're getting it ready. ${pickupInfo ? "Your corn will be hand-picked fresh the morning of your stop." : "We'll ship your cooler boxes after the season ends."}

${itemRows}
Item Total
Subtotal $${data.subtotal.toFixed(2)}
${data.taxAmount > 0 ? `
Tax $${data.taxAmount.toFixed(2)}
` : ""}
Total Paid $${data.total.toFixed(2)}
${pickupBlock} ${logoCallout || data.brandName ? `
${logoCallout}

${data.brandName ? `Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct` : ""}

` : ""}

Questions? Reply to this email or contact us at hello@tuxedocorn.com

© ${new Date().getFullYear()} ${data.brandName ?? "Tuxedo Corn"} · 59751 David Road, Olathe, CO 81425

`; const text = `Order Confirmed — Tuxedo Corn (Order #${data.orderId.slice(0, 8).toUpperCase()}) Hi ${data.customerName.split(" ")[0]}, Your order is confirmed.${pickupInfo ? `\n\nPickup: ${data.stopCity}, ${data.stopState}\nDate: ${data.stopDate}${data.stopTime ? ` at ${data.stopTime}` : ""}\nLocation: ${data.stopLocation ?? ""}` : "\n\nShipping: Cooler boxes ship after the season ends."} Items: ${data.items.map((i) => ` ${i.name} × ${i.quantity} — $${(i.price * i.quantity).toFixed(2)}`).join("\n")} Subtotal: $${data.subtotal.toFixed(2)}${data.taxAmount > 0 ? `\nTax: $${data.taxAmount.toFixed(2)}` : ""} Total: $${data.total.toFixed(2)} Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct Questions? Reply to this email or contact hello@tuxedocorn.com`; return sendEmail({ to: data.customerEmail, subject: `Your Order is Confirmed — Tuxedo Corn #${data.orderId.slice(0, 8).toUpperCase()}`, html, text, }); } // ───────────────────────────────────────────────────────────────────────────── // Welcome Email — sent to new admin or wholesale users // ───────────────────────────────────────────────────────────────────────────── export type WelcomeEmailData = { to: string; name: string; role: "brand_admin" | "wholesale_buyer" | "store_employee"; brandName?: string; tempPassword?: string; setupUrl?: string; /** Public URL for the brand logo (e.g. from brand_settings.logo_url). */ logoUrl?: string | null; }; export async function sendWelcomeEmail(data: WelcomeEmailData): Promise { const roleLabel = data.role === "brand_admin" ? "Admin" : data.role === "wholesale_buyer" ? "Wholesale Buyer" : "Store Employee"; const brandBlock = data.brandName ? `

Brand: ${data.brandName}

` : ""; const nextSteps = data.role === "brand_admin" ? `

Your first steps

  1. Visit /admin/settings/brand to add your company info and logos
  2. Add your first product under Products
  3. Create a stop under Tours & Stops
  4. Configure payments under Settings → Payments
` : data.role === "wholesale_buyer" ? `

What you can do

  • Browse and order products at your assigned wholesale pricing
  • Track your order history and upcoming deliveries
  • Manage billing with net-30 terms (if approved)
` : ""; const logoHeader = data.logoUrl ? `${data.brandName ?? ` : data.brandName ? `

${data.brandName}

` : ""; const html = ` Welcome to ${data.brandName ?? "Route Commerce"}
${logoHeader}

Welcome, ${data.name.split(" ")[0]}

Your ${roleLabel} account is ready

${brandBlock}

Your account has been created on the Route Commerce platform and is now active for ${data.brandName ?? "this brand"}.

${nextSteps}

Need help? Reply to this email or reach us at hello@tuxedocorn.com — we respond same business day.

© ${new Date().getFullYear()} ${data.brandName ?? "Route Commerce"} · Route Commerce Platform

`; const text = `Welcome to Tuxedo Corn, ${data.name.split(" ")[0]} Your ${roleLabel} account is now active${data.brandName ? ` for ${data.brandName}` : ""}. ${data.role === "brand_admin" ? ` First steps: 1. Visit /admin/settings/brand — add company info and logos 2. Add your first product 3. Create a stop under Tours & Stops 4. Configure payments under Settings → Payments ` : data.role === "wholesale_buyer" ? ` You can now: - Browse and order products at your wholesale pricing - Track your order history and upcoming deliveries - Manage billing with net-30 terms (if approved) ` : ""} Need help? Reply to this email or contact hello@tuxedocorn.com `; return sendEmail({ to: data.to, subject: `Welcome to Tuxedo Corn — ${roleLabel} Account Ready`, html, text, }); } // ───────────────────────────────────────────────────────────────────────────── // Password Reset Email — triggered by Supabase auth (custom template via Resend) // Note: Supabase handles the reset flow by default; this can be used as a // custom template if you configure Resend as the SMTP provider in Supabase. // ───────────────────────────────────────────────────────────────────────────── export type PasswordResetData = { to: string; name: string; resetUrl: string; /** Public URL for the brand logo (e.g. from brand_settings.logo_url). */ logoUrl?: string | null; }; export async function sendPasswordResetEmail(data: PasswordResetData): Promise { const html = ` Reset Your Password
${data.logoUrl ? `` : ""}

Reset Your Password

Hi ${data.name.split(" ")[0]}, we received a request to reset your password. Click the button below to set a new password. This link expires in 1 hour.

If you didn't request this, you can safely ignore this email.
This link expires in 1 hour and can only be used once.

© ${new Date().getFullYear()} Tuxedo Corn · Route Commerce Platform

`; const text = `Reset Your Password — Tuxedo Corn Hi ${data.name.split(" ")[0]}, We received a request to reset your password. Click the link below to set a new password: ${data.resetUrl} This link expires in 1 hour and can only be used once. If you didn't request this, you can safely ignore this email.`; return sendEmail({ to: data.to, subject: "Reset Your Password — Tuxedo Corn", html, text, }); } // ───────────────────────────────────────────────────────────────────────────── // Operational Alert — future-proof alert for low stock, system events, etc. // ───────────────────────────────────────────────────────────────────────────── export type OperationalAlertData = { to: string; severity: "info" | "warning" | "critical"; title: string; message: string; brandName?: string; actionUrl?: string; actionLabel?: string; }; export async function sendOperationalAlert(data: OperationalAlertData): Promise { const severityConfig = { info: { bg: "#1e3a5f", label: "Notice" }, warning: { bg: "#b45309", label: "Warning" }, critical: { bg: "#991b1b", label: "Critical" }, }; const cfg = severityConfig[data.severity] ?? severityConfig.info; const brandBadge = data.brandName ? `${data.brandName}` : ""; const actionBlock = (data.actionUrl && data.actionLabel) ? `` : ""; const html = ` ${data.title}

${cfg.label}

${data.title}

${brandBadge}

${data.message}

${actionBlock}

Route Commerce Platform · Powered by Tuxedo Corn

`; const text = `[${cfg.label.toUpperCase()}] ${data.title} ${data.message} ${data.actionUrl ? `${data.actionLabel}: ${data.actionUrl}` : ""}`; return sendEmail({ to: data.to, subject: `[${cfg.label}] ${data.title}`, html, text, }); }