/** * Transactional email over the Resend HTTP API. * * Deliberately no SDK. This template is copied into other people's * repositories, so every dependency added here is one the operator inherits, * has to audit, and has to keep patched. Sending one message is a single POST * with a JSON body; `resend` and `@react-email/components` would buy us * nothing that `fetch` does not already do, and `@react-email/components` * would additionally drag a server-side React render into a Cloudflare Worker * request path. The same reasoning put Stripe.js on a CDN rather than in * package.json. * * The cost of that choice is honest: no typed client, and the field names * below are the raw API's snake_case (`content_id`, not the SDK's * `contentId`). They are pinned by tests. */ import brand from "@/brand.config"; const RESEND_API = "https://api.resend.com"; /** * Resend rejects any request without a User-Agent with a 403 that looks * exactly like a bad key. Workers do not set one, so we always do. */ const USER_AGENT = "carrier-storefront/1.0"; /** One file travelling with the message. Inline when `content_id` is set. */ export interface EmailAttachment { filename: string; /** Base64, no data: prefix. */ content: string; content_type?: string; /** * Makes this attachment addressable from the HTML as `cid:`. * * This is the whole reason the QR is an attachment rather than a `data:` * URI in an ``. Gmail strips `data:` image sources, so a `data:` * QR renders as a broken image for a large share of buyers — and a broken * QR in a "your eSIM is ready" email is a support ticket, every time. */ content_id?: string; } export interface EmailMessage { to: string; subject: string; html: string; text?: string; attachments?: EmailAttachment[]; } /** * Why mail cannot be sent, when it cannot. * * "unconfigured" is a normal state, not a fault: a storefront with no Resend * key still sells eSIMs, and every caller must treat this as a thing to * mention rather than a thing to fail on. */ export type EmailUnavailable = "unconfigured" | "no-from-address"; export type EmailSendResult = | { ok: true; id?: string } | { ok: false; reason: EmailUnavailable | "rejected" | "network-error"; detail?: string }; /** Server-side only. Never reached from a client bundle, so never inlined. */ function apiKey(): string { return process.env.RESEND_API_KEY?.trim() ?? ""; } export function emailConfigured(): boolean { return apiKey().length > 0; } /** * The address this storefront sends from, composed from the operator's own * mailbox and their own domain. * * It is composed rather than stored whole on purpose. A literal from-address * in a white-label template drifts onto a domain the operator does not own the * first time somebody copies the file, and mail from a domain you cannot add * SPF and DKIM records to does not arrive. There is no default: if the brand * has no mailbox or no domain, this returns null and the caller says email is * not configured rather than sending from somebody else's domain. */ export function fromAddress( config: { emailFromName?: string; emailFromMailbox?: string; domain?: string } = brand, ): string | null { const mailbox = config.emailFromMailbox?.trim(); const domain = config.domain?.trim().replace(/^https?:\/\//, "").replace(/\/+$/, ""); if (!mailbox || !domain) return null; const address = `${mailbox}@${domain}`; const name = config.emailFromName?.trim(); return name ? `${name} <${address}>` : address; } /** * Send one message. Never throws: a storefront that cannot send an email must * still be able to finish taking an order. */ export async function sendEmail(message: EmailMessage): Promise { const key = apiKey(); if (!key) return { ok: false, reason: "unconfigured" }; const from = fromAddress(); if (!from) return { ok: false, reason: "no-from-address" }; try { const res = await fetch(`${RESEND_API}/emails`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "User-Agent": USER_AGENT, }, body: JSON.stringify({ from, to: [message.to], subject: message.subject, html: message.html, ...(message.text ? { text: message.text } : {}), ...(message.attachments?.length ? { attachments: message.attachments } : {}), }), }); if (!res.ok) { const detail = (await res.text().catch(() => "")).slice(0, 300); return { ok: false, reason: "rejected", detail: `HTTP ${res.status} ${detail}`.trim() }; } const body = (await res.json().catch(() => ({}))) as { id?: string }; return { ok: true, id: body.id }; } catch (e: unknown) { return { ok: false, reason: "network-error", detail: e instanceof Error ? e.message : String(e), }; } }