Litestore · Developer guide
Platform services

Email

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.

Inbox
Sign in to Your StoreTo ava@example.com
now
YYour sign-in linkClick the button below to sign in. No password needed.Sign inDidn't ask for this? You can ignore this email.
A customer signs in by clicking the link that Litestore sends through Resend.

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 from line
  • 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:

functions/email.send.ts
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_invitation

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

lib/email-templates.ts
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.

SlugTemplate typeSent when
welcomeWELCOMEA new customer signs up
magic-linkMAGIC_LINKPasswordless sign-in
order-confirmationORDER_CONFIRMATIONAn order is paid
collab-approvedAFFILIATE_SUBMISSIONA collab application is approved
collab-rejectedAFFILIATE_SUBMISSIONA collab application is rejected
shipping-updateSHIPPING_UPDATEOperator-authored — no send path queues it
team-invitationTEAM_INVITATIONAn 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:

functions/email.send.ts
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-signature

If 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/entityId link 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:

  1. broadcastAudienceWhere turns a lifecycle segment into a list of recipients.
  2. buildCustomEmail renders the same element that the one-off compose form uses.
  3. sendEmail sends 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.

On this page