Litestore · Developer guide

Email

Learn how Litestore renders, queues and sends email through Resend, how database templates override the React components in emails/, and what the EmailLog ledger records.

Every email Litestore sends goes through Resend.

lib/email.ts is the one send path. The 26 React Email components in emails/ are the default renderers, and an operator-editable EmailTemplate row overrides the component for the six queued types that map to one.

RESEND_API_KEY and RESEND_SENDER_EMAIL are required in env.ts. Sign-in sends a magic link, so an unconfigured Resend means nobody can log in.

The three entry points

lib/email.ts exports three functions and nothing else sends mail.

sendEmail

Renders a React element to HTML and plain text with @react-email/components, then calls resend.emails.send.

It is synchronous, so it is used where the sender needs immediate feedback — the admin compose form and test sends.

sendRawEmail

The same send, for callers that already hold HTML. That is how database templates go out.

It owns:

  • the from line
  • the dev-mode guard
  • the unsubscribe headers
  • the idempotency key

Those live here so they are not re-implemented at each resend.emails.send call site.

queueEmail

Publishes an email/send Inngest event.

This is the path for transactional and automated mail, and the only path that consults database templates.

Behaviour they share

sendEmail and sendRawEmail both check isProd first. Outside production they log the recipient, subject and rendered length, then return undefined without contacting Resend.

queueEmail is best-effort by design. If the Inngest publish throws — no event key locally, an Inngest outage — it logs and returns rather than throwing.

The reason is ordering: the database mutation that triggered the email has already committed, so failing the whole action would misreport what happened.

Template resolution

The Inngest function in functions/email.send.ts tries the database first and falls back to 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

A queued type with no entry in that map never reaches the database renderer at all. It renders from its React component with the caller-supplied subject.

notification is deliberately absent.

It used to map to OTHER, whose default template is "Review Approved". Refund, shipment, cancellation, abandoned-cart and return emails therefore all silently rendered "Your review has been published".

Placeholders

Database templates are plain HTML with {{variable}} placeholders.

renderTemplateContent substitutes them. An unknown or misspelled key renders as an empty string rather than as literal braces, so a customer never sees {{order_number}}.

wrapEmailHtml adds the shared layout, logo, signature and footer. htmlToPlainText derives the text part.

Two defaults for one type

getEmailTemplate resolves a type's default template.

Nothing in the schema stops two rows from both being isDefault for the same type, so the query orders the result to make the outcome deterministic instead of "whichever row was edited last":

lib/email-templates.ts
return db.emailTemplate.findFirst({
  where: { type, isActive: true, isDefault: true },
  orderBy: [{ updatedAt: "desc" }, { createdAt: "asc" }, { id: "asc" }],
})

The real fix is a unique (type, isDefault) constraint. It is staged and not implemented — the code comment at lib/email-templates.ts:167 says so, and there is no migration for it. Until there is, the tiebreak above is what decides.

Why slugs exist

Several templates share a type, which is why EMAIL_TEMPLATE_SLUG_MAP exists.

AFFILIATE_SUBMISSION backs both collab-approved and collab-rejected, so a slug-less lookup would send a rejected applicant the approval email.

When a slug is given and no matching active row exists, getEmailTemplate returns null and the caller falls back to the React component.

It deliberately does not fall back to the type's default, because that default is the wrong email.

The seeded templates

DEFAULT_TEMPLATES in lib/email-template-constants.ts seeds seven rows.

They are editable in Admin → Emails, and EmailTemplateCRUD.beforeDelete refuses to delete any row with 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. The two with no seeded row are MARKETING and NEWSLETTER, both available for operator-authored templates.

Adding a type means adding a key and a TEMPLATE_VARIABLE_SCHEMAS entry. The database column is a plain string, so there is no migration.

SHIPPING_UPDATE is a live type with a dead producer. order_shipped renders emails/order-shipped.tsx directly, and the shipping_update entry was removed from EMAIL_TYPE_MAP.

Editing that seeded template therefore changes nothing until you add a queued type that maps to it.

The queue function

sendEmailFunction handles email/send.

retries: 3
concurrency: { limit: 10 }

It covers 22 queued types in QueuedEmailType, each resolving to a component in getEmailComponent.

Idempotency

Every send carries a Resend idempotency key built by buildEmailIdempotencyKey:

email:<type>:<ref>

ref prefers a domain id from the props — orderId, refundId, collabCode, reviewId, invitationId — and falls back to recipient plus subject.

An Inngest retry of the same event produces the same key, so Resend treats the resend as a no-op instead of a duplicate delivery.

Failure

onFailure fires once all retries are exhausted.

It is the only emitter of the email_failed activity type, which is what the emailsFailing operator task counts, and it writes a terminal EmailLog row with status: "failed".

Two types without a component

refund_processed and notification both render emails/alert-notification.tsx with a synthesized title and severity.

Purpose-built templates for the customer-facing notification events are staged, not shipped.

Marketing mail and suppression

queueEmail accepts a classification:

classification: "transactional" | "marketing"

Marketing is the only class that is suppressed. Before publishing the event, queueEmail looks up the Customer by email and returns without sending if unsubscribedAt is set.

Transactional mail is always deliverable.

Inside the function, marketing sends resolve an HMAC unsubscribe URL through buildUnsubscribeUrl and attach RFC 8058 headers:

functions/email.send.ts
unsubscribeHeaders = {
  "List-Unsubscribe": `<${unsubscribeUrl}>`,
  "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}

The URL is also passed into props as unsubscribeUrl, so React templates render a visible footer link and database templates can reference {{unsubscribeUrl}}.

The link only exists for recipients who are known Customer rows.

Inbound delivery events

app/api/webhooks/resend/route.ts ingests Resend delivery events.

Resend signs with Svix, so the route verifies three headers against RESEND_WEBHOOK_SECRET:

svix-id
svix-timestamp
svix-signature

An unset secret returns 503. An invalid signature returns 401.

Only email.bounced and email.complained do anything.

A complaint always suppresses. A bounce suppresses unless Resend explicitly classifies it Transient or Undetermined — those are logged and the customer keeps consent, because a full mailbox should not permanently unsubscribe a reachable buyer.

Suppression sets Customer.unsubscribedAt through an updateMany scoped to unsubscribedAt: null, so replays and prior explicit unsubscribes are never overwritten, then invalidates the customers cache.

Every event, suppressing or not, emits an email_bounced activity row.

There is no email-keyed suppression store, so transactional mail to a hard-bounced address keeps sending. Resend suppresses hard bounces at the provider level.

The send ledger

recordEmailLog writes one EmailLog row at the terminal state of a send — sent or failed. One row per email, not per retry attempt.

The row carries:

  • emailType
  • recipient
  • subject
  • the Resend id
  • an optional entityType/entityId anchor

The anchor is the point. It lets an operator ask "was the pickup-ready email for order #123 sent?" instead of matching on subject text.

No admin screen reads EmailLog. The only code that queries it is the winback step in functions/cron.index-data.ts, which uses it as a dedupe guard against re-emailing a lapsed customer within 180 days.

functions/cron.data-cleanup.ts purges rows older than 180 days in batches of 1,000.

The activity feed, not EmailLog, is the consent ledger.

Broadcasts

functions/email.broadcast.ts handles the email/broadcast event from the admin compose flow.

It adds no schema and no new send machinery:

  1. broadcastAudienceWhere turns a lifecycle segment into recipients
  2. buildCustomEmail renders the same element the one-off compose form uses
  3. sendEmail delivers it

Sends run in Promise.allSettled batches of 25, with a per-recipient idempotency key:

broadcast:<broadcastId>:<customerId>

A function retry therefore re-sends nothing, while a genuinely new broadcast always goes out.

Configuration

RESEND_API_KEY is required. It backs the Resend client in services/resend.ts.

RESEND_SENDER_EMAIL is required. It is the from address, validated as an email in env.ts.

RESEND_WEBHOOK_SECRET is optional. It is the Svix verification secret for inbound delivery events; unset means the route returns 503.

services/resend.ts is four lines — new Resend(env.RESEND_API_KEY).

Replacing Resend means editing that file and the two resend.emails.send calls in lib/email.ts.

Which send path to use

Use queueEmail for anything a customer receives as a consequence of something that happened. It retries, it dedupes on the idempotency key, it writes an EmailLog row, and it is the only path an operator can override from Admin → Emails.

Use sendEmail when a human is waiting on the result — the admin compose form and test sends — and can act on a failure immediately.

Use sendRawEmail only when you already hold rendered HTML. In practice that means the database-template path.

If the email is marketing, set classification: "marketing" so the unsubscribe check and the RFC 8058 headers apply. If it is transactional, leave it alone: it will always be deliverable.

When adding a new queued type, the question that decides the rest is:

Should an operator be able to edit this email's wording without a deploy?

If yes, it needs an EMAIL_TYPE_MAP entry, a template type, and — where the type is shared — an EMAIL_TEMPLATE_SLUG_MAP entry. If no, a React component in emails/ is the whole implementation.

On this page