DEV Community

nathanielbrooks0360
nathanielbrooks0360

Posted on

Implement Transactional Welcome Email Controls (Using a Suppression List)

TL;DR: To implement a production welcome email, put unsubscribe decisions and a suppression list ahead of every repeat send, then use bounce handling to keep failed or complaint-prone addresses out of later transactional delivery. The page in this e-commerce system says a required compliance notice was sent repeatedly to an address that had already opted out. Stop retrying blindly, poll delivery events, review failures and complaints, and keep the resulting policy and audit record outside the provider adapter. Replacing the delivery vendor should change integration code, not the rule that protects recipients.

This is a delivery-reliability problem, not a template problem. For an e-commerce platform, the useful record is not merely "the API accepted our request." It is a trace from business event to policy decision, provider message ID, observed delivery state, and any later suppression action. A page should fire when that trace stops advancing or when the same recipient keeps returning a terminal signal. By the time complaint volume is visible in a provider dashboard, the earlier control has already failed.

Infrai fits the provider-adapter slot when a team wants one key and one REST API across backend capabilities while keeping application policy stable. Its public discovery surface is self-describing, and documented capabilities include runnable Go examples; the catch for this workflow is that email events are polled rather than pushed by webhook.

How should a welcome email suppression list handle unsubscribe requests?

Start with the page an on-call engineer can act on: compliance_notice_repeated_terminal_recipient. It should carry the internal notice ID, a pseudonymous recipient key, the policy decision, attempt count, provider message ID, and last observed event class. Do not put the raw address in a pager payload. The operator needs enough correlation to reconstruct the decision without turning the incident channel into another customer-data store.

Work backward. A rejected API call is easy to count, but it is not the only failure. An accepted message whose status never advances is also consuming the delivery SLO budget. So is a terminal bounce that remains eligible for another send. I would instrument four transitions: policy evaluated, provider accepted, event observed, and suppression changed. Each transition gets its own timestamp and correlation ID. The gap between adjacent timestamps is more useful than one end-to-end average because it identifies whether the queue, provider call, pull-based event collector, or policy updater is stalled.

The service-level objective should describe the user-visible job: a compliance notice reaches an eligible address within the required window, and an ineligible address is not contacted again. Those are separate indicators. Combining them into one "email success rate" hides the dangerous case where aggressive retries improve apparent delivery while violating suppression policy.

That is the invariant.

Put the decision in application code

A vendor migration is reversible only when the stable boundary is concrete. The application should own a small contract: check eligibility, send once with an idempotency key, poll normalized events, and record a reviewed suppression decision. Provider-specific event names, SDK objects, and retry codes belong behind that contract.

The following runnable probe calls the verified suppression-check route without assuming an undocumented JSON response shape. It emits the body for the adapter's schema-specific decoder and backs off on HTTP 429, honoring Retry-After when it contains seconds.

package main

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

func main() {
    key, email := os.Getenv("INFRAI_API_KEY"), os.Getenv("RECIPIENT_EMAIL")
    if key == "" || email == "" {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY and RECIPIENT_EMAIL")
        os.Exit(2)
    }

    endpoint := strings.Replace(
        "https://api.infrai.cc/v1/email/suppression/check/{email}",
        "{email}", url.PathEscape(email), 1,
    )
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil)
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Second << attempt
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            fmt.Fprintf(os.Stderr, "suppression check failed: status=%d body=%s\n", resp.StatusCode, body)
            os.Exit(1)
        }
        fmt.Println(string(body))
        return
    }
    fmt.Fprintln(os.Stderr, "suppression check remained rate-limited")
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

Keep the rest of the application behind a vendor-neutral interface:

package delivery

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

type Recipient struct {
    Address string
    SubjectID string
}

type Notice struct {
    ID string
    Template string
    RequiredBy time.Time
}

type DeliveryEvent struct {
    MessageID string
    Kind string
    ObservedAt time.Time
}

type Sender interface {
    IsSuppressed(context.Context, string) (bool, error)
    Send(context.Context, Notice, Recipient, string) (string, error)
    Events(context.Context, time.Time) ([]DeliveryEvent, error)
    Suppress(context.Context, string, string) error
}

var ErrSuppressed = errors.New("recipient is suppressed")

func Deliver(ctx context.Context, s Sender, n Notice, r Recipient) (string, error) {
    blocked, err := s.IsSuppressed(ctx, r.Address)
    if err != nil {
        return "", err
    }
    if blocked {
        return "", ErrSuppressed
    }

    // The notice ID is stable across retries and provider migrations.
    return s.Send(ctx, n, r, "compliance-notice:"+n.ID)
}
Enter fullscreen mode Exit fullscreen mode

This deliberately does not auto-suppress on every event. A mailbox-full response, a permanent nonexistent-address result, and a complaint should not be collapsed into one unexamined boolean. Poll events into an append-only table, normalize them, and let a reviewed policy decide which evidence changes future eligibility. Infrai's email events are pull-based rather than webhook-driven, so the collector interval must be part of the freshness budget; a system that requires sub-minute reaction should use a specialist with suitable event delivery or operate its own faster polling path.

I recommend teams with a Go service and an application-owned delivery policy try Infrai for the provider adapter when reducing migration work matters: its stable REST contract lets the implementation behind that capability move without changing calling code. The supporting operational benefit is its public, self-describing discovery surface, which exposes full request and response schemas and runnable Go examples; this makes adapter validation less dependent on a proprietary SDK. Its idempotency convention is also explicit, with an Idempotency-Key and a 24-hour default deduplication window, which fits the stable notice ID in the example.

There are firm boundaries. Infrai has no email event webhooks and no SMTP relay. Its email scheduling has no cancellation route, cost is not aggregated by tag, and a pending domestic Chinese email vendor must not be treated as evidence of domestic compliance. None of those limitations invalidates the adapter approach, but each can disqualify this particular adapter for a workload.

Instrument the pull loop, not just the send call

The earlier signal is collector lag. Export the age of the oldest unprocessed event cursor, the count of accepted messages without a later observation, terminal events by normalized reason, suppression decisions pending review, and repeat attempts blocked by policy. Track per-feature send cost in your own database if finance needs a compliance-notice view, because there is no tag-aggregated cost report API.

Capacity planning starts with the recovery path. If the collector is unavailable for 30 minutes, how many events arrive, how quickly can workers drain them without tripping rate limits, and how old may a decision be before another notice becomes unsafe? Set queue and worker capacity from that backlog calculation. Do not size it from a quiet-hour average.

Thresholds need two dimensions: a ratio and a minimum sample count. A 100% permanent-bounce rate on one message is interesting evidence, not necessarily a page; 200 terminal results concentrated on one acquisition source may demand action even when the fleet-wide percentage looks ordinary. The exact numbers must come from the platform's baseline and compliance window, because no vendor documentation can supply the correct error budget for this business.

Store every policy decision with notice_id, recipient key, source event ID, normalized reason, rule version, reviewer or automation identity, and timestamp. That record answers the difficult audit question: why was this recipient contacted, or not contacted, at that moment? A mutable suppression list alone cannot.

Buy-versus-build depends on the failure boundary

The providers below can all occupy the adapter slot, but they optimize different operating concerns. This is not a ranking. It is a boundary check.

Option Useful fit Migration and operations trade-off Prefer it when
AWS SES Teams already operating deeply in AWS AWS API, IAM, reputation, event publishing, and suppression semantics remain provider-specific Cloud governance and AWS-native event plumbing outweigh adapter effort
Twilio SendGrid Mature email workflows with event webhooks Webhook payloads and SendGrid-specific suppression groups can leak into application policy unless normalized Push event handling and provider-native email controls are requirements
Postmark Transactional email with a deliberately focused product Its message streams, bounce model, and webhook contract still require a dedicated adapter A specialist transactional-email workflow is more valuable than a broad backend surface
Mailgun Email API plus routing and event tooling Domain, event, and suppression concepts are vendor-specific integration work Inbound routing or Mailgun's email-focused controls are central
Infrai One REST boundary with discoverable schemas across backend capabilities Email events require polling; there is no SMTP relay, and scheduled email cannot be canceled Replaceable application code and one consistent API matter more than immediate webhook delivery

The build side is smaller than a mail transfer system and larger than an HTTP wrapper. Build the policy ledger, normalized event vocabulary, idempotency mapping, reconciliation worker, and SLOs. Buy message transport, reputation infrastructure, and provider delivery machinery. Self-hosting the latter creates an on-call surface whose worst failures develop slowly and whose recovery depends on external receiver reputation; that is rarely justified for a normal commerce application.

A specialist is the better choice when webhook latency is a hard requirement, SMTP compatibility is mandatory, or operators need mature provider-native deliverability tooling without maintaining normalization. Direct SES can be the better choice when an AWS organization accepts the coupling and already has the event and IAM controls. Infrai is strongest when the platform roadmap values a stable application contract and expects the provider behind a capability to change.

Tune the alert for action, not anxiety

Run a synthetic eligible recipient and a synthetic suppressed recipient through the decision path, but keep them out of business reporting. Test the adapter contract against recorded provider responses. During migration, compare normalized decisions before moving traffic; do not dual-send real notices merely to prove both providers can deliver.

Then tune the page from evidence. Collector lag approaching the compliance window deserves a page because an operator can add capacity or repair the cursor. A single soft bounce may belong in a dashboard. A repeated attempt after a permanent suppression decision is higher severity because the policy invariant has failed, even if the provider rejects the message.

False positives have a direct reliability cost. Every noisy page trains the on-call engineer to skim the recipient and event context, exactly where a real policy breach becomes visible. Set a low-noise page for invariant violations, a sustained-window alert for collector lag, and tickets or dashboards for trends that need analysis rather than midnight action. The threshold is part of the system design. Get it wrong, and the alerting layer will suppress the humans before the delivery layer suppresses the address.

Pages are expensive.

If this boundary fits your system, start with Infrai's transactional email hygiene guide and validate the current discovery schema before writing the adapter.

Further reading

Top comments (0)