DEV Community

BrodyVance2149
BrodyVance2149

Posted on

Node.js Property Email Bounce Handling — Auditable Complaint Event Polling

TL;DR: In a property-management notification system, treat delivery events as evidence, not as instructions to mutate a mailing list directly. Record each raw event once, derive a versioned recipient decision, and make every sender check that decision immediately before accepting work. Polling retries are then harmless, complaints stop future mail without waiting for another batch, and an auditor can reconstruct why a lease notice was or was not attempted. The trade-off is extra state and a stricter send path; that is preferable to a dashboard that says "processed" while invalid addresses continue to receive retries.

The useful question at 3 a.m. is not how many bounces appear on a chart. It is: what page fired, which recipient state changed, and can the exact input behind that change still be shown?

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

Consider a bounded failure: a property platform polls a delivery-event feed for rent receipts, maintenance updates, and account-recovery messages. The worker reads a page, applies several suppressions, then loses its lease before saving the next cursor. Its replacement reads the same page. At the same time, a complaint for a tenant arrives after a renewal reminder has already entered the local queue. Nothing here requires a provider outage; duplicate delivery and ordinary concurrency are enough to expose a weak design.

A weak postmortem says the poller retried and the dashboard eventually caught up. A useful one names the invariant that failed: one external observation must produce one durable fact, while any number of observations may produce the same current recipient state. The cursor is progress metadata, not proof that a particular event was handled. If the system updates only a suppressed boolean and advances the cursor, it destroys the evidence needed to distinguish a duplicate from a missed event.

The second invariant sits on the other side of the queue: no send may be admitted from stale eligibility alone. A job created before a complaint can still be present after the complaint. The final suppression check therefore belongs at send admission, not merely when a transactional job is created. This closes the race without asking the queue to recall every pending job perfectly.

No dashboard proves either invariant.

Keep an evidence ledger, then derive recipient state

Use two layers. The append-only ledger stores the source-scoped event identifier, normalized recipient key, observed event kind, source timestamp, ingestion timestamp, source account, and a hash or pointer for the retained raw payload. The derived table stores the current decision: allowed, temporarily deferred, or suppressed; the reason; the evidence event; the policy version; and the decision time. Retention and access controls belong in the design because addresses and payloads are operational data, not debugging confetti.

That separation handles a detail that gets lost in cheerful diagrams: evidence and policy change at different speeds. A complaint can be a durable suppression input while a temporary delivery failure may only defer an attempt under a bounded retry policy. If policy changes next quarter, the old decision must remain explainable under its old policy version. Replaying facts into a new projection is safer than rewriting history.

The state transition should be monotonic where compliance requires it. A later success event must not silently clear a complaint-derived suppression. Re-enablement needs a separate, authenticated consent or correction event, its own actor and timestamp, and an explicit policy rule. Otherwise an out-of-order feed can convert "do not send" into "send" just because the newest timestamp won a generic last-write-wins update.

For one-click unsubscribe, implement the protocol rather than copying a convenient-looking header. RFC 8058 defines an HTTPS POST mechanism and the List-Unsubscribe-Post: List-Unsubscribe=One-Click header used with the matching List-Unsubscribe URL. That mechanism concerns list mail; do not make an unsubscribe link the control plane for password-reset delivery. Account recovery has a different threat model, and OWASP guidance calls for consistent responses, cryptographically secure random tokens or codes, secure storage, single use, and expiry. A property system may suppress marketing while still allowing a narrowly classified security message under its documented policy. The category decision must be explicit in code and evidence, not inferred from a subject line.

Make the poller retry the page, not repeat the side effect

A Node.js service can own the API and queue while a Go worker implements the same storage contract; language is not the safety boundary. The transaction is. This core deliberately omits transport and SQL-driver details so the important ordering remains visible.

package delivery

import (
    "context"
    "errors"
)

type Event struct {
    Source, ID, RecipientKey, Kind, PayloadHash string
}

type Tx interface {
    InsertEventOnce(context.Context, Event) (bool, error)
    ApplyPolicy(context.Context, Event, string) error
    SaveCursor(context.Context, string, string) error
    Commit() error
    Rollback() error
}

type Store interface {
    Begin(context.Context) (Tx, error)
}

func ApplyPage(ctx context.Context, store Store, cursor, next string, events []Event) (err error) {
    tx, err := store.Begin(ctx)
    if err != nil {
        return err
    }
    defer func() {
        if err != nil {
            _ = tx.Rollback()
        }
    }()

    for _, event := range events {
        inserted, insertErr := tx.InsertEventOnce(ctx, event)
        if insertErr != nil {
            return insertErr
        }
        if inserted {
            if err = tx.ApplyPolicy(ctx, event, "property-mail-v3"); err != nil {
                return err
            }
        }
    }
    if next == cursor && len(events) > 0 {
        return errors.New("non-advancing cursor with non-empty page")
    }
    if err = tx.SaveCursor(ctx, cursor, next); err != nil {
        return err
    }
    return tx.Commit()
}
Enter fullscreen mode Exit fullscreen mode

Put a unique constraint on (source, event_id). Event insertion, policy application, and cursor update run in the same database transaction. If the commit result is ambiguous, retry the whole page: the unique key turns repeats into reads of established facts rather than repeated side effects. Do not checkpoint a page first and promise to fill the ledger later. That ordering creates an unprovable gap.

A poison event needs a visible terminal state, not an infinite hot loop. Preserve its raw evidence, record the validation failure, stop cursor advancement for that partition or move it through a documented quarantine transaction, and page only when age or volume breaches an objective. Silently skipping malformed evidence makes the pipeline look healthy by erasing the item an investigator needs.

Backoff still matters, but it is load control rather than correctness. Use bounded exponential delay with jitter for retriable fetch failures, respect cancellation and lease expiry, and keep only one active owner per partition. A longer sleep cannot repair non-idempotent storage.

Close the queue race at send admission

The send path should ask a narrow question inside a consistent operation: is this recipient eligible for this message class under the current policy version? Checking only at enqueue time leaves a window between complaint ingestion and delivery. Checking only in a nightly export leaves a much larger one.

package delivery

import (
    "context"
    "errors"
)

var ErrSuppressed = errors.New("recipient suppressed for message class")

type Message struct {
    ID, RecipientKey, Class string
}

type AdmissionStore interface {
    ReserveIfEligible(context.Context, Message) (bool, string, error)
}

func Admit(ctx context.Context, store AdmissionStore, message Message) error {
    reserved, _, err := store.ReserveIfEligible(ctx, message)
    if err != nil {
        return err
    }
    if !reserved {
        return ErrSuppressed
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

ReserveIfEligible should atomically record the message identifier, recipient key, class, decision, evidence reference, and policy version. Repeating it with the same message identifier must return the established result. The transport call comes after that reservation and needs its own idempotency strategy where supported; otherwise record the ambiguous outcome and reconcile it rather than guessing that a timeout means no attempt occurred.

This is also where message taxonomy earns its keep. Rent receipts, building announcements, promotional offers, and password recovery do not share one legal or security purpose. This article cannot supply a jurisdiction-specific legal rule, but the system can require that counsel-approved policy maps every message class to permitted states and evidence. Unknown classes should fail closed.

Two words. No send.

Test the failures the happy path hides

Unit tests for event mapping are necessary and unimpressive. The deployment gate should exercise duplicates within one page, the same page after a simulated lost commit response, events arriving out of order, a complaint racing a queued message, a non-advancing cursor, cancellation during backoff, and two workers contesting one partition lease. For each case, assert ledger cardinality, derived state, cursor position, and the admission record; an HTTP status alone says little.

Run a shadow projection before changing policy. Feed retained events into the candidate policy version, compare decisions by recipient and message class, and review every change from suppressed to allowed. Promotion should be reversible at the projection pointer, while the source ledger remains untouched. This is slower than editing a boolean in production, which is exactly why it leaves evidence.

Operational signals should follow the causal chain:

  • oldest uncommitted event age;
  • repeated page and quarantine counts;
  • partition lease contention;
  • decision latency;
  • sends denied at admission.

Keep low-cardinality aggregates for alerting and attach identifiers to traces or searchable logs under access control. A page that says "bounce rate high" provides no immediate action; a page that says a partition has made no durable progress for an objective window does.

Deploy schema changes before workers depend on them, run mixed-version tests, and ensure an older worker cannot erase a newer policy decision. The rollback plan must preserve ledger writes. Turning off ingestion during rollback creates another evidence gap, so decouple accepting facts from projecting them whenever a policy deployment can fail independently.

When is this design too much?

For a local tool that sends no external email and retains no recipient identity, a durable event ledger may add machinery without reducing meaningful risk. A synchronous system with one process and a transport that returns a final outcome before the request completes may also need fewer moving parts. Even there, retain an explicit suppression decision if future sends are possible.

For property communications, the threshold is crossed early: multiple message purposes, delayed delivery outcomes, tenant disputes, and workers that can restart make a mutable list inadequate. The correct acceptance test is not that a poll completes. It is that a reviewer can start from a disputed message, reach the admission decision and policy version, then reach immutable delivery or complaint evidence without depending on a vendor dashboard. Build that chain first; retries become routine after it exists.

References

Top comments (0)