Treat email as a durable workflow, not a request side effect
Password resets, receipts, invitations, and account alerts are part of the product experience. Sending them directly inside an API request couples a customer-facing response to a remote provider: a timeout can lose the email, repeat it, or keep the user waiting without revealing what happened.
Trigger
A committed business event records why an email is needed and which account owns it.
Queue
A durable job absorbs provider latency, controls concurrency, and makes retries visible.
Provider
The provider accepts a message, while later webhooks report delivery, bounce, or complaint.
Return success only after the business change and durable email intent are saved. Let a worker perform the external send independently.
Commit the business change and email intent together
A database change followed by a separate queue publish creates a dangerous gap: the order may commit while the receipt job never appears. Store an outbox record in the same database transaction, then let a dispatcher publish unsent records to the queue.
await db.transaction(async (tx) => {
const order = await tx.orders.markPaid(orderId);
await tx.outbox.insert({
type: "order.receipt.requested",
aggregateId: order.id,
payload: { customerId: order.customerId },
});
});Keep the outbox payload small and avoid copying secrets or rendered message content into it. The pattern is covered in more depth in our transactional outbox guide.
Design for at-least-once execution without duplicate mail
Workers can crash after a provider accepts a message but before the job is acknowledged. Assume every job may run again. Derive a stable message key from the business event and template purpose, claim it atomically, and retain the provider message ID with the send attempt.
- Retry transient timeouts, connection failures, and rate limits with exponential backoff and jitter.
- Do not retry permanent recipient, template, or authorization failures unchanged.
- Set maximum attempts and move exhausted jobs to a reviewable dead-letter state.
- Use the provider's idempotency capability when available, but keep application-side deduplication.
Version templates like application code
Each message type should have an explicit data contract, subject, HTML body, plain-text alternative, sender identity, reply behavior, and template version. Validate required fields before enqueueing so workers do not repeatedly discover malformed jobs.
Escape untrusted values, avoid placing sensitive data in subject lines, and link recipients back to authenticated application pages instead of embedding private records in email. Test narrow screens, dark mode, images-off rendering, keyboard navigation, and meaningful link text. Preview representative locales and long customer names in CI.
Protect sender reputation before volume grows
Use a verified sending domain and align SPF, DKIM, and DMARC with the addresses customers see. Separate transactional traffic from marketing campaigns so consent mistakes or promotional spikes do not jeopardize essential account messages.
- Send only messages the recipient reasonably expects.
- Process hard bounces and complaints into a suppression list promptly.
- Warm new sending identities gradually and avoid sudden volume changes.
- Monitor reputation by domain, provider, template, and deployment.
Do not hide transactional promotions inside operational email. Clear purpose and consistent identity help both customers and mailbox providers trust the stream.
Turn signed provider webhooks into trustworthy delivery state
Provider acceptance is not delivery. Receive delivered, deferred, bounced, and complaint events through a dedicated endpoint. Verify the provider signature against the exact request bytes, enforce timestamp tolerance, and deduplicate by provider event ID before changing state.
Normalize provider-specific events into a small internal model, retain the raw event reference for support, and make processing replayable. Reject oversized payloads and unknown event types safely. A browser redirect or client callback must never be trusted as proof of delivery.
Webhook work should finish quickly; enqueue expensive enrichment or customer notification as another job. The same bounded-worker principles used for Next.js scheduled and background work apply here.
Observe customer outcomes, not only API success
Track queue age, attempts, provider latency, acceptance, delivery, deferral, bounce, complaint, and suppression rates. Segment alerts by message type and provider because an acceptable newsletter delay may be a serious password-reset incident.
Correlate a business event, outbox record, job, send attempt, provider message, and webhook without logging message bodies or unnecessary personal data. Attach structured reason codes and request IDs, then use the tracing practices in our Next.js observability guide.
Transactional email production checklist
✓ Business changes and email intents commit atomically
✓ Workers use stable message keys and bounded retries
✓ Templates have validated, versioned data contracts
✓ HTML and plain-text output are accessible and tested
✓ SPF, DKIM, and DMARC align with the sending identity
✓ Webhook signatures and replay protection are enforced
✓ Bounces and complaints update a suppression list
✓ Queue age and delivery outcomes have actionable alerts
Build a more dependable Next.js application
Endurance Softwares helps teams design, build, test, and operate production-ready Next.js platforms.
