DEV Community

magnusberg2958
magnusberg2958

Posted on

Email Bounce and Complaint Suppression Polling (5 Transactional Deliverability Checks)

Short answer: in a Node.js transactional app, treat email bounce and complaint feedback as a compliance event stream, and check the suppression list before admitting another send. A healthtech password-reset service should preserve enough evidence to explain that decision and keep the reset token's short expiry independent from the email provider's delivery state.

The hard part isn't calling an email API. It's closing the race between a failed send, a polling cycle, and the next password-reset request while retaining an audit trail that doesn't become a second store of sensitive message content. My default target would be a 15-minute reset expiry as an explicit product policy, a polling interval derived from the allowed repeat-send window, and an SLO on feedback age rather than on cron success. Fifteen minutes is an example configuration here, not a claim from a standard.

Fast is secondary. Evidence wins.

Governance starts with a policy-versioned evidence record

A compliance reviewer needs a policy-versioned decision record; a green scheduler is not evidence that the safety control worked. The job may fetch the same page repeatedly, advance a cursor before committing records, or finish successfully after receiving no new events even though upstream feedback is delayed. None of those outcomes is captured by a simple poller_up == 1 alert. The useful signals are the age of the newest fully committed feedback event, the oldest unprocessed event, the number of page or cursor replays, and the count of send attempts rejected by the local suppression decision.

For password resets, separate three clocks. The reset token has a security expiry. The delivery attempt has a provider lifecycle. The suppression record has a retention policy justified by compliance and abuse controls. Coupling them is a category error: deleting the suppression record when a 15-minute token expires can allow another message to an address that already bounced or complained, while retaining the token because delivery is uncertain extends access beyond the policy the user was shown.

The capacity calculation is plain, but teams often skip it. If peak feedback arrival is R events per second, a poll returns at most P events, and the interval is T seconds, steady-state capacity requires P/T to exceed R with headroom for retries and maintenance. Don't use average daily email volume. A campaign, an identity-provider outage, or a retry burst can compress feedback into a much shorter window — even though the reset traffic itself looks modest. I'm not sure a defensible interval can be chosen from provider documentation alone; replay tests plus observed backlog age are what resolve that uncertainty.

How can a transactional app audit email bounce and complaint suppression polling?

Use one writer for feedback ingestion and one synchronous read path for send admission. The poller reads a page from an abstract feedback source, validates each event, writes suppression state and immutable evidence in one database transaction, and commits the remote cursor only after that transaction succeeds. The request path never calls the provider to ask whether an address is suppressed; it checks the local projection, creates a reset challenge, and queues the email only when policy permits. This keeps provider latency outside the user-facing dependency graph and makes the decision reproducible during an audit.

A useful evidence record contains an internal event ID, a keyed recipient digest, the reason category, provider event time, ingestion time, source reference, policy version, and the cursor or page that carried it. Keep the email body and reset token out. The digest should be produced with a secret-keyed construction rather than a bare hash because email addresses come from a small, guessable space. Key rotation and access to the evidence store belong in the threat model.

Model ingestion as at-least-once. A uniqueness constraint on (source, event_id) makes replay harmless; an upsert on the recipient digest makes the latest policy state deterministic. If the source doesn't expose a stable event ID, derive an idempotency key only from documented stable fields and test collisions before trusting it. Cursor movement must be coupled to the same commit boundary. Otherwise a crash creates one of two bad choices: skip evidence or resend work without knowing whether it was applied.

Commit first.

The admission rule should fail closed for known suppressed recipients and fail according to an explicit risk decision when feedback is stale. For a healthtech reset flow, that stale-data branch deserves a named policy, an owner, and an alert; silently sending because the poller is late defeats the control, while blocking every reset during a long external delay can become an availability incident. There isn't a universal answer. Set the branch from the service's risk assessment, record which branch executed, and ensure support has a recovery process that does not expose whether an account exists.

How much polling capacity does the ingestion state machine need?

The following Go sketch keeps transport, storage, and scheduling separate. It deliberately omits a vendor URL: endpoint paths, authentication, pagination fields, and event schemas must come from the selected provider's current documentation. The interesting contract is the commit ordering.

package feedback

import (
    "context"
    "errors"
    "time"
)

type Event struct {
    ID          string
    Address     string
    Kind        string
    ObservedAt  time.Time
    SourceRef   string
}

type Page struct {
    Events     []Event
    NextCursor string
}

type Source interface {
    Fetch(ctx context.Context, cursor string, limit int) (Page, error)
}

type Store interface {
    Cursor(ctx context.Context) (string, error)
    CommitPage(ctx context.Context, page Page, policyVersion string) error
}

type Poller struct {
    Source        Source
    Store         Store
    PageSize      int
    PolicyVersion string
}

func (p Poller) RunOnce(ctx context.Context) (int, error) {
    cursor, err := p.Store.Cursor(ctx)
    if err != nil {
        return 0, err
    }

    page, err := p.Source.Fetch(ctx, cursor, p.PageSize)
    if err != nil {
        return 0, err
    }
    if page.NextCursor == cursor && len(page.Events) > 0 {
        return 0, errors.New("feedback cursor did not advance")
    }

    for _, event := range page.Events {
        if event.ID == "" || event.Address == "" || event.ObservedAt.IsZero() {
            return 0, errors.New("invalid feedback event")
        }
        switch event.Kind {
        case "bounce", "complaint":
        default:
            return 0, errors.New("unknown feedback kind")
        }
    }

    // CommitPage atomically deduplicates events, updates suppression, and advances the cursor.
    if err := p.Store.CommitPage(ctx, page, p.PolicyVersion); err != nil {
        return 0, err
    }
    return len(page.Events), nil
}
Enter fullscreen mode Exit fullscreen mode

In production, bound every fetch with a context deadline and apply retry limits with jitter in the scheduler. Don't hide continuous failure behind infinite retries. A failed cycle should leave the cursor unchanged, emit a structured result, and let the next scheduled run replay the page. Use a lease or database advisory lock if more than one replica can invoke RunOnce; idempotency protects the records, but duplicate pollers still waste quota and can distort lag metrics.

The synchronous send gate can stay small: normalize the address according to a documented internal rule, compute the keyed digest, read the suppression projection, then atomically record the decision and enqueue the reset message. Avoid pretending that aggressive normalization is universally correct. Case handling and provider-specific alias behavior can change address identity, so any transformation beyond trimming obvious input noise needs evidence and tests.

Deployment proof must include replay and rollback

Test the replay.

Test with synthetic events and an isolated recipient domain that cannot reach real patients. The core suite should replay the same page twice, crash after fetching but before committing, reject an unknown event kind, hold one page while another worker attempts the lease, and inject a feedback timestamp older than the SLO. Verify database state and evidence records, not just function return values. A green response from the scheduler proves very little.

Run a deployment canary with reads enabled and send admission in shadow mode first, comparing the new local decision with the established decision without changing user-visible behavior. Record mismatches by reason category, never by plaintext address in general logs. Once mismatch review is clean, enable enforcement for a small traffic slice, watch feedback age and reset completion, then increase gradually. This is where a long paragraph is justified: rollout combines a security control, a communication channel, and an account-recovery journey, so a single delivery metric cannot tell you whether the system is healthy; you need the feedback-age SLO, queue delay, suppression decisions, reset completions, and support signals in the same review window, with owners already assigned for pausing the rollout.

Rollback the application decision logic, not the evidence. If enforcement causes unacceptable account-recovery impact, disable the new gate through a controlled configuration change while continuing to ingest feedback and preserve policy-versioned decisions. Never rewind a cursor merely to undo a release. Replaying is safe only because event writes are idempotent, and erasing evidence destroys the record needed to understand what happened.

One more check: restore from backup into an isolated environment and recompute the suppression projection from evidence. A backup that has never been restored is capacity theater.

On-call ownership sets the buy-versus-build boundary

This is a buy-versus-build decision, not a syntax preference. The catch is that polling is not suitable when the required suppression latency is lower than the source's documented visibility and your proven poll-to-commit budget. In that case, use a documented push or event-stream integration if the provider offers one. Stick with polling when its bounded lag satisfies the risk decision and the team can operate cursor state cleanly; choose a managed feedback pipeline when reducing on-call ownership matters more than control over the evidence schema.

Decision axis Self-hosted poller Managed feedback pipeline
Compliance evidence Full control over schema, retention, and replay Verify export, retention, and audit access contractually
On-call load Your team owns lag, leases, retries, and migrations Provider owns more plumbing; your team still owns admission policy
Lock-in Generic source interface can isolate transport changes Event schema and workflow may be provider-specific
Capacity You size page rate, storage, and recovery headroom You validate service limits and backlog behavior
Failure boundary Local database and poller join the critical control path External processing and delivery contracts join it

No row produces a universal winner. Amazon SES is one documented example of a managed email service, but its documentation should be used to confirm the exact feedback and suppression mechanisms selected for an implementation, not as evidence that one architecture fits every workload. The NIST digital identity guidance is useful for framing authenticator and verifier controls; an organization still has to map its password-reset flow, retry limits, evidence retention, and privacy obligations to its own risk assessment.

The final readiness question is blunt: can an operator explain why a particular reset email was admitted or suppressed, using policy-versioned evidence, without reading message content or guessing what a cron job did? If the answer is no, deliverability isn't yet an operational control.

References

Top comments (0)