This page shows you how Litestore sends email through Resend and how operators can replace the built-in templates with their own.
Litestore sends every email through Resend. Read this page when you add a new email, change how one is sent, or need to find out why a message did or did not arrive.
All sending goes through lib/email.ts. By default, Litestore renders each email with one of the 26 React Email components in emails/. For six of the queued email types, an operator can edit an EmailTemplate row in the database, and Litestore then uses that row instead of the component.
You must set RESEND_API_KEY and RESEND_SENDER_EMAIL in env.ts. Customers sign in with a link that Litestore emails to them, so if Resend is not configured, nobody can log in.
The three ways to send
lib/email.ts exports three functions. No other code sends mail.
sendEmail
sendEmail uses @react-email/components to turn a React element into HTML and plain text. Then it calls resend.emails.send.
It waits for Resend to answer, so you use it when the sender needs to know right away whether it worked. The admin compose form and test sends use it.
sendRawEmail
sendRawEmail sends in the same way, but it takes HTML that is already rendered. Database templates go out through this function.
It is the one place that sets these parts of a send:
- the
fromline - the guard that stops sending in development
- the unsubscribe headers
- the idempotency key
Keeping them here means no one has to write them again next to each resend.emails.send call.
queueEmail
queueEmail publishes an email/send Inngest event and lets a background function do the sending.
You use it for transactional and automated mail. It is also the only path that checks for a database template.
What they have in common
sendEmail and sendRawEmail both check isProd first. Outside production they log the recipient, the subject and the length of the rendered email. Then they return undefined and never contact Resend.
queueEmail is designed to fail quietly. If publishing to Inngest throws, for example because there is no event key locally or Inngest is down, it logs the error and returns instead of throwing.
It works this way because the database change that triggered the email is already saved. Failing the whole action at that point would tell the user it failed when it did not.
How Litestore picks a template
The Inngest function in functions/email.send.ts looks for a database template first. If there is none, it uses the React component:
const dbTemplateType = EMAIL_TYPE_MAP[type]
if (dbTemplateType) {
const variables = propsToVariables(type, { ...sendProps, to })
const templateSlug = EMAIL_TEMPLATE_SLUG_MAP[type]
const dbTemplate = await renderDatabaseTemplate(dbTemplateType, variables, to, templateSlug)
if (dbTemplate) { /* sendRawEmail */ }
}
// Fallback to React component
const react = await getEmailComponent(type, { ...sendProps, to, subject })EMAIL_TYPE_MAP in lib/email-template-constants.ts has six entries:
welcome
magic_link
order_confirmation
collab_approved
collab_rejected
team_invitationIf a queued type is not in that map, Litestore never looks in the database for it. It renders the React component with the subject the caller passed in.
notification is left out of the map on purpose.
It used to map to OTHER, and the default template for OTHER is "Review Approved". As a result, refund, shipment, cancellation, abandoned-cart and return emails all went out saying "Your review has been published", and nothing reported an error.
Placeholders
A database template is plain HTML with {{variable}} placeholders in it.
renderTemplateContent fills them in. If a key is unknown or misspelled, it becomes an empty string. A customer never sees raw text like {{order_number}}.
wrapEmailHtml wraps the content in the shared layout with the logo, signature and footer. htmlToPlainText then builds the plain-text version from the HTML.
When two templates are both the default
getEmailTemplate finds the default template for a type.
The schema does not stop two rows of the same type from both having isDefault set. To make the result predictable, the query sorts the rows. Without the sort, whichever row was edited last could win.
return db.emailTemplate.findFirst({
where: { type, isActive: true, isDefault: true },
orderBy: [{ updatedAt: "desc" }, { createdAt: "asc" }, { id: "asc" }],
})The proper fix is a unique constraint on (type, isDefault). It is planned but not built. The code comment at lib/email-templates.ts:167 says so, and there is no migration for it yet. Until there is one, the sort order above decides which template wins.
Why templates have slugs
Some templates share a type, so Litestore needs EMAIL_TEMPLATE_SLUG_MAP to tell them apart.
For example, AFFILIATE_SUBMISSION is the type behind both collab-approved and collab-rejected. If Litestore looked up the template by type alone, a rejected applicant could receive the approval email.
When the caller passes a slug and no active row has that slug, getEmailTemplate returns null. The caller then uses the React component.
It does not fall back to the type's default template on purpose, because that default would be the wrong email.
The seeded templates
DEFAULT_TEMPLATES in lib/email-template-constants.ts creates seven rows when the database is seeded.
Operators can edit them in Admin → Emails. EmailTemplateCRUD.beforeDelete refuses to delete any row that has isDefault set.
| Slug | Template type | Sent when |
|---|---|---|
welcome | WELCOME | A new customer signs up |
magic-link | MAGIC_LINK | Passwordless sign-in |
order-confirmation | ORDER_CONFIRMATION | An order is paid |
collab-approved | AFFILIATE_SUBMISSION | A collab application is approved |
collab-rejected | AFFILIATE_SUBMISSION | A collab application is rejected |
shipping-update | SHIPPING_UPDATE | Operator-authored — no send path queues it |
team-invitation | TEAM_INVITATION | An admin invites a team member |
EMAIL_TEMPLATE_TYPES declares nine keys. MARKETING and NEWSLETTER have no seeded row, and operators can use them for templates they write themselves.
To add a type, you add a key and an entry in TEMPLATE_VARIABLE_SCHEMAS. The database column is a plain string, so you don't need a migration.
SHIPPING_UPDATE is a valid type, but nothing sends it any more. The order_shipped email renders emails/order-shipped.tsx directly, and the shipping_update entry was removed from EMAIL_TYPE_MAP.
So if you edit that seeded template, nothing changes until you add a queued type that maps to it.
The queue function
sendEmailFunction handles the email/send event.
retries: 3
concurrency: { limit: 10 }It handles the 22 queued types in QueuedEmailType. getEmailComponent returns a component for each one.
Idempotency
buildEmailIdempotencyKey builds a Resend idempotency key for every send:
email:<type>:<ref>For ref, it uses a record id from the props when one exists: orderId, refundId, collabCode, reviewId or invitationId. Otherwise it combines the recipient and the subject.
When Inngest retries the same event, the key comes out the same. Resend then ignores the second send instead of delivering the email twice.
When a send fails
onFailure runs once all retries have failed.
It is the only code that records the email_failed activity type, and the emailsFailing operator task counts those records. It also writes a final EmailLog row with status: "failed".
Two types without their own component
refund_processed and notification both render emails/alert-notification.tsx. Litestore builds a title and a severity for them.
Dedicated templates for the customer-facing notification events are planned but not shipped.
Marketing mail and unsubscribes
queueEmail takes a classification:
classification: "transactional" | "marketing"Litestore only ever holds back marketing mail. Before it publishes the event, queueEmail looks up the Customer by email address. If unsubscribedAt is set, it returns without sending.
Transactional mail is always sent.
For a marketing send, the Inngest function gets a signed (HMAC) unsubscribe URL from buildUnsubscribeUrl and adds the RFC 8058 headers:
unsubscribeHeaders = {
"List-Unsubscribe": `<${unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}It also passes the URL to the template as the unsubscribeUrl prop. React templates show it as a link in the footer, and database templates can use it as {{unsubscribeUrl}}.
Litestore can only create this link when the recipient is a known Customer row.
Bounces and complaints from Resend
app/api/webhooks/resend/route.ts receives delivery events from Resend.
Resend signs these events with Svix. The route checks three headers against RESEND_WEBHOOK_SECRET:
svix-id
svix-timestamp
svix-signatureIf the secret is not set, the route returns 503. If the signature is wrong, it returns 401.
The route acts only on email.bounced and email.complained.
A complaint always unsubscribes the customer. A bounce unsubscribes them too, unless Resend marks it as Transient or Undetermined. In that case Litestore only logs it and the customer stays subscribed, because a full mailbox should not permanently cut off a buyer you can still reach.
To unsubscribe someone, Litestore sets Customer.unsubscribedAt with an updateMany that only touches rows where unsubscribedAt is still null. This way a replayed event never overwrites an earlier unsubscribe the customer chose themselves. Afterwards Litestore clears the customers cache.
Every event, whether or not it unsubscribes anyone, adds an email_bounced activity row.
Litestore has no list of blocked addresses of its own. Transactional mail to an address that hard-bounced keeps being sent, and Resend blocks hard bounces on its side.
The send log
recordEmailLog writes one EmailLog row when a send finishes, either sent or failed. Each email gets one row, not one per retry.
The row records:
emailType- recipient
- subject
- the Resend id
- an optional
entityType/entityIdlink to a record
That link to a record is what makes the log useful. An operator can ask "was the pickup-ready email for order #123 sent?" without searching by subject text.
No admin screen reads EmailLog. The only code that queries it is the winback step in functions/cron.index-data.ts. That step checks the log so it doesn't email the same lapsed customer again within 180 days.
functions/cron.data-cleanup.ts deletes rows older than 180 days, 1,000 at a time.
The activity feed is the record of consent, not EmailLog.
Broadcasts
functions/email.broadcast.ts handles the email/broadcast event that the admin compose flow sends.
It adds no schema and reuses the existing sending code:
broadcastAudienceWhereturns a lifecycle segment into a list of recipients.buildCustomEmailrenders the same element that the one-off compose form uses.sendEmailsends it.
It sends in batches of 25 with Promise.allSettled, and each recipient gets their own idempotency key:
broadcast:<broadcastId>:<customerId>If the function retries, nobody gets the email twice. A new broadcast still always goes out.
Configuration
RESEND_API_KEY is required. The Resend client in services/resend.ts uses it.
RESEND_SENDER_EMAIL is required. It is the from address, and env.ts checks that it is a valid email.
RESEND_WEBHOOK_SECRET is optional. The Resend webhook route uses it to verify Svix signatures. If you leave it unset, that route returns 503.
services/resend.ts is four lines long and creates the client with new Resend(env.RESEND_API_KEY).
To replace Resend, you edit that file and the two resend.emails.send calls in lib/email.ts.
Which send path to use
Use queueEmail for any email a customer gets because something happened. It retries, it uses the idempotency key to avoid duplicates, and it writes an EmailLog row. It is also the only path where an operator can replace the template from Admin → Emails.
Use sendEmail when a person is waiting for the result and can react to a failure right away, as in the admin compose form and test sends.
Use sendRawEmail only when you already have rendered HTML. In practice, only the database-template path does.
If the email is marketing, set classification: "marketing". Litestore then checks for an unsubscribe and adds the RFC 8058 headers. If it is transactional, leave the default and it will always be sent.
When you add a new queued type, start by asking:
Should an operator be able to change this email's wording without a deploy?
If yes, the type needs an EMAIL_TYPE_MAP entry and a template type. If several emails share that template type, it also needs an EMAIL_TEMPLATE_SLUG_MAP entry. If no, a React component in emails/ is all you need.
Related
Webhooks
See how Litestore signs and retries outgoing deliveries, when it turns an endpoint off, and how the incoming Stripe and Resend routes differ.
Events
See how the 56 domain events publish through Inngest, how handlers avoid doing work twice, and why delivery is best-effort.
Providers
See which external services you can swap, which are wired in directly, and which environment variables the app needs to start.