DEV Community

leiferiksson8493
leiferiksson8493

Posted on

How to Handle Polled Email API Bounce Events — Node.js Complaint Suppression

Short answer: Choose an email API with durable, cursor-based event polling when integration effort matters more than second-by-second bounce and complaint updates; keep one local suppression table and check it before every password-reset send.

Choice Integration work Signal latency Operational burden Best fit
Polling Outbound worker plus scheduled job Bounded by poll interval Cursor, lock, retry state Modest reset volume
Webhooks Public inbound endpoint Event-driven Verification and replay handling Tight reaction deadline
Mailbox processing Mailbox access and parsing Mail-dependent Parser and mailbox operations Legacy sender without events

Recommendation: start with polling if the provider documents durable, ordered events. The catch is latency: choose webhooks when a complaint or bounce must affect another send within seconds.

What recovery contract should polled SaaS email API events provide?

The loop needs four properties: a stable cursor, idempotent event application, a single active poller, and an explicit delay target. If an API cannot document how long events remain available, how pagination works, or what happens when a cursor is reused, it is a poor fit for polling. Those details matter more than a long feature checklist because they determine whether a missed run can be recovered.

Set the interval from the business deadline, not from habit. For a property portal with a short-expiry password reset, the user-facing request should return without waiting for the event poller. The poller protects later sends; it does not make the current delivery synchronous. A five-minute schedule means a newly reported hard bounce may remain unknown locally for almost five minutes, plus processing time. That may be fine for a low-volume portal. It is not fine if another workflow can send repeatedly during that window. Your mileage may vary because the acceptable delay depends on send frequency and abuse controls, not company size.

Keep one cursor per event stream and commit it only after every event on the page has been applied. Don't advance first and fill the database later. A crash between those operations creates a quiet gap, which is harder to spot than a duplicate. Replaying a page is safe when application is idempotent. Missing a page isn't.

One boundary matters: an unsubscribe mechanism and a password-reset suppression policy solve different problems. RFC 8058 defines one-click unsubscribe for list email. A transactional password-reset message is not a reason to ignore bounce or complaint signals, but the local decision should remain typed by signal and message class rather than collapsing every address state into one boolean.

Why does complaint suppression need an ordered state model?

The first criterion is recoverability. Ask for the event-retention window, cursor lifetime, sort order, pagination contract, and duplicate behavior. Then compare retention with the longest realistic outage of your scheduled worker. If a weekend deployment pause could outlast retention, polling is unsafe no matter how clean the API looks. This is a contract question. Get the answer in writing. Consider a concrete restart: the poller commits cursor c41, fetches the page after it, applies three events, and stops before committing c42. On restart it must request from c41, see those three event IDs again, ignore their duplicate effects, apply any remaining records, and only then commit c42. If the provider's cursor advances when read, or if the retention window can expire while the worker is paused, that recovery sequence cannot be proven. Reject the integration or move the durable handoff to an authenticated webhook queue.

Order matters.

The second criterion is the machinery you must own. Polling uses outbound HTTPS, which usually fits beside existing jobs. Webhooks require an inbound route, request authentication, replay protection, rate limits, and a queue or durable handoff before acknowledgement. Neither pattern removes retries. They put retries on different sides of the boundary.

For a one-person SaaS, use a revenue-per-hour lens: does owning the extra path improve tenant experience enough to displace the feature planned for this week's release? There is no universal winner. A boring scheduled worker can be right at low volume, while an inbound event pipeline earns its keep when latency or throughput is tied to revenue. I'm not sure which side your workload lands on until you measure event rate and the maximum acceptable suppression delay. Those two numbers resolve the choice.

Before selecting an API, run a small acceptance test against its documented test mode. Produce one event of each supported class, stop the poller for longer than one normal interval, restart it, replay the last cursor, and confirm that final suppression state is unchanged. Also test an empty page and a page boundary. This takes less time than debugging a reset flow after launch.

Track the age of the newest observed event, the age of the committed cursor, pages processed per run, duplicate count, suppression changes by reason, rate-limit responses, and runs that hit the page cap. Alert on stale progress relative to the chosen delay target. On a slower schedule, compare local suppression state with any authoritative suppression export the selected API documents. The goal is to detect drift caused by retention mistakes, credential changes, or a worker that runs but never advances.

A green scheduled-job status is weak evidence.

Build one replaceable TypeScript event adapter

Keep provider vocabulary outside the application core. The adapter below expects a documented event URL in EMAIL_EVENTS_URL; email APIs do not share a universal route. It also expects opaque cursors. Do not parse or increment them.

type DeliveryEvent = {
  id: string;
  type: "hard_bounce" | "soft_bounce" | "complaint" | "delivered";
  recipient: string;
  occurredAt: string;
};

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

interface EventSource {
  list(cursor: string | null, signal: AbortSignal): Promise<EventPage>;
}

class RateLimitedError extends Error {
  constructor(readonly retryAfterSeconds: number) {
    super("Event polling was rate limited");
  }
}

class HttpEventSource implements EventSource {
  constructor(
    private readonly url: string,
    private readonly token: string,
  ) {}

  async list(cursor: string | null, signal: AbortSignal): Promise<EventPage> {
    const url = new URL(this.url);
    if (cursor) url.searchParams.set("cursor", cursor);

    const response = await fetch(url, {
      headers: { authorization: `Bearer ${this.token}` },
      signal,
    });

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "5");
      throw new RateLimitedError(retryAfter);
    }
    if (!response.ok) throw new Error(`Event poll failed: ${response.status}`);
    return (await response.json()) as EventPage;
  }
}
Enter fullscreen mode Exit fullscreen mode

The database transaction is the important part. Make delivery_events.id unique, and update the cursor in the same transaction as event effects. A complaint or hard bounce can suppress future email. A soft bounce should follow a documented retry policy rather than becoming permanent suppression by accident. Delivered events help monitoring but should not erase a later, stronger suppression signal.

type Tx = {
  hasEvent(id: string): Promise<boolean>;
  recordEvent(event: DeliveryEvent): Promise<void>;
  suppress(recipient: string, reason: "hard_bounce" | "complaint"): Promise<void>;
  saveCursor(cursor: string | null): Promise<void>;
};

interface Store {
  loadCursor(): Promise<string | null>;
  transaction<T>(work: (tx: Tx) => Promise<T>): Promise<T>;
}

async function applyPage(store: Store, page: EventPage): Promise<void> {
  await store.transaction(async (tx) => {
    for (const event of page.events) {
      if (await tx.hasEvent(event.id)) continue;
      await tx.recordEvent(event);
      if (event.type === "hard_bounce" || event.type === "complaint") {
        await tx.suppress(event.recipient.toLowerCase(), event.type);
      }
    }
    await tx.saveCursor(page.nextCursor);
  });
}
Enter fullscreen mode Exit fullscreen mode

Run only one poller for a stream. A database advisory lock, lease row, or scheduler singleton can enforce that rule; pick the primitive your stack already operates. Outsource the undifferentiated work to existing infrastructure, but keep the correctness rule visible in code review.

The worker should cap each run so a backlog cannot monopolize the process. It should also respect provider-directed backoff. This loop uses a page cap and abort timeout; production wiring supplies the lock and persistent store.

const sleep = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

async function pollOnce(source: EventSource, store: Store): Promise<void> {
  let cursor = await store.loadCursor();

  for (let pageCount = 0; pageCount < 100; pageCount += 1) {
    try {
      const page = await source.list(cursor, AbortSignal.timeout(10_000));
      await applyPage(store, page);
      cursor = page.nextCursor;
      if (!cursor || page.events.length === 0) return;
    } catch (error) {
      if (error instanceof RateLimitedError) {
        await sleep(error.retryAfterSeconds * 1_000);
        continue;
      }
      throw error;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Check suppression immediately before enqueueing a reset message, not when the request first arrives. State can change between request validation and queue execution. Keep the public response neutral so the endpoint does not reveal whether a tenant email exists. For a short-expiry reset, store an expiring, single-use token and compare expiry on redemption; RFC 6238 is relevant only if the system uses time-based one-time passwords, not ordinary reset links.

Test the password-reset path as a state machine

The reset-path test should cover the state transitions, not merely a successful poll. Start with an eligible address, apply a complaint, and assert that the next queued reset is suppressed. Replay the complaint and assert that neither the state nor the audit count changes. Then deliver an older delivered event and confirm it cannot clear the newer complaint. This chore doesn't sell a subscription — it protects the reset path customers use when they're already frustrated.

Do not treat opens as delivery proof. The useful operational question is narrower: did the provider accept the message, did a structured failure or complaint arrive, and did local policy change before another eligible send? Keep raw events long enough to investigate within your privacy and retention policy, while storing normalized state for the hot send path.

Ship the first dashboard with three panels: cursor age, event lag, and suppressions by reason. Fancy charts can wait.

Keep it dull.

Where does polling stop paying for itself?

Polling is not suitable when the reaction deadline is shorter than a responsible polling interval, when event volume makes repeated listing wasteful, or when the API cannot guarantee enough retention to cover worker downtime. Stick with webhooks when seconds matter and you already operate a secure inbound event pipeline. The runner-up becomes better because its operational cost is already paid.

Mailbox processing is a last practical option for a legacy sender without a structured event feed. Its catch is parsing and mailbox ownership, so isolate it behind the same EventSource interface and plan an exit. Do not let mailbox formats leak into password-reset code.

A polling design does not fix domain authentication, message content, or reputation. It closes the feedback loop after sending. One-click unsubscribe under RFC 8058 belongs in qualifying list mail; it is not a substitute for complaint suppression, and it should not be bolted onto a password-reset message merely to make a monitoring checklist look complete.

The decision rule stays simple: choose the least integration work that still meets a written recovery window and suppression deadline. Then test cursor replay before launch. Everything else is secondary.

References

Top comments (0)