Acme Store Docs
Features

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:

RouteTriggerEmails sentTemplate
POST /api/quotes/notifyShopper submits a quote from the cartAdmin alert and customer confirmationQuoteEmailTemplate
POST /api/notifications/statusAdmin changes a quote/order statusSingle customer emailStatusUpdateEmailTemplate

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.

VariableRequired for emailDefaultPurpose
RESEND_API_KEYYes(empty → email disabled)Resend API key. Read server-side only. Leave empty to disable email entirely.
RESEND_FROM_EMAILNoonboarding@resend.devVerified sender address for all outgoing mail.
QUOTE_NOTIFICATION_EMAILNositeConfig.contact.emailInbox that receives the new-quote admin alert.
.env.example
# 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:

Terminal
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
RESEND_FROM_EMAIL="Acme Store <quotes@yourdomain.com>"
QUOTE_NOTIFICATION_EMAIL=sales@yourdomain.com

Restart 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 alertQUOTE_NOTIFICATION_EMAIL (falls back to siteConfig.contact.email), subject New quote request <quoteNumber>. Rendered with audience: "admin" — includes the full customer contact block (name, company, email, phone).
  • Customer confirmationcustomer.email, subject We received your quote request <quoteNumber>. Rendered with audience: "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.

FieldTypeRules
quoteNumberstringrequired, 1–100 chars
fallbackobjectoptional — email content below, used only by credential-free servers
fallback.customer.firstNamestringoptional, ≤ 200
fallback.customer.lastNamestringoptional, ≤ 200
fallback.customer.emailstringrequired, valid email, ≤ 320
fallback.customer.phonestringoptional, ≤ 50
fallback.customer.companyNamestringoptional, ≤ 200
fallback.itemsarrayrequired, 1–200 items
fallback.items[].idstringoptional, ≤ 200
fallback.items[].productNamestringrequired, 1–500
fallback.items[].brandstringoptional, ≤ 200
fallback.items[].variantNamestringoptional, ≤ 200
fallback.items[].quantitynumberrequired, positive integer, ≤ 1,000,000
fallback.items[].pricenumberoptional, finite, ≥ 0
fallback.messagestringoptional, ≤ 5000
fallback.discount.codestringrequired within discount, 1–50
fallback.discount.amountnumberrequired 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
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

Email configured
{ "sent": 2, "failed": 0 }
Email disabled (no RESEND_API_KEY)
{ "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.

StatusBodyWhen
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.

kindRecognised statuses with custom copy
quoteQuoted, Accepted, Declined
orderShipped, 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.

FieldTypeRules
kindstringrequired, "quote" or "order"
referenceNumberstringrequired, 1–100
fallbackobjectoptional — email content below, used only by credential-free servers
fallback.statusstringrequired, 1–50
fallback.customer.emailstringrequired, valid email, ≤ 320
fallback.customer.firstNamestringoptional, ≤ 200
fallback.customer.lastNamestringoptional, ≤ 200
fallback.itemCountnumberoptional, positive integer, ≤ 10,000
fallback.totalnumberoptional, finite, ≥ 0

Example request

curl
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

Delivered
{ "sent": 1, "failed": 0 }
Email disabled (no RESEND_API_KEY)
{ "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.

StatusBodyWhen
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).

RouteLimitWindow
POST /api/quotes/notify5 requests10 minutes
POST /api/notifications/status20 requests10 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).

  • QuoteEmailTemplate takes an audience prop 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.
  • StatusUpdateEmailTemplate renders 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.

On this page