Add, send and debug email in Litestore, from the React Email templates in code to the EmailLog send ledger.
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 modules/email/send.ts. Every email is a React Email component in modules/email/templates/. There is no database template layer and no template editor in the admin: to change an email's wording, you edit its component and deploy.
RESEND_API_KEY and RESEND_SENDER_EMAIL, and env.ts refuses to start without them. Customers sign in with a link that Litestore emails to them, so if Resend is not configured, nobody can log in. Outside production both are optional and no email is ever sent.Send paths
modules/email/send.ts exports two functions, and no other code sends mail.
| Function | What it does | Who uses it |
|---|---|---|
sendEmail | It uses @react-email/components to turn a React element into HTML and plain text, then calls resend.emails.send and waits for Resend to answer. | Sign-in links and the broadcast fan-out, where the sender needs the result right away. |
queueEmail | It publishes an email/send Inngest event and lets a background function render and send it. | Transactional and automated mail. |
sendEmail checks isProd first. Outside production it logs the recipient, the subject, the from line and the plain-text body, writes an EmailLog row and returns without contacting Resend. This is why the sign-in link shows up in the terminal running bun run dev.
queueEmail is designed to fail quietly. Without an Inngest event key, it renders and sends the email inline in the same request instead. If publishing to Inngest throws for another reason, it logs the error and returns instead of throwing. The database change that triggered the email is already saved, and failing the whole action at that point would show the user an error even though their change was saved.
Templates
modules/email/queued-templates.ts maps every queued email type to the template it renders with. The QueuedEmailType union is derived from that map, so adding a type means adding one entry there and nothing else.
collab_approved collab_rejected welcome
notification alert_notification order_confirmation
order_pickup_ready order_pickup_reminder order_shipped
order_delivered order_cancelled gift_card_issued
cart_abandoned return_requested refund_processed
team_invitation customer_invitation customer_winbacknotification, alert_notification and refund_processed all render alert-notification.tsx with a generated title. order_confirmation reads the whole order at send time, because the event only carries its id. The shared layout and button are in modules/email/templates/components/.
Queue function
sendEmailFunction in jobs/email.send.ts handles the email/send event. It renders the template through renderQueuedEmail and sends it.
retries: 3
concurrency: { limit: 10 }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, which can be id, 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, and Resend ignores the second send instead of delivering the email twice.
Failed sends
onFailure runs once all retries have failed. It is the only code that records the email_failed activity type, so the failure shows up in the admin activity history. It also writes a final EmailLog row with status: "failed".
Unsubscribes
queueEmail takes a classification:
classification: "transactional" | "marketing"cart_abandoned and customer_winback are always marketing, whatever the caller passes. Litestore only ever holds back marketing mail. Before it publishes the event, queueEmail looks up the Customer by email address, and if unsubscribedAt is set, it returns without sending. Transactional mail is always sent.
For a marketing send, the queue 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, and the template shows it as a link in the footer. Litestore can only create this link when the recipient is an existing Customer row.
Bounces and complaints
app/api/webhooks/resend/route.ts receives delivery events from Resend. Resend signs these events with Svix, and 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.
| Event | What Litestore does |
|---|---|
| A complaint | It always unsubscribes the customer. |
| A bounce | It unsubscribes the customer. |
A bounce marked Transient or Undetermined | It only logs the bounce, and the customer stays subscribed, because a full mailbox should not permanently cut off a buyer who is still reachable. |
To unsubscribe someone, Litestore sets Customer.unsubscribedAt with an updateMany that only touches rows where unsubscribedAt is still null. That 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. It keeps sending transactional mail to an address that hard-bounced, and Resend blocks hard bounces on its side.
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 the emailType, the recipient, the subject, the Resend id and an optional entityType/entityId link to a record.
The record link makes the log much easier to use, because you can check whether the pickup-ready email for order #123 was sent without searching by subject text.
No admin screen lists EmailLog. Two pieces of code read it: the winback step in jobs/cron.index-data.ts, which checks the log so it doesn't email the same lapsed customer again within 180 days, and the abandoned-cart reports in modules/checkout/abandoned-cart-reports.ts, which count confirmed recovery sends. jobs/cron.data-cleanup.ts deletes rows older than 180 days, 1,000 at a time.
Consent is recorded in the activity feed rather than in EmailLog.
Broadcasts
A broadcast is a marketing email to a customer segment. The assistant's sendBroadcastEmail tool prepares it (subject and plain-text body), and it only goes out after an operator approves the proposal. jobs/email.broadcast.ts then handles the email/broadcast event. It adds no schema and reuses the existing sending code.
Recipients
broadcastAudienceWhere in modules/email/broadcast-audience.ts turns a lifecycle segment into a list of recipients.
Rendering
buildCustomEmail in modules/email/custom-email.ts renders the email, with a one-click unsubscribe link in every copy.
Batches
sendEmail sends it 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, and a new broadcast still always goes out.
Automations
The lifecycle emails that the store sends on its own, such as abandoned-cart recovery, review requests, the signup nudge and win-back, are durable workflows in modules/email/automations.ts. Their timings are constants in platform/config/email-automations.ts. Workflows explains them.
Configuration
| Variable | What it does | Required |
|---|---|---|
RESEND_API_KEY | The Resend client in platform/resend.ts uses it. | In production |
RESEND_SENDER_EMAIL | It is the from address, and env.ts checks that it is a valid email. | In production |
RESEND_WEBHOOK_SECRET | The Resend webhook route uses it to verify Svix signatures. If you leave it unset, that route returns 503. | No |
platform/resend.ts creates the one client with new Resend(env.RESEND_API_KEY), or null without a key. To replace Resend, you edit that file and the resend.emails.send call in modules/email/send.ts.
Quick reference
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.
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 Litestore will always send it.
To add a new queued email, write its React component in modules/email/templates/ and add the type to the TEMPLATES map in modules/email/queued-templates.ts.
Related
Webhooks
Litestore signs and retries outgoing webhook deliveries, turns off an endpoint that keeps failing, and handles incoming Stripe and Resend callbacks on separate routes.
Events
Litestore publishes its domain events through Inngest, handlers avoid doing work twice, and delivery is best-effort.
Providers
Some external services sit behind a swappable interface, Litestore calls others directly, and the app needs certain environment variables to start.