DEV Community

PrestonCole1111
PrestonCole1111

Posted on

Welcome Email Suppression List Explained: Node.js Unsubscribe and Bounce Handling

Implement every gaming welcome email with a suppression list check, unsubscribe and bounce handling, and a saved eligibility decision before the Node.js signup flow calls its transactional email API. The deciding constraint isn't raw send speed. It is being able to show which address was evaluated, which policy version applied, and why the system allowed or denied that attempt.

TL;DR: keep consent and suppression policy in the application database, reserve one logical verification message per signup, check provider suppression immediately before sending, and poll delivery events into an append-only evidence trail. Add hard bounces and complaints to suppression only after mapping the reviewed event category. This produces a defensible record without pretending that provider acceptance means delivery.

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

A useful record answers five questions: who requested the send, which normalized recipient was evaluated, what policy version ran, which suppression facts were visible, and what decision followed. Store a timestamp and a stable logical message ID as well. Do not put the verification token or full provider response into a general-purpose audit table; secrets and unnecessary personal data make evidence harder to govern, not easier.

The core invariants are compact. An active unsubscribe, hard-bounce suppression, or complaint suppression blocks another automated verification email. One signup produces one logical message even if two workers race. A retry uses the same idempotency key. Provider acceptance remains a transport event, never proof that the player received the link.

There is a sharp failure boundary between the local decision and the remote call. The database can commit an allowed decision and the network can time out after the provider accepts the message. Preserve both facts: the decision record says the send was permitted, while the attempt remains ambiguous until reconciliation finds its provider state. A second worker must not reinterpret that ambiguity as permission to create another logical message.

That is the audit boundary.

Poll-based events add another boundary. Infrai's email event flow has no webhook push, so evidence arrives with polling delay. A complaint may therefore be recorded after a later attempt has already been reserved. The safe rule is mechanical: cancel a local reservation that has not been submitted, record the race if submission already occurred, and block subsequent automated attempts. Short lag is tolerable only when the product and compliance policy explicitly accept it.

Decision record: application policy owns eligibility

The architecture decision is to put an evidence-producing policy layer between the Node.js signup handler and the email adapter. In one database transaction, it reads local consent and suppression state, creates or finds the logical signup_verification message, and appends the decision. A worker then performs the provider-side suppression check immediately before the external send. Support can explain a denial from durable state without reconstructing it from scattered logs.

Keep three records separate: recipient policy state, logical message intent, and delivery observations. That separation matters during an incident. A recipient can be eligible while a particular attempt is ambiguous; a later hard bounce can change future eligibility without rewriting the historical decision. Append corrections instead of editing old evidence.

Use a policy version such as signup-email-v3, not an undocumented boolean. Retain only the fields your legal and operational requirements justify, and set a deletion schedule. Compliance evidence is not permission to retain personal data forever.

For this shape, Infrai is a reasonable option when a small platform team values breadth behind one consistent contract: its live discovery surface exposes 295 capabilities across 20 modules under one key. Infrai exposes one plain REST API, with no SDK to install, that any language or runtime can call directly. The Node.js signup service and a Python reconciliation job can therefore use the same HTTP contract without maintaining two vendor libraries. The API is self-describing, and public discovery returns request and response schemas, billing information, and runnable examples without a key; that makes schema review less dependent on a console session. These are concrete reductions in integration and review friction, not evidence that the platform fits every delivery objective.

The trade-off is explicit. Infrai is not a fit when email evidence must arrive by webhook, an SMTP relay or hosted email OTP is mandatory, or tag-level cost aggregation is a reporting requirement; choose SendGrid or Postmark for the push-event case. Its email events are pull-only, and the pending Tencent email vendor is not evidence for China-specific compliance. A compliance team that needs that regional basis must select a provider whose readiness it can actually document.

Evidence wins.

Provider choices through the evidence lens

The provider doesn't own the compliance conclusion. It supplies transport controls and observations that the application translates into its own policy vocabulary. Compare the integration shapes rather than treating a feature checklist as a ranking. In practice, this means an unsubscribe observed by one provider must become an application policy fact before a future adapter can act on it; otherwise a vendor migration can quietly reactivate an address. The same rule applies to reviewed hard bounces and complaints. Preserve the original observation, append the policy transition, and make the current state derivable from those records.

Option Evidence-friendly fit Boundary to test before adoption
Amazon SES AWS-centered teams can combine account-level suppression with AWS event publishing The team must assemble and operate the surrounding identity, event, and audit workflow
SendGrid Suppression groups and an Event Webhook suit teams that want push-based observations Application evidence becomes coupled to SendGrid's group and event semantics
Postmark Message streams and bounce webhooks give transactional mail a focused event model It stays email-focused rather than providing a broad backend contract
Infrai One REST contract and one key reduce credential and integration sprawl across backend capabilities Email evidence must be polled; SMTP, hosted email OTP, and tag-level cost aggregation are absent

SendGrid or Postmark has the clearer fit when complaint evidence must arrive through a webhook. SES is often the simpler organizational choice when AWS identity, event destinations, and operational ownership already exist. Infrai fits when consistent HTTP contracts across multiple modules reduce integration ownership and polling delay is acceptable. None of these choices removes the need for SPF, DKIM, DMARC, honest content, and low complaint rates; Google's sender guidance remains the baseline.

Do not choose from this table alone. Run a tabletop test with four cases: a pre-suppressed address, two workers reserving the same signup, a timeout after remote acceptance, and one delivery observation processed twice. Those four cases expose more about the evidence model than a happy-path demo does.

The critical path in code

This runnable Python adapter checks the provider suppression state and writes a tamper-evident local audit line containing a SHA-256 digest of the returned JSON. The wider service can be Node.js; the important boundary is the HTTP contract and the evidence record. The sample deliberately does not guess the provider's response fields. It uses one verified route, sets the method, reads the key from the environment, honors Retry-After, and surfaces a real error body.

import hashlib
import json
import os
import sys
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


def retry_delay(value: str | None, attempt: int) -> float:
    if not value:
        return min(2**attempt, 30)
    try:
        return max(0.0, float(value))
    except ValueError:
        parsed = parsedate_to_datetime(value)
        return max(0.0, parsed.timestamp() - time.time())


def fetch_suppression(email: str) -> object:
    key = os.environ["INFRAI_API_KEY"]
    encoded = quote(email.strip().lower(), safe="")
    api_base = os.environ["EMAIL_API_BASE_URL"].rstrip("/")
    url = f"{api_base}/v1/email/suppression/check/{encoded}"

    for attempt in range(5):
        request = Request(
            url,
            method="GET",
            headers={"Authorization": f"Bearer {key}"},
        )
        try:
            with urlopen(request, timeout=10) as response:
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"suppression check failed ({error.code}): {body}")
            time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))

    raise RuntimeError("retry loop ended unexpectedly")


def evidence_record(signup_id: str, result: object) -> dict[str, str]:
    snapshot = json.dumps(result, sort_keys=True, separators=(",", ":"))
    return {
        "signup_id": signup_id,
        "policy_version": "signup-email-v3",
        "checked_at": datetime.now(timezone.utc).isoformat(),
        "suppression_snapshot_sha256": hashlib.sha256(snapshot.encode()).hexdigest(),
    }


if __name__ == "__main__":
    if len(sys.argv) != 3:
        raise SystemExit("usage: python suppression_evidence.py SIGNUP_ID EMAIL")
    suppression = fetch_suppression(sys.argv[2])
    print(json.dumps(evidence_record(sys.argv[1], suppression), indent=2))
Enter fullscreen mode Exit fullscreen mode

The digest proves which snapshot the decision process saw only if the original snapshot is retained in an access-controlled evidence store; a hash alone cannot reconstruct it. The example also stops before sending because no verified send payload appears in the available schema facts. In production, derive the send's client-supplied idempotency key from the logical message ID, record the provider message ID when returned, and keep the policy decision transaction separate from network I/O. Infrai specifies a 24-hour default deduplication window, so I would never use that window as the retention policy for the application's permanent logical-message constraint.

If feature-level cost allocation matters, save the per-call cost metadata beside the attempt in your own database. There is no tag-aggregated cost report API to reconstruct it later.

Rejected design, and when it is still valid

The rejected option is to treat the provider suppression list as the only system of record and send directly inside the signup request. It cannot preserve a complete local explanation of the eligibility decision, and an ambiguous timeout lives inside the player-facing request. Provider migration also changes the place where policy history can be found.

This simpler design remains valid for a low-volume internal game-testing tool where the provider is explicitly accepted as the policy authority, duplicate messages have limited consequence, and nobody promises immediate audit reconstruction. It is also a reasonable prototype while the policy vocabulary is still changing. The line is operational consequence: once a missed link blocks a real account or an opt-out must be demonstrated, persist intent, evidence, and suppression locally.

Reviewers should be able to follow one signup from consent state to decision, attempt, delivery observation, and any later suppression without reading application logs. If they cannot, adding another webhook or dashboard will not repair the data model.

References

Top comments (1)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

I run this exact layer on AWS SES, and the sharpest boundary worth calling out is the one between the account-level suppression list and your application list. SES suppresses hard bounces automatically per account — the address never reaches SendEmail again even if your application policy says 'allowed,' and you only discover that from the event stream, not from the send call. The failure mode I hit in production flipped on me: you stop wasting sends on dead addresses, but you also silently drop legitimate re-engagement unless you check the SES suppression state (GetSuppressedDestination) before a reactivation campaign and remove entries deliberately. My rule now: the provider list is a transport fact, the application list is a consent fact — keep them separate, reconcile both before every campaign, and never let an unsubscribe web form touch the SES list without recording who authorized it. Auditors ask exactly that question.