DEV Community

PerNilsson3147
PerNilsson3147

Posted on

Transactional Email Bounce Complaint Handling with Polling Event Suppression

TL;DR: For a property-management password reset, use the least complex design that preserves evidence: send once, poll delivery feedback with a durable cursor, record every source event idempotently, and derive suppression separately from retry eligibility. A complaint or permanent recipient failure suppresses future email to that address. A temporary failure can be retried only while the short-lived reset token is still useful. Never resend the original reset email after its token expires; issue a fresh token only after a fresh user request.

This separation matters more than a clever mail API. A maintenance coordinator locked out before a shift needs one valid reset message, while the application needs to avoid turning delayed feedback into duplicate mail. The integration should optimize for a small, auditable state machine rather than provider-specific convenience.

How should a Node.js polling job handle email bounce and complaint events?

The job should guarantee durable observation, not exactly-once delivery. It fetches a bounded page after a stored cursor, normalizes each item, inserts it under a unique source-event key, updates message and recipient state in the same database transaction, then advances the cursor. If the process dies before commit, it reads the page again; if it dies after commit, the cursor has already moved. Replayed events become harmless no-ops because the event key is unique. This is deliberately an at-least-once consumer, so duplicate input is expected rather than exceptional, and the database transaction is the unit that makes a page safe to repeat.

Crashes happen.

Keep three records distinct: the reset request, the outbound message attempt, and the delivery event. The reset request owns the token hash and expiry. A message attempt owns the source message ID and send time. An event owns the source event ID, observed classification, and raw payload needed for later diagnosis. That model survives out-of-order events: an accepted event arriving after a bounce stays in the ledger but cannot move a terminal message back to accepted.

Here is a compact TypeScript example. The interfaces mark the two places a production service would connect to a feedback source and a transactional database. Event names are deliberately normalized because transport vocabularies differ.

import { createHash } from "node:crypto";

type DeliveryKind =
  | "accepted"
  | "delivered"
  | "temporary_failure"
  | "permanent_failure"
  | "complaint";

type DeliveryEvent = {
  sourceEventId: string;
  messageId: string;
  recipient: string;
  kind: DeliveryKind;
  occurredAt: string;
};

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

interface FeedbackSource {
  poll(cursor: string | null, limit: number): Promise<Page>;
}

interface DeliveryStore {
  cursor(): Promise<string | null>;
  transaction<T>(work: (tx: DeliveryTransaction) => Promise<T>): Promise<T>;
}

interface DeliveryTransaction {
  insertEventOnce(event: DeliveryEvent): Promise<boolean>;
  markMessage(messageId: string, kind: DeliveryKind): Promise<void>;
  suppress(recipientHash: string, reason: string): Promise<void>;
  advanceCursor(cursor: string): Promise<void>;
}

const recipientKey = (address: string) =>
  createHash("sha256").update(address.trim().toLowerCase()).digest("hex");

async function pollOnce(source: FeedbackSource, store: DeliveryStore) {
  const start = await store.cursor();
  const page = await source.poll(start, 100);

  await store.transaction(async (tx) => {
    for (const event of page.events) {
      const inserted = await tx.insertEventOnce(event);
      if (!inserted) continue;

      await tx.markMessage(event.messageId, event.kind);
      if (event.kind === "complaint" || event.kind === "permanent_failure") {
        await tx.suppress(recipientKey(event.recipient), event.kind);
      }
    }

    if (page.nextCursor !== null) {
      await tx.advanceCursor(page.nextCursor);
    }
  });

  return page.events.length;
}
Enter fullscreen mode Exit fullscreen mode

In a real database, insertEventOnce needs a unique constraint on the source plus source event ID. The transaction also needs a monotonic transition rule. For example, accepted may become delivered or a failure, but a late accepted event must not overwrite complaint. Arrival order is not meaning.

There is one uncomfortable edge. Some feeds may not provide an immutable event ID. Build a documented deduplication key from stable supplied fields only if the source contract supports that combination. Hashing an entire JSON body is a weak fallback because field order and incidental metadata can change; it looks deterministic in a unit test, then fails as soon as an added metadata field changes the bytes without changing the event. The trade-off is explicit: a composite key can collapse two genuinely distinct events if its documented fields aren't unique, while whole-body hashing can miss duplicates that differ only in decoration. When neither choice is supported by the source contract, retain both events and make the state transition idempotent instead of pretending the evidence is cleaner than it is.

Make suppression a policy decision

A suppression table answers whether another email may be sent to a recipient. It is not the event ledger, and it should not be hidden inside retry code. Store a normalized recipient key, reason, source event, creation time, and policy status. Restrict access to the original address; a hash reduces casual exposure but does not make a small email-address space anonymous.

Complaints and permanent recipient failures are terminal inputs for this transactional email channel. Temporary failures are different. Enhanced mail status codes define persistent and temporary classes, but the application still has to map the feedback source's values to those classes. Keep that mapping in reviewed configuration and retain the raw event. Unknown values go to review rather than defaulting to retry.

Password reset adds a security boundary. OWASP recommends a consistent response for existing and nonexistent accounts, cryptographically random single-use tokens, secure storage, and expiry. The user-facing request endpoint should therefore return the same shape and comparable timing even when the address is suppressed. Internal observability can record suppressed, but the response must not reveal account existence.

One-click unsubscribe is a different mechanism. RFC 8058 specifies one-click behavior for list email through List-Unsubscribe headers and an HTTPS POST. A password-reset message is user-triggered transactional mail, not a subscription campaign. Do not bolt a marketing unsubscribe action onto the reset flow. Complaint feedback still matters as a channel-safety signal.

Retries must fit inside the token lifetime

The retry decision uses four facts: normalized event kind, attempt count, next eligible time, and token expiry. A temporary failure may schedule another send attempt with backoff, but only when enough lifetime remains for the message to arrive and be used. Once expired, mark that reset request finished.

No rescue send.

That rule prevents a subtle failure mode: a retry queue can be technically healthy while delivering a dead credential. It also limits duplicate messages when feedback is delayed. Use one active reset request per account, and have a newer request supersede the older one. The reset handler must atomically consume the token so two clicks cannot change the password twice.

Do not retry complaints, permanent failures, malformed addresses, or unknown classifications. A transport timeout is not proof that no send occurred. Persist an idempotency key before the send call and reconcile the eventual source message ID; otherwise a client timeout followed by a blind retry can create two accepted messages.

This pattern has real limitations: it adds a ledger, cursor state, transition rules, and an operator path for unknown events. A low-volume internal tool with no polling feed may be better served by manual review or a simpler callback consumer. Polling is not suitable when the required response time is shorter than the source's event availability or when the source cannot expose a stable cursor. In those cases, use a signed callback where available, but keep the same ledger and idempotent consumer. The trade-off is operational control in exchange for more application-owned state.

That isn't free.

Integration effort stays bounded when the transport adapter does only three jobs: send a message, fetch a page, and normalize feedback. The application owns policy. This costs schema work up front, but it prevents a transport migration from rewriting reset security and suppression behavior.

Operate the loop without waking users twice

Run one poller at a time per feedback stream, or use a database lease that expires if the worker disappears. Bound page size and processing time. Track cursor age, oldest unprocessed event age, events by normalized kind, duplicate count, unknown classification count, and suppression writes. These are operational signals, not delivery promises.

Test replay explicitly. Feed the same page twice and verify one ledger row and one suppression record. Then crash the test worker immediately before commit and immediately after commit. Deliver events out of order. Include a temporary failure received after expiry, a complaint received before an accepted event, and two reset requests for the same property manager. The final state should be identical across every replay.

Deployment can be quiet: create the ledger and cursor tables, ship the poller in observe-only mode, compare normalized counts with the source, then enable state transitions and suppression. Preserve raw payloads according to a defined retention policy, with secrets and unnecessary personal data excluded from logs. Alert when the cursor stops advancing while the source still returns data, not merely when a single poll is empty.

The final operational check is prose-sized. Confirm the unique event constraint exists, cursor advancement shares the event transaction, terminal states cannot regress, suppression is checked before enqueueing, expired tokens cannot be retried, account enumeration remains blocked, and an operator can inspect unknown classifications. Then send synthetic events through the deployed path and verify the ledger rather than trusting a successful job exit.

The result is intentionally plain: durable evidence enters once, policy is deterministic, and an expired password-reset message stays dead. That is the right amount of machinery for a small team that needs trustworthy delivery feedback without binding account security to one mail service.

References

Top comments (0)