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
fromline - 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:
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_invitationA 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":
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.
| 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. 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:
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-signatureAn 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/entityIdanchor
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:
broadcastAudienceWhereturns a lifecycle segment into recipientsbuildCustomEmailrenders the same element the one-off compose form usessendEmaildelivers 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.
Related
Webhooks
Learn how outbound deliveries are signed and retried, when an endpoint is auto-disabled, and how the inbound Stripe and Resend routes differ.
Events
Learn how the 56 domain events publish through Inngest, how handlers stay idempotent, what the crons do, and why delivery is best-effort rather than guaranteed.
Providers
Learn which external services sit behind a swappable interface, which are integrated directly, and which environment variables the app refuses to boot without.
Workflows
Learn how the three WORKFLOWS in lib/rules/workflows.ts use step.sleep for durable multi-touch follow-ups, how that differs from the polling crons, and what the other event-driven background functions do.
Storage
Learn how Litestore uploads files to S3-compatible storage, why it sniffs magic bytes instead of trusting the declared MIME type, and how MediaAssociation attaches one file to seven owner types.