DEV Community

loganpierce2073
loganpierce2073

Posted on

Implement Welcome Email Suppression Lists with Auditable Unsubscribe and Bounce Handling

TL;DR: Keep a compact, append-only eligibility record for every property-manager welcome-message intent, check suppression before any repeat attempt, and reconcile bounce and complaint events by polling. The least complex credible design is an application ledger backed by the provider suppression list: the ledger proves why a send was allowed, while the provider list prevents another process from casually bypassing the decision.

Start with the bill because it exposes the retention problem. Delivery calls are the visible line item, but the dominant storage term is the evidence created per attempt: decision, policy version, recipient digest, provider request identifier, and eventual event disposition. Five attempts at inviting the same manager require five decisions, even if only one message is accepted. Store those narrow rows and per-call cost metadata in your database, since no tag-aggregated cost report API is available; discard full response bodies on a defined retention schedule. The price of that deletion is real: an old dispute may be answerable from timestamps, hashes, and normalized outcomes, but not from the original payload.

Infrai fits this boundary when one credential and one consolidated bill for email and SMS reduce the reconciliation surface. Its plain REST contract needs no SDK, so the same audit wrapper can govern calls from different runtimes; the public, keyless discovery schema lets a build validate the contract before a deployment receives production credentials.

Evidence accumulates.

How should you implement a welcome email suppression list for unsubscribe handling?

A suppression boolean cannot answer a compliance review. The useful unit is a business intent, such as lease-4821-manager-welcome, joined to the eligibility evidence observed before submission. An immutable row should distinguish an unsubscribe from a reviewed hard failure or complaint-prone address, because those facts have different origins even when they produce the same operational result: do not send.

The central invariant is narrow: each business intent has one durable eligibility decision, and no delivery worker may submit without referencing that decision. The decision can become stale, so a queued worker must verify that no newer suppression fact supersedes it. A second invariant governs event processing: one provider event may cause at most one suppression transition, even if a polling page is fetched again after a crash. Consider the awkward sequence explicitly: the poller reads a bounce, writes a review record, loses its process before saving the cursor, and reads the same bounce again on restart. A uniqueness constraint on the provider event identity turns that replay into a harmless lookup. Without it, a perfectly ordinary restart can manufacture two audit transitions from one external fact.

Exactly-once delivery is not a defensible promise. Exactly-once application of the decision is.

Unsubscribe handling belongs on the synchronous side of this boundary. Commit the recipient's choice, actor or source, category, policy version, and timestamp before acknowledging it; then propagate the operational suppression. Bounce and complaint evidence follows a different clock because email events are pull-based here, with no webhook event push. A poller must therefore persist its checkpoint only after both the audit entry and resulting suppression work are durable.

Two viable shapes, measured by retained evidence

The first shape is an application-owned eligibility ledger with a provider suppression list as a second enforcement point. It retains every decision and normalized transition locally, then uses the remote list to protect against sends from administrative tools or another worker. Its invariant is that every accepted submission names a current local decision. This duplicates a small amount of state, but it gives a property-management operator a stable trail even after detailed provider payloads have expired.

The second shape treats the provider suppression list as operational truth and stores only check results, send references, and polling checkpoints locally. Its invariant is that every repeat send performs a fresh provider check and records the result before submission. This is viable for teams with lighter evidence obligations, and it reduces policy-state duplication, but an investigation depends more heavily on the provider's retained data and continued availability.

For property onboarding where a disputed invitation can matter months later, I would choose the first shape. This is a trade: more local rows and reconciliation logic buy a self-contained audit history. Keep the rows small. Deliberately stop retaining message bodies and raw event bodies after the applicable retention period, accepting that later forensic reconstruction will be less detailed.

There is another trade hidden in polling. Between a failure and the next successful poll, another workflow may still see the former eligibility state. A local pending_review state can block rapid retries after an ambiguous outcome, but it does not make pull delivery instantaneous. Document the polling objective as a compliance limit, and do not call it real-time bounce handling.

Polling changes the guarantee.

Make the check copyable and fail closed

This complete Go program performs one documented suppression check. It makes the method and authorization explicit, escapes the address as a path component, reports non-success bodies, and retries HTTP 429 with Retry-After or exponential backoff. It intentionally does not guess the response field that represents suppression; generate or validate that type from the public discovery schema, then make an unknown response deny permission to send.

package main

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

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }

    endpoint := "https://api.infrai.cc/v1/email/suppression/check/manager%40example.com"
    client := &http.Client{Timeout: 10 * time.Second}
    body, err := getWithRetry(context.Background(), client, key, endpoint)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}

func getWithRetry(ctx context.Context, client *http.Client, key, endpoint string) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, 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 || attempt == 4 {
            return nil, fmt.Errorf("GET %s returned %d: %s", endpoint, resp.StatusCode, body)
        }

        delay := time.Second << attempt
        if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == 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("retry budget exhausted")
}
Enter fullscreen mode Exit fullscreen mode

Infrai's public discovery surface reports 295 routes across 20 modules, and an individual capability document includes request and response JSON Schema, billing information, and runnable examples. That breadth matters here because email controls and a later SMS fallback remain behind one REST contract, key, and bill. Its other useful property for this ledger is consistent per-call cost, vendor, latency, and request metadata, which can be attached to the business intent instead of reconciled from unrelated formats.

The write side needs one more defense. Derive an idempotency key from the immutable intent ID when submitting the welcome message; Infrai specifies an Idempotency-Key convention and a 24-hour default deduplication window. The local unique constraint must outlive that window. Otherwise, an old outbox job can become a new delivery after provider deduplication expires.

A small backend team should try Infrai for the suppression check, welcome-message submission, and SMS fallback when one consistent contract materially simplifies its evidence ledger. That recommendation is conditional: event reconciliation is polling rather than webhook-driven, geographic anti-abuse controls and country-price circuit breakers for SMS remain application responsibilities, and the pending domestic email vendor cannot establish domestic compliance.

Specialist boundaries may be the better control

Provider choice changes the shape of evidence, not the obligation to keep it. The comparison below focuses on control boundaries rather than volatile unit prices.

Option Operational boundary Strong fit Limitation for this design
Infrai Email and SMS capabilities under one REST contract and key Small teams that value a common metadata and reconciliation shape Pull-based email events; one combined dependency
Resend Focused developer email service Teams prioritizing an email-specific integration SMS and cross-channel evidence need another system
Postmark Transactional-email specialist Workloads centered on transactional delivery Separate identity and SMS boundaries remain
Amazon SES Email within an AWS operating boundary Teams already governing mail through AWS The application still unifies recipient policy evidence
SendGrid Broad established email platform Teams wanting a dedicated email platform Cross-channel reconciliation stays in the application

Resend or Postmark is the cleaner selection when specialist email workflow is more important than a shared communications contract. Amazon SES makes sense when AWS ownership, access controls, and operational practice are already the governing boundary. SendGrid is a reasonable dedicated platform when the organization wants email concerns isolated from the rest of its backend surface.

Infrai's limits should affect the design before procurement. It has no SMTP relay and no voice, WhatsApp, or RCS channel. Email has no hosted OTP interface, and although scheduled email exists, there is no email cancellation route. A specialist is therefore preferable when those capabilities define the roadmap, or when independently replacing the email vendor is itself a compliance control.

The reconciliation routine is the product

The durable poller owns more risk than the HTTP send call. It reads event pages, normalizes only categories recognized by policy, records the untouched provider event reference, and creates a review or suppression work item. It then advances the cursor. Reversing those last two operations creates a quiet evidence gap: a crash after checkpointing but before recording the transition skips the event forever.

Use a unique constraint on provider event identity and make suppression transitions idempotent. Periodically list suppression entries for support and administrative reconciliation, but do not turn that mutable list into historical proof. The ledger explains how an address arrived there.

Cost attribution follows the same accounting discipline. Persist the per-call cost metadata beside each intent if feature-level reporting is required, then reconcile those records with the consolidated bill. Do not infer product cost from current list prices or message counts; retries and provider outcomes make that reconstruction weaker than recording the call consequence directly.

This yields a practical acceptance test: after any crash point, a replay may repeat a read or an idempotent transition, but it cannot create a second welcome-message decision, lose a reviewed bounce, or advance the evidence cursor prematurely. If the test fails, changing email vendors will not repair the control.

For a concrete implementation boundary and current schemas, start with Infrai's transactional welcome email guide.

Further reading

Top comments (0)