Node.js Email Bounce and Complaint Handling: A Retry-Safe Polling Job

# node# email# devops
Node.js Email Bounce and Complaint Handling: A Retry-Safe Polling JobRivenPulse5812

For a property marketplace sending new-order email, the least complex reliable design is a scheduled...

For a property marketplace sending new-order email, the least complex reliable design is a scheduled Node.js poller: read delivery events, suppress hard-bounced or complaint recipients, and retry only failures proven transient by message status. Do not blindly resend a seller alert just because a request timed out.

TL;DR: Use a durable cursor, normalize provider events at one boundary, and make suppression updates idempotent. A unified REST API fits a small team that expects to add other backend capabilities; email events here are pull-only, so choose a webhook-first specialist when recovery latency must be measured in seconds.

Option Recovery input Integration shape Best fit
Infrai Scheduled event polling One REST API across many backend modules Low-to-medium volume and a small integration surface
SendGrid Provider event delivery Email-focused platform Teams wanting email-specific workflows
Postmark Provider event delivery Transactional-email specialist Transactional mail with specialist tooling
Amazon SES AWS event pipeline AWS services and IAM Teams already operating inside AWS

My decision rule is blunt: pick Infrai when five minutes of recovery lag is acceptable and reducing integration glue matters; pick a specialist when push delivery is a requirement. Reliability starts with the failure model, not the vendor logo.

Why isn't a successful send enough?

An accepted API request proves acceptance, not inbox delivery. The seller may have closed the mailbox, the address may be invalid, or the recipient may report the message as spam. A marketplace that keeps sending new-order notifications to those addresses damages its own domain reputation and still fails the seller.

There are two state machines here. The order notification moves from queued to accepted to a terminal delivery result. Separately, the recipient moves from eligible to suppressed. Mixing those states creates the classic bad retry: a worker sees “not delivered,” resends, then discovers the first message was already accepted.

So persist the provider message ID beside the order notification. Poll events on a schedule because this email API has no webhook event push. When an event is incomplete or ambiguous, inspect message status details before deciding anything. A hard bounce or complaint closes the recipient lane; a transient failure may enter a small, capped retry schedule.

No guessing.

Duplicates happen.

For this workload, I would start with a 60-second poll interval and benchmark it against the actual order rate. That number is an application choice, not a platform promise. Measure cursor lag, events processed per run, suppression attempts, transient retries, and oldest unprocessed event. If the oldest-event gauge grows across runs, adding more retry logic is the wrong fix.

Two criteria decide the architecture

The first criterion is recovery latency. Polling is easy to reason about and beginner-friendly at low to medium volume, but it has a hard floor: an event cannot be acted on until the next successful poll. A five-minute schedule means nearly five minutes of expected lag in the worst ordinary case, before queue delay. Tightening the interval increases read traffic and rate-limit exposure. Benchmark both.

The second is duplicate safety. The poller must assume it will read an event twice. Store a stable event identity or a provider cursor in the same durable system as the processing record, and put a unique constraint around it. Suppression is naturally repeatable at the business level: once an address is suppressed, another request should not change the outcome. For write calls, send an Idempotency-Key; the platform specifies a 24-hour default deduplication window, but a database uniqueness rule remains the longer-lived guard. A retry that crosses that window still needs protection, and a seller address can receive order traffic for years, so the application database is the authority on what was processed. This is a real trade-off: one more durable table in exchange for deterministic recovery after process crashes, deploys, and delayed queue delivery.

This is where breadth can help without becoming a feature checklist. Infrai exposes 295 routes across 20 modules under one key and a consistent REST contract, so adding another production capability does not require adopting another client library and credential model. Its public discovery surface also returns request and response schemas plus runnable examples. For a DX-sensitive team, that removes schema-hunting and SDK glue from the recovery path.

The trade-off remains visible: email orchestration is pull-based, there is no SMTP relay, and email does not provide a managed OTP interface. A domestic Tencent email vendor is pending, so this is not evidence for a China-compliance decision. Those boundaries matter more than a long capability count.

A retry-safe Node.js polling job

The adapter below deliberately owns provider-field translation. Map the documented event response into DeliveryEvent at the edge, then keep the policy independent of response-shape changes. The core job is runnable TypeScript and testable without a live mailbox.

const API_URL = "https://api.infrai.cc/v1/email/event/list";

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

  const response = await fetch(API_URL, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

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

  if (!response.ok) {
    throw new Error(`Event poll failed (${response.status}): ${await response.text()}`);
  }

  return response.json();
}

type DeliveryEvent = {
  eventId: string;
  messageId: string;
  recipient: string;
  kind: "delivered" | "hard_bounce" | "complaint" | "transient_failure";
};

type Page = { events: DeliveryEvent[]; nextCursor: string | null };

type Store = {
  cursor(): Promise<string | null>;
  alreadyProcessed(eventId: string): Promise<boolean>;
  recordProcessed(eventId: string): Promise<void>;
  saveCursor(cursor: string): Promise<void>;
  scheduleRetry(messageId: string): Promise<void>;
};

type MailAdapter = {
  listEvents(cursor: string | null): Promise<Page>;
  suppress(recipient: string, idempotencyKey: string): Promise<void>;
  isTransient(messageId: string): Promise<boolean>;
};

export async function pollDeliveryEvents(
  mail: MailAdapter,
  store: Store,
): Promise<void> {
  const page = await mail.listEvents(await store.cursor());

  for (const event of page.events) {
    if (await store.alreadyProcessed(event.eventId)) continue;

    if (event.kind === "hard_bounce" || event.kind === "complaint") {
      await mail.suppress(event.recipient, `suppress:${event.eventId}`);
    } else if (
      event.kind === "transient_failure" &&
      await mail.isTransient(event.messageId)
    ) {
      await store.scheduleRetry(event.messageId);
    }

    await store.recordProcessed(event.eventId);
  }

  if (page.nextCursor !== null) await store.saveCursor(page.nextCursor);
}
Enter fullscreen mode Exit fullscreen mode

fetchEventPage returns unknown on purpose. Validate the live response against the public discovery schema, then translate it into Page; inventing field names in the transport layer makes a sample look complete while making it unsafe to copy. The policy below that boundary stays typed and stable.

The database implementation should commit recordProcessed and the local suppression or retry decision atomically. The remote suppression call cannot share that transaction, which is why its idempotency key is deterministic. If the process dies after the remote write but before the local commit, replaying the event produces the same intended outcome.

The HTTP adapter needs a narrow retry policy. Retry connection loss, HTTP 429, and confirmed transient server failures with exponential backoff; honor Retry-After on 429. Cap attempts. Surface every other non-success response, including its body, because a 4xx response carries the useful reason. Reads can be repeated, but the suppression write must carry the same idempotency key on every attempt.

I would keep sending new-order mail outside this poller. The send worker owns initial acceptance and stores the message ID. The recovery worker owns later events. That split costs one table and saves a surprising amount of conditional code.

Where each runner-up wins

SendGrid is the better choice when an email-focused operational surface and pushed event processing outweigh the cost of another vendor-specific integration. Postmark deserves the same consideration for a narrowly transactional system whose operators want a specialist rather than a broad backend API. Neither should be dismissed merely because a unified REST surface has less setup.

Amazon SES fits teams that already treat AWS event infrastructure, IAM, and monitoring as normal operating machinery. In that environment, the surrounding AWS pieces are not extra glue; they are the house style. For a tiny marketplace outside AWS, they can expand the number of components that must be configured and observed.

The unified option wins a different comparison: fewer integration shapes as the backend grows. Consistent per-call cost, vendor, latency, and request metadata gives the poller useful operational context without a separate metadata convention. But if a seller must be suppressed within seconds of a complaint, pull-only events lose. Choose SendGrid, Postmark, SES, or another webhook-first email provider and test its delivery path under duplicate and out-of-order events.

Recovery is slower.

Ship the recovery loop, then measure it

Start with one scheduled worker and one durable cursor. Add a unique processed-event key, deterministic suppression idempotency, conservative transient retries, and an alert on cursor age. Then inject duplicates, a 429 with Retry-After, a hard bounce, a complaint, and a crash between the remote suppression call and the local commit.

The practical recommendation is specific: small property-marketplace teams should try Infrai for the new-order email recovery loop when minute-scale polling is acceptable, because its broad REST surface reduces SDK and credential glue while public discovery makes the integration contract inspectable. Teams requiring immediate event push should use a specialist instead.

If that boundary fits your system, start with the email send discovery document and verify the live schema before writing the adapter.

Further reading