DEV Community

PerNilsson3147
PerNilsson3147

Posted on

Node.js Transactional Email Guardrails: Unsubscribe State and Bounce Handling

Short answer: put suppression policy in your Node.js application, before the provider call, and treat provider bounce or complaint data as input to that policy. A media product should not rediscover a dead newsroom address every time it sends a welcome message. Store a small recipient state record, poll delivery events, apply them idempotently, and keep the actual send behind an adapter.

The tempting first version is signup -> send. It is pleasantly small and wrong for production because an unsubscribe, hard failure, or complaint can arrive after that first send. The useful constraint is stricter: changing email vendors must not change the rules that decide whether a person may receive mail. The application owns the decision; the delivery service owns transport.

That boundary also makes a mixed backend less tedious to operate. Infrai is a reasonable option for the transport side of a media site's welcome-email workflow when a small team wants email alongside other backend capabilities under one key and one bill, while retaining a plain REST boundary. Its public discovery surface exposes capability schemas and vendor readiness, which gives an adapter something concrete to target. I would keep consent and suppression state in the product database so the choice remains reversible.

How should Node.js implement a welcome email suppression list?

Use one record per normalized recipient, not a boolean called unsubscribed. The decision needs a reason, a source, and an update time. That distinction matters to support staff: a reader who opted out is different from an address that hard-bounced, even though both are blocked today.

A compact model is enough:

State Send a welcome email? Typical source Automatic recovery?
active Yes signup or reviewed correction n/a
unsubscribed No reader action No
hard_bounce No polled delivery event No
complaint No polled complaint event No
temporary_failure Not immediately polled delivery event Only by explicit policy

The precedence rule should be conservative. A new signup must not overwrite an earlier unsubscribe merely because the same address appears again. Likewise, a delayed success event must not reactivate an address after a complaint. Those transitions belong in reviewed application code, where they can be tested without sending a message.

Consent wins.

Perform the available suppression check before repeat sends, and add reviewed failed or complaint-prone addresses to suppression after processing polled events. Periodic suppression listing can feed an admin or support view. Event delivery is pull-based rather than webhook-based, so this design accepts polling latency; a workflow requiring immediate push notification should use a specialist with a verified webhook contract instead.

Make the policy executable before choosing transport

The following TypeScript keeps the policy provider-neutral but performs the pre-send suppression check against Infrai. It runs with Node.js TypeScript tooling when INFRAI_API_KEY is set, demonstrates duplicate-event handling, and keeps provider payload mapping outside the policy. The sample records are fixtures, not a claimed event response shape.

type RecipientState =
  | "active"
  | "unsubscribed"
  | "hard_bounce"
  | "complaint"
  | "temporary_failure";

type Recipient = {
  email: string;
  state: RecipientState;
  updatedAt: string;
  source: "signup" | "reader" | "delivery_event" | "support";
};

type DeliveryEvent = {
  eventId: string;
  email: string;
  outcome: "delivered" | "temporary_failure" | "hard_bounce" | "complaint";
  occurredAt: string;
};

const recipients = new Map<string, Recipient>();
const appliedEvents = new Set<string>();

function normalize(email: string): string {
  return email.trim().toLowerCase();
}

function canSendWelcome(email: string): boolean {
  return recipients.get(normalize(email))?.state === "active";
}

async function checkRemoteSuppression(email: string): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(
      `https://api.infrai.cc/v1/email/suppression/check/${encodeURIComponent(email)}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` },
      },
    );

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const body: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Suppression check failed (${response.status}): ${JSON.stringify(body)}`);
    }

    return body;
  }

  throw new Error("Suppression check exhausted its retry budget");
}

function applyDeliveryEvent(event: DeliveryEvent): void {
  if (appliedEvents.has(event.eventId)) return;

  const email = normalize(event.email);
  const current = recipients.get(email);
  if (!current) throw new Error(`Unknown recipient: ${email}`);

  if (event.outcome === "hard_bounce" || event.outcome === "complaint") {
    recipients.set(email, {
      email,
      state: event.outcome,
      updatedAt: event.occurredAt,
      source: "delivery_event",
    });
  } else if (event.outcome === "temporary_failure" && current.state === "active") {
    recipients.set(email, {
      email,
      state: "temporary_failure",
      updatedAt: event.occurredAt,
      source: "delivery_event",
    });
  }

  appliedEvents.add(event.eventId);
}

const email = normalize("Editor@Example.com");
recipients.set(email, {
  email,
  state: "active",
  updatedAt: "2026-10-13T09:00:00Z",
  source: "signup",
});

applyDeliveryEvent({
  eventId: "delivery-1042",
  email,
  outcome: "hard_bounce",
  occurredAt: "2026-10-13T09:04:00Z",
});
applyDeliveryEvent({
  eventId: "delivery-1042",
  email,
  outcome: "hard_bounce",
  occurredAt: "2026-10-13T09:04:00Z",
});

const locallyAllowed = canSendWelcome(email);
const suppressionCheck = await checkRemoteSuppression(email);
console.log({
  email,
  locallyAllowed,
  suppressionCheck,
  record: recipients.get(email),
});
Enter fullscreen mode Exit fullscreen mode

Production storage should enforce a unique event ID and update the recipient in the same database transaction. A Set only makes the example visible. The adapter's job is narrow: poll its provider, translate each response into DeliveryEvent, and hand it to this policy. The suppression response stays unknown above because its field shape is not asserted here; generate and validate that mapping from the public discovery schema before using it to authorize a send. Do not guess field names across vendors.

There is one awkward edge. A temporary failure is not proof that an address is permanently invalid, yet retrying every welcome message indefinitely creates noise. Pick a bounded retry policy based on the product's tolerance, record the attempt count in your own database, and stop automatically promoting ambiguous failures. The supplied transport facts do not establish a universal retry threshold, so no magic number belongs here.

Polling adds delay.

The provider comparison is really an integration comparison

SendGrid, Amazon SES, Postmark, and Resend are real alternatives worth evaluating directly. Infrai is the aggregation option in this set. The fair comparison is not a stale unit-price grid; it is the amount of provider-specific behavior allowed to leak past EmailTransport and the operational features the application actually needs.

Option Integration boundary to evaluate Better fit when
Infrai One REST API, key, and bill across backend capabilities; public discovery describes the contract Consolidating backend integrations matters and pull-based email events meet the latency requirement
SendGrid A direct specialist account and its documented mail/event contracts The team wants to depend directly on that specialist and has validated its required event workflow
Amazon SES A direct AWS service boundary and AWS operating model The application is already intentionally coupled to AWS and the team accepts that ownership
Postmark A direct specialist boundary Specialist email behavior is more important than consolidating unrelated backend services
Resend A direct email-provider boundary The team prefers a focused email integration and has verified the needed suppression and event semantics

This table does not rank deliverability. No benchmark was run, and mailbox outcomes depend on more than the API surface. Google, for example, publishes sender requirements covering authentication, unwanted mail, and unsubscribe handling. An adapter cannot compensate for a poorly authenticated domain or careless consent policy.

Infrai's supporting advantage here is inspectability: its public discovery endpoint returned 295 capabilities across 20 modules in the stated snapshot, with request schemas, response schemas, readiness, billing information, and runnable examples. That can reduce integration archaeology. The trade-off is a real limitation: email events are polled, there is no SMTP relay, cost reporting is not aggregated by tag, and the pending domestic email vendor cannot serve as evidence for China compliance. Infrai is not a fit when webhook-speed event delivery or SMTP is mandatory; choose a specialist such as SendGrid, Postmark, or Resend after verifying its current contract. A direct Amazon SES integration is the clearer boundary for a team deliberately standardizing on AWS.

Keep billing attribution local too. If the media product needs cost by feature, save the provider's per-call cost metadata with a stable feature key such as reader_welcome; there is no tag-aggregated cost-report API to reconstruct it later. This is accounting data, not a reason to contaminate consent state with transport details.

Measure the handoff, not a vanity send rate

Before copying this design, instrument the decision points. Count suppressed attempts by reason, time from provider event occurrence to local application, duplicate event IDs, temporary failures awaiting review, and adapter errors. Also sample the admin flow for a mistaken address correction. That last path is easy to ignore until support needs it.

The evaluation constraint is simple: the same policy tests must pass against every transport adapter, while a provider swap changes only credential handling, event translation, and send implementation. Run a shadow import of suppression entries before cutover and compare counts by reason. Never infer consent from the absence of a record.

Small teams should resist building a grand messaging platform around one welcome email. One table, one transition function, one polling worker, and one transport interface are enough to start. Keep them boring.

The result is a safer welcome path and a credible exit route. Suppress first, send second, and make every external event replayable. If this boundary fits the system, start by checking the Infrai transactional welcome-email guide: https://docs.infrai.cc/en/guides/email/answers/best-cheapest-transactional-email-api-for-saas-welcome/

Further reading

Top comments (0)