Litestore · Developer docs
Platform services

Email

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.

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.

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.

Production requires 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.

FunctionWhat it doesWho uses it
sendEmailIt 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.
queueEmailIt 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_winback

notification, 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:

jobs/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, 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-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.

EventWhat Litestore does
A complaintIt always unsubscribes the customer.
A bounceIt unsubscribes the customer.
A bounce marked Transient or UndeterminedIt 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

VariableWhat it doesRequired
RESEND_API_KEYThe Resend client in platform/resend.ts uses it.In production
RESEND_SENDER_EMAILIt is the from address, and env.ts checks that it is a valid email.In production
RESEND_WEBHOOK_SECRETThe 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.

On this page