DEV Community

QuintonShaw1483
QuintonShaw1483

Posted on

Email List Hygiene Explained: Sync Suppression Events for Transactional App Deliverability

Short answer: a transactional app should own recipient status, poll provider suppression and delivery events into that state, and block a send whenever the local record says the address has unsubscribed, bounced, or complained.

For a Node.js B2B SaaS product sending a compliance notice, the delivery vendor is transport, not the system of record. The auditable chain belongs in the application: notice version, intended recipient, send decision, provider request identifier, observed delivery events, and the status transition that prevents another bad send. Basic list hygiene works well with that boundary. It is less suitable when the product needs real-time, multi-channel orchestration, because a polling-only event path puts detection latency under the application's control.

The operational recommendation is plain: set a hygiene-lag SLO, size the polling job for the event backlog, and make the send path consult local state before every compliance message. Don't let a provider dashboard become the only place where a bounce or complaint exists.

How should a transactional app sync email suppression events for deliverability?

Start with a small recipient-state machine. active may send; unsubscribed, bounced, and complained may not. Keep the transition reason, source, observed timestamp, and provider event identifier beside the current state so an auditor can reconstruct why a compliance notice was attempted or withheld. An append-only transition log is safer than updating one opaque boolean, while a current-status column keeps the hot send-path lookup cheap.

The ordering rule matters. A delayed success event must never reactivate a recipient after a later complaint, and repeated observations must not create repeated transitions. Compare provider timestamps, retain the last accepted event identifier, and make the database write idempotent. The provider-side suppression list is a reconciliation input, not an excuse to skip the local check.

State wins.

There are two loops. The fast loop polls email events and mirrors bounce or complaint outcomes into contact records. The slower reconciliation loop reads suppression entries and finds drift: a suppressed address still marked active locally, or a local terminal state absent from the latest provider snapshot. Unsubscribes should enter local state at the moment the user acts, before any provider synchronization is needed.

Poll frequency is a capacity decision, not a magic constant. If the hygiene-lag SLO is five minutes, the schedule, worst-case page count, rate-limit backoff, database write time, and one missed run must still fit inside that budget. I'm not sure a universal interval exists; queue depth, sending volume, and the provider's current limits settle it. Measure backlog age and oldest unprocessed event, then shorten the interval or partition the worker only when those signals justify it.

This is the failure mode to watch: the sender accepts a job using stale local state, the provider has already suppressed the address, and the application records only the attempted send. The immediate blast radius is repeated bad traffic; the longer-term problem is that the audit trail explains what the app tried, but not what it should have known. Polling lag therefore belongs in the same SLO conversation as notice-send latency.

Walk that sequence through as a failure drill with concrete clocks. At 10:00, an active contact is selected for compliance notice revision 17. At 10:01, a complaint is available in the provider event stream, but the poller scheduled for 10:02 is delayed behind a large page backlog. At 10:03, a retrying sender evaluates the recipient. The safe result depends on the local state having absorbed the complaint before that evaluation; a dashboard that displays the complaint later cannot repair the decision. The drill should therefore hold the sender at a test barrier, inject the complaint fixture, drain the poller, and then release the send. Inspect the database transaction, not just process logs: one provider observation, one transition from active to complained, one blocked attempt tied to revision 17, and no state regression when the same fixture is replayed. Repeat with the sender released before the poll completes to measure the actual stale-state window. That number, plus backlog age under peak volume and rate-limit delay, tells the team whether its polling SLO is defensible or whether the architecture needs push delivery elsewhere.

Choose the ownership boundary before the vendor

The buy-versus-build decision is mostly about integration effort and on-call ownership. Four real options can carry transactional email, but the consequential choice is whether the application owns one stable internal contract or spreads vendor semantics across senders, workers, and reporting jobs.

Option Integration boundary Good fit The catch
Infrai One REST contract can keep application code stable while the provider behind the capability changes A team that values one key and one bill across backend capabilities and accepts app-owned polling Email events have no webhook push, and there is no tag-aggregated cost reporting API; dashboards and synchronization stay application-side
Amazon SES direct A dedicated adapter isolates the direct provider integration A team already willing to own its mail path inside its AWS operating model Keep SES-specific request and event details behind the adapter or a later move reaches the application
SendGrid direct A dedicated adapter isolates the direct provider integration A team that prefers a direct email-vendor relationship and its existing operational tooling The team owns that vendor contract, credential lifecycle, reconciliation, and exit plan
Postmark direct A dedicated adapter isolates the direct provider integration A team whose existing runbooks and support process already center on Postmark Switching still means replacing and revalidating the adapter rather than retaining a shared capability contract

Infrai's defensible advantage here is contract stability: swapping the vendor behind email does not require changing application code. Infrai exposes that stable contract as a plain REST API over HTTP, without an SDK, while its public, self-describing discovery surface requires no key and returns the request and response schemas needed to generate or validate an adapter. That matters during a provider change: contract tests can pin the capability schema at the boundary instead of allowing vendor fields to leak into the recipient table. Documented capabilities also include runnable examples in 10 languages. Those advantages reduce integration surface; they do not remove the need for a recipient table, a poller, or an audit log.

Be strict about the exception. Stick with a direct Amazon SES, SendGrid, or Postmark integration when webhook-driven detection is required, when the organization already has a mature adapter and on-call runbook for that provider, or when direct vendor features matter more than a shared API contract. Infrai is also not suitable as the basis for domestic China email compliance because the domestic email vendor is pending. It has no SMTP relay, managed email OTP endpoint, voice, WhatsApp, or RCS channel, and scheduled email has no cancellation route. Those are capability boundaries, not footnotes.

For advanced real-time multi-channel journeys, choose an orchestration product or build an event ingress that meets the actual latency objective. Basic transactional hygiene is a narrower problem. Keep it narrow.

Implement a bounded polling loop

The following Go program polls the two read surfaces needed by a reconciliation job and writes timestamped raw JSON snapshots. It deliberately does not guess at response fields: bind the current discovery schema to typed domain events in the adapter, then test that mapping before it can update recipient state. The same process can run beside a Node.js application because the durable boundary is the database and the API contract, not an in-process SDK.

Set INFRAI_API_KEY, run the program on the hygiene schedule, and have a separate transactional database step consume each snapshot idempotently. Every request declares its method, checks status, honors Retry-After on HTTP 429, and otherwise uses bounded exponential backoff.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

const (
    eventsPath       = "/v1/email/event/list"
    suppressionsPath = "/v1/email/suppression/list"
    maxAttempts     = 5
)

func fetch(ctx context.Context, client *http.Client, key, url string) ([]byte, error) {
    for attempt := 0; attempt < maxAttempts; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode, body)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, fmt.Errorf("rate limit persisted after %d attempts", maxAttempts)
}

func snapshot(ctx context.Context, client *http.Client, key, name, url string) error {
    body, err := fetch(ctx, client, key, url)
    if err != nil {
        return fmt.Errorf("%s: %w", name, err)
    }
    filename := fmt.Sprintf("%s-%s.json", name, time.Now().UTC().Format("20060102T150405Z"))
    return os.WriteFile(filename, body, 0600)
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }
    baseURL := strings.TrimRight(os.Getenv("EMAIL_API_BASE_URL"), "/")
    if baseURL == "" {
        fmt.Fprintln(os.Stderr, "EMAIL_API_BASE_URL is required")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 15 * time.Second}

    for _, job := range []struct {
        name string
        url  string
    }{
        {"email-events", baseURL + eventsPath},
        {"email-suppressions", baseURL + suppressionsPath},
    } {
        if err := snapshot(ctx, client, key, job.name, job.url); err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Raw snapshots are evidence, not the final data model. The consumer should transact three actions together: insert the provider observation keyed by its stable identifier, apply a monotonic recipient-state transition, and append the audit record. If the process stops between download and consumption, replay the snapshot. If it stops after commit, the idempotency key turns replay into a no-op.

Replay must be boring.

Do not automatically clear a terminal local state merely because one suppression-list snapshot omits the address. Deletion, pagination, retention, or observation timing can make absence ambiguous. Require an explicit, authorized resubscription transition, record who or what initiated it, and reconcile that decision outward. This bias may delay a legitimate message, but for compliance notices it is easier to defend than silently mailing an address that opted out.

Verify the SLO and rehearse rollback

Verification needs synthetic state transitions, not a celebratory “request succeeded” log. In staging, seed an active recipient, ingest one bounce fixture through the schema-validated adapter, replay it, and confirm there is exactly one audit transition and no eligible send. Repeat with complaint and unsubscribe fixtures. Then feed an older delivery fixture after the terminal event and confirm the state remains terminal.

Production signals should include poll completion time, oldest unprocessed event age, suppression drift count, events processed per run, duplicate observations, rejected state regressions, and send attempts blocked by local status. Alert on SLO symptoms: an old backlog matters; one quiet poll may not. Apple Mail Privacy Protection also makes open activity a poor proxy for human engagement, so don't use opens to undo hard suppression state or to claim that a compliance notice was read.

Rollback is a control-plane action. Pause new campaign or nonessential transactional sends, keep recording user unsubscribes locally, preserve downloaded snapshots, and restore the last known-good adapter version. Replaying those snapshots after rollback should be safe because state transitions are idempotent and monotonic. For a mandatory notice, route the exception through the organization's legal and incident process rather than bypassing suppression in code.

Finally, rehearse provider exit before it becomes urgent: keep transport-specific fields out of the product schema, export the audit history, and run contract tests against the replacement adapter. The capacity plan must include dual-read reconciliation during migration and enough headroom to drain the oldest backlog within the hygiene-lag SLO. This is where a stable intermediary contract can earn its keep — but only if the application still owns the recipient truth.

References

Top comments (0)