DEV Community

DorianVale91583
DorianVale91583

Posted on

Node.js Bulk Welcome Email After User Import — Transactional Receipt Control

A payment-settled order receipt should come from a versioned template your application can identify, then pass through a rate-limited batch worker with an application-owned dedupe key. Keep the order, template version, attempt count, and provider message ID in your database. Check suppression before sending, retry only transient failures, and poll message records for delivery status.

The same rule applies to a bulk welcome email after a user import. Both are transactional email jobs; the receipt merely raises the stakes for template ownership because its wording describes a settled purchase.

Short answer: own the delivery decision and audit trail in the application, even if a provider owns rendering. That boundary stops a retry from becoming a duplicate receipt and lets support answer a harder question: which exact content did this buyer receive?

The pipeline is small. Payment settles. An outbox row freezes intent. A worker checks suppression, selects a template version, sends a bounded batch, and records the result. A separate poller updates delivery state. API acceptance does not mean inbox delivery.

Before and after: move intent ahead of transport.

The fragile design begins with a payment event and ends with an email API call. The handler renders whatever template is current, calls the provider, and marks the order notified. If the process loses its database connection after acceptance, a retry may send twice. If someone edits the shared template meanwhile, the copies may differ.

The stronger design writes immutable intent first. For ord_74291, create order-receipt:ord_74291:v7. This identifier belongs to the application and survives restarts, provider changes, and batch reshaping. Store the template version beside it.

Separate queued, accepted, and delivered. Because delivery visibility here is pulled rather than pushed, a poller must fetch message or event records and advance local state. Do not promise an immediately confirmed delivery badge.

That is the before/after: from “call email after payment” to “persist intent, attempt transport, reconcile outcome.” Small change. Big clarity.

A copyable TypeScript worker with bounded retries

This worker is provider-neutral. The adapter alone knows the vendor payload, so the ledger remains stable. It uses an eight-message concurrency ceiling, honors Retry-After, applies exponential backoff, and claims each receipt before sending. A production store must enforce a unique constraint on dedupeKey.

import { setTimeout as sleep } from "node:timers/promises";

type Receipt = {
  orderId: string;
  to: string;
  templateVersion: string;
  data: Readonly<Record<string, string>>;
};

type Result =
  | { ok: true; messageId: string }
  | { ok: false; status: number; retryAfterSeconds?: number; detail: string };

interface EmailAdapter {
  isSuppressed(email: string): Promise<boolean>;
  send(receipt: Receipt, dedupeKey: string): Promise<Result>;
}

interface Store {
  claim(key: string, receipt: Receipt): Promise<boolean>;
  accepted(key: string, messageId: string): Promise<void>;
  failed(key: string, detail: string): Promise<void>;
}

async function postInfraiBatch(payload: unknown, dedupeKey: string) {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseURL = process.env.INFRAI_BASE_URL;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");
  if (!baseURL) throw new Error("INFRAI_BASE_URL is required");
  const response = await fetch(`${baseURL}/v1/email/batch/send`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": dedupeKey,
    },
    body: JSON.stringify(payload),
  });
  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`email batch failed (${response.status}): ${detail}`);
  }
  return (await response.json()) as unknown;
}

async function deliver(receipt: Receipt, email: EmailAdapter, store: Store) {
  const key = `order-receipt:${receipt.orderId}:${receipt.templateVersion}`;
  if (!(await store.claim(key, receipt))) return;
  if (await email.isSuppressed(receipt.to)) {
    await store.failed(key, "recipient is suppressed");
    return;
  }

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const result = await email.send(receipt, key);
    if (result.ok) {
      await store.accepted(key, result.messageId);
      return;
    }
    const transient = result.status === 429 || result.status >= 500;
    if (!transient || attempt === 4) {
      await store.failed(key, result.detail);
      return;
    }
    await sleep((result.retryAfterSeconds ?? 2 ** attempt) * 1_000);
  }
}

export async function sendReceiptBatch(
  receipts: readonly Receipt[], email: EmailAdapter, store: Store,
) {
  for (let offset = 0; offset < receipts.length; offset += 8) {
    await Promise.all(
      receipts.slice(offset, offset + 8).map((item) => deliver(item, email, store)),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

A validation error is recorded rather than retried forever. A 429 gets another attempt, but not a hot loop. Five attempts and eight concurrent sends are concrete starting values, not universal constants; configure them from observed limits. Add jitter when multiple workers run.

For a batch API, claim every receipt before constructing the batch and associate every returned message ID with its logical operation. If the whole request must be retried, use a provider idempotency key where supported while retaining database uniqueness as the cross-provider guarantee.

Should bulk welcome email after user import share receipt templates?

Template ownership is not template storage. The team that approves receipt semantics should own the version, required variables, and rollout decision. A provider may still render it.

Option Rendering location Good fit Boundary to plan for
Resend Templates Provider Reusable templates near the sending service Map an application version to the remote reference
SendGrid Dynamic Templates Provider Existing dynamic-template workflows Treat activation as a controlled release
Postmark Templates and layouts Provider with shared layout Receipt families sharing a branded shell Record the content version and remote alias
Amazon SES templates Provider AWS-centered systems Build review and lineage into your release process
Application rendering Repository Tightly reviewed receipt wording Own rendering correctness and payload size

These are real alternatives, not a ranking. Application rendering gives the clearest source-control history. Provider rendering can let content teams publish without deploying the worker, but adds coordination.

Infrai fits when an organization values one key and one bill across backend services and wants a plain REST boundary instead of another service-specific SDK. Its idempotency convention supports transport attempts, and suppression can be checked before bulk mail. Keep campaign or tenant reporting metadata locally because there is no tag-aggregated cost reporting API. Reconcile delivery by polling because email events are not pushed by webhook.

The limitations change the choice. It is not a fit when SMTP relay, email webhooks, hosted email OTP, or voice, WhatsApp, and RCS delivery are requirements. Choose a specialist whose documented interface supplies the required channel or push model. Likewise, choose application rendering over any hosted template product when reviewers need every content change tied to a code commit. This is a template-ownership decision, not a vendor loyalty test.

My rule is blunt: if wording requires the same review as code, render a versioned repository artifact. If content teams must publish independently, use provider-managed templates but pin an approved version. Never let “latest” silently decide financial wording for a settled order.

What happens when the worker is rate-limited?

Stop adding parallelism. A 429 is feedback. Honor Retry-After; otherwise use exponential backoff and jitter. Cap attempts, persist the next eligible time, and let a queue redrive the item later.

Back off.

Should it retry an entire batch? Only when every item has a stable idempotency identity. Partial acceptance is the dangerous case. If item-level outcomes exist, retry only transient failures. Otherwise, reconcile known message records before repeating a request.

Check bounced and opted-out addresses before constructing bulk sends, then record a terminal suppressed outcome. Google also expects bulk senders to meet authentication and subscription requirements; retries cannot repair weak sender identity or ignored opt-outs.

Watch queue age, attempts by status class, suppression count, and time from acceptance to observed delivery. Alert on sustained age and a rising retry ratio, not one isolated 429. Diagram in words: a counter shows volume, a histogram shows delay, and a structured log identifies the order.

Can polling still provide useful receipt observability?

Yes, with honest freshness labels. Poll accepted messages, write the latest state and observation time to the ledger, and age out terminal records under your retention policy. The support screen should say “last checked at 14:32 UTC,” not imply a live stream.

Measure the oldest accepted-but-unreconciled receipt against the poll interval. Keep transport health and reconciliation health on separate panels. Combining them hides the boundary an operator needs.

For a concrete reconciliation pass, imagine 2,000 imported users and 600 settled orders entering the same Node.js queue. The welcome batch can tolerate a later status refresh, while a receipt may drive a support conversation within minutes. Give them separate freshness objectives and template families, even if they share the worker. The ledger still uses one shape: logical operation, pinned version, attempt, remote ID, observed state, and observed time. That common shape makes dashboards comparable without pretending the messages carry equal urgency.

The operational test is simple. Given an order ID, can support find the logical send, pinned template version, attempts, message ID, suppression decision, last observed state, and timestamp without opening three dashboards? If yes, the template boundary works.

Further reading and References

Top comments (0)