Transactional Emails
Optional Resend-powered quote and status notification emails.
The boilerplate can send two kinds of transactional email through Resend: a pair of quote-request notifications (an admin alert plus a customer confirmation) when a shopper submits a cart as a quote, and a status-change email when an admin moves a quote or order to a new status. Both are rendered with react-email templates.
Email is fully optional. The default store is quote-based and works end to end with no email provider configured. When RESEND_API_KEY is unset, the notification endpoints short-circuit with { skipped: true } and return 200, so nothing breaks — the quote is still created and the admin can still update statuses. Turn email on only if you want customers and staff to receive notifications.
How it fits together
There are two server routes, each backed by a react-email template:
| Route | Trigger | Emails sent | Template |
|---|---|---|---|
POST /api/quotes/notify | Shopper submits a quote from the cart | Admin alert and customer confirmation | QuoteEmailTemplate |
POST /api/notifications/status | Admin changes a quote/order status | Single customer email | StatusUpdateEmailTemplate |
Both routes are public, so they are rate-limited, validate their body strictly against a zod schema, and never echo request data back in error responses. The Resend client is instantiated lazily inside the handler (only after the key check passes) so builds and deployments without the env var succeed.
The client-side callers are fire-and-forget: the cart posts to /api/quotes/notify and ignores the outcome, and the admin pages call a helper that posts to /api/notifications/status in the background. Email delivery never blocks the underlying commerce action.
For the request/response contracts of these endpoints, see the notifications API reference.
Configuration
All configuration is via environment variables. See environment variables for the full list.
| Variable | Required for email | Default | Purpose |
|---|---|---|---|
RESEND_API_KEY | Yes | (empty → email disabled) | Resend API key. Read server-side only. Leave empty to disable email entirely. |
RESEND_FROM_EMAIL | No | onboarding@resend.dev | Verified sender address for all outgoing mail. |
QUOTE_NOTIFICATION_EMAIL | No | siteConfig.contact.email | Inbox that receives the new-quote admin alert. |
# Optional: quote notification emails via Resend (server-side only).
# Leave RESEND_API_KEY empty to disable emails entirely.
RESEND_API_KEY=
# Verified sender ("onboarding@resend.dev" works for testing).
RESEND_FROM_EMAIL=
# Inbox that receives new-quote notifications (defaults to the site contact email).
QUOTE_NOTIFICATION_EMAIL=The onboarding@resend.dev fallback sender is only meant for testing. For production, verify your own domain in Resend and set RESEND_FROM_EMAIL to an address on it, otherwise deliverability will suffer.
Enabling email
Create a Resend account and generate an API key.
(Recommended) Verify your sending domain in Resend so you can send from your own address.
Set the variables in your environment:
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
RESEND_FROM_EMAIL="Acme Store <quotes@yourdomain.com>"
QUOTE_NOTIFICATION_EMAIL=sales@yourdomain.comRestart the app. Submit a test quote from the cart, or change a status in the admin, and the corresponding emails go out. If RESEND_API_KEY is still empty, the endpoints respond { skipped: true, reason: "email not configured" } and no mail is sent.
Quote notifications — POST /api/quotes/notify
When a shopper submits their cart as a quote, Cart.tsx posts the quote payload to this endpoint. On success the route sends two emails via Promise.allSettled, both rendered from the same QuoteEmailTemplate component with a different audience prop:
- Admin alert →
QUOTE_NOTIFICATION_EMAIL(falls back tositeConfig.contact.email), subjectNew quote request <quoteNumber>. Rendered withaudience: "admin"— includes the full customer contact block (name, company, email, phone). - Customer confirmation →
customer.email, subjectWe received your quote request <quoteNumber>. Rendered withaudience: "customer"— a friendly summary of what they requested.
Request body
Validated against quoteNotifyRequestSchema in src/lib/email.ts. Sizes are capped because the endpoint is public. The body is an identifier plus an optional fallback content block: when the server has Firebase Admin credentials it looks the quote up in Firestore by quoteNumber and builds both emails from the persisted record (fallback is ignored; unknown numbers get a 404); without admin credentials it renders the validated fallback block instead. See SECURITY.md for the trust model.
| Field | Type | Rules |
|---|---|---|
quoteNumber | string | required, 1–100 chars |
fallback | object | optional — email content below, used only by credential-free servers |
fallback.customer.firstName | string | optional, ≤ 200 |
fallback.customer.lastName | string | optional, ≤ 200 |
fallback.customer.email | string | required, valid email, ≤ 320 |
fallback.customer.phone | string | optional, ≤ 50 |
fallback.customer.companyName | string | optional, ≤ 200 |
fallback.items | array | required, 1–200 items |
fallback.items[].id | string | optional, ≤ 200 |
fallback.items[].productName | string | required, 1–500 |
fallback.items[].brand | string | optional, ≤ 200 |
fallback.items[].variantName | string | optional, ≤ 200 |
fallback.items[].quantity | number | required, positive integer, ≤ 1,000,000 |
fallback.items[].price | number | optional, finite, ≥ 0 |
fallback.message | string | optional, ≤ 5000 |
fallback.discount.code | string | required within discount, 1–50 |
fallback.discount.amount | number | required within discount, finite, ≥ 0 |
Prices are optional throughout: quote-based catalogs may not carry prices, and the template renders an em dash where a price is absent.
Example request
curl -X POST http://localhost:3000/api/quotes/notify \
-H "Content-Type: application/json" \
-d '{
"quoteNumber": "Q-1042",
"fallback": {
"customer": {
"firstName": "Dana",
"lastName": "Reed",
"email": "dana@example.com",
"companyName": "Reed Interiors"
},
"items": [
{ "productName": "Studio Monitor", "brand": "Acme", "quantity": 2, "price": 199 }
],
"message": "Do you offer bulk pricing?",
"discount": { "code": "WELCOME10", "amount": 39.8 }
}
}'Responses
{ "sent": 2, "failed": 0 }{ "skipped": true, "reason": "email not configured" }sent and failed count the two messages independently — a partial failure (for example, the admin address bounces) still returns 200 with { "sent": 1, "failed": 1 }. Resend resolves with { data, error } rather than throwing, so an error on the resolved value is counted as a failure.
| Status | Body | When |
|---|---|---|
200 | { sent, failed } | Email configured; delivery attempted |
200 | { skipped: true, reason: "email not configured" } | RESEND_API_KEY unset |
400 | { error: "Invalid JSON body" } | Body is not valid JSON |
400 | { error: "Invalid request body" } | Fails schema validation, or no fallback on a credential-free server |
404 | { error: "Quote not found" } | Admin credentials configured; no persisted quote matches quoteNumber |
200 | { skipped: true, reason: "quote record is not emailable" } | Quote found but it has no valid customer email |
429 | { error: "Too many requests" } | Rate limit exceeded |
500 | { error: "Internal server error" } | Unexpected server error |
Status-change notifications — POST /api/notifications/status
When an admin updates a quote or order status (in admin/quotes or admin/orders), the page calls the sendStatusUpdateNotification helper from src/lib/email.ts. The helper is best-effort: it trims and plausibility-checks the customer email, silently does nothing when there is no valid-looking address, never throws, and never blocks the admin action. It then posts to this endpoint, which sends a single email rendered from StatusUpdateEmailTemplate.
The email copy adapts to kind and status. Recognised statuses get tailored subject and body copy; anything else falls back to a generic "status changed" message.
kind | Recognised statuses with custom copy |
|---|---|
quote | Quoted, Accepted, Declined |
order | Shipped, Delivered, Cancelled |
The subject line is Update on your <kind> <referenceNumber>: <status>, and the email links the customer to ${siteConfig.url}/account?tab=quotes or ?tab=orders depending on kind.
Request body
Validated against statusUpdateRequestSchema in src/lib/email.ts. Like the quote endpoint, the body is an identifier plus an optional fallback block: servers with Firebase Admin credentials look the quote/order up by kind + referenceNumber and build the email from the persisted record (recipient, current status, item count, total), ignoring fallback; credential-free servers render the validated fallback block.
| Field | Type | Rules |
|---|---|---|
kind | string | required, "quote" or "order" |
referenceNumber | string | required, 1–100 |
fallback | object | optional — email content below, used only by credential-free servers |
fallback.status | string | required, 1–50 |
fallback.customer.email | string | required, valid email, ≤ 320 |
fallback.customer.firstName | string | optional, ≤ 200 |
fallback.customer.lastName | string | optional, ≤ 200 |
fallback.itemCount | number | optional, positive integer, ≤ 10,000 |
fallback.total | number | optional, finite, ≥ 0 |
Example request
curl -X POST http://localhost:3000/api/notifications/status \
-H "Content-Type: application/json" \
-d '{
"kind": "quote",
"referenceNumber": "Q-1042",
"fallback": {
"status": "Quoted",
"customer": { "email": "dana@example.com", "firstName": "Dana" },
"itemCount": 2
}
}'Responses
{ "sent": 1, "failed": 0 }{ "skipped": true, "reason": "email not configured" }A delivery failure returns 200 with { "sent": 0, "failed": 1 } rather than an error, since the email is a side effect of an already-successful status update.
| Status | Body | When |
|---|---|---|
200 | { sent: 1, failed: 0 } | Delivered |
200 | { sent: 0, failed: 1 } | Send attempted but failed |
200 | { skipped: true, reason: "email not configured" } | RESEND_API_KEY unset |
400 | { error: "Invalid JSON body" } | Body is not valid JSON |
400 | { error: "Invalid request body" } | Fails schema validation, or no fallback on a credential-free server |
404 | { error: "Record not found" } | Admin credentials configured; no persisted quote/order matches |
200 | { skipped: true, reason: "record is not emailable" } | Record found but it has no valid customer email |
429 | { error: "Too many requests" } | Rate limit exceeded |
500 | { error: "Internal server error" } | Unexpected server error |
Rate limiting
Both routes use the in-process fixed-window limiter from src/lib/rate-limit.ts, keyed by client IP. When the limit is exceeded they return 429 with a Retry-After header (in seconds).
| Route | Limit | Window |
|---|---|---|
POST /api/quotes/notify | 5 requests | 10 minutes |
POST /api/notifications/status | 20 requests | 10 minutes |
The limiter state lives in a single process's memory, so limits are enforced per serverless instance and reset on restart. That is a fine abuse speed-bump for small deployments; for multi-instance production traffic, swap rateLimit() for a shared store such as Upstash/Redis.
The email templates
Both templates live in src/components/emailing and are built with react-email components (Html, Head, Body, Container, Tailwind, Text, Hr, Preview, and Link) styled with the react-email Tailwind wrapper. They pull the store name from siteConfig.name and format money with the shared formatMoney helper (rendering an em dash when a value is absent).
QuoteEmailTemplatetakes anaudienceprop of"admin"or"customer". The admin variant shows the full customer contact block and an internal call to action; the customer variant is a friendly confirmation. Both render a requested-products table (product, quantity, price, subtotal) and an optional discount line and notes section.StatusUpdateEmailTemplaterenders the adaptive heading/body from the status map, a reference/status summary with optional item count and total, and an "View in your account" link.
To preview or customise these, edit the .tsx files directly — they are plain React components. Changing copy, colours, or layout does not require touching the API routes.