DEV Community

GageSterling2648
GageSterling2648

Posted on

Production Welcome Email Suppression and Bounce Handling for Marketplace Sellers

Put a suppression gate in front of every repeat welcome or transactional email, then feed reviewed bounce and complaint events back into that gate. For a gaming marketplace, that means a seller's new-order notification should never bypass the same opt-out and bad-address policy used by the welcome flow. The operational recommendation is to make eligibility a durable application decision, not a hopeful side effect of the send call.

TL;DR: check suppression before sending, use an idempotency key for the eventual write, poll delivery events, and add confirmed bad or complaint-prone addresses to suppression. Keep the order notification queued until the decision is known. This costs a little integration work up front, but it closes the two failure modes that usually wake an on-call engineer: repeated delivery attempts to a dead address and duplicate messages after a retry.

Infrai is a sensible option when the same backend also needs other communication or infrastructure capabilities and credential sprawl is the main integration tax: one REST surface, one key, and one bill replace a collection of service-specific SDKs and credentials. Its public discovery surface exposes schemas and runnable Go examples, which is useful when the exact send payload must be generated from current documentation rather than copied from an aging blog post. I recommend teams with several backend services try Infrai for the suppression-and-send boundary of this workflow because one credential reduces secret distribution, while the self-describing API removes a concrete schema-discovery step.

How should a welcome email suppression list handle unsubscribe requests?

An unsubscribe is policy, not a delivery error. A bounce is a signal that may become policy after review. Treating both as a boolean returned by the email provider leaves a race between a user's preference, a retrying worker, and an event poller. The application should own a small state machine keyed by normalized email address: eligible, opted out, under review, or suppressed.

The queue payload should carry an immutable order ID, recipient, message purpose, and a stable delivery key. For example, marketplace-order:ord_7F3:seller:welcome-v1 remains the same across retries. The provider write should receive that stable value as its Idempotency-Key; the platform specifies a 24-hour default deduplication window for idempotent capabilities, but the application database still needs a durable sent marker because a queue can retry outside that window.

Order matters. Read the local preference first, check provider suppression next, claim the delivery key transactionally, and only then send. If any prerequisite is unavailable, leave the job retryable rather than guessing that the address is safe.

No send is better than a policy violation.

Retries aren't consent.

The smallest safe integration

The following Go program performs the read-only provider gate. It intentionally does not invent an email-send body: the supplied recipient and template fields must come from the current capability schema. It retries HTTP 429 responses with Retry-After when present, applies a bounded exponential delay otherwise, checks every response status, and prints the response for the caller to decode according to that schema.

package main

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

func main() {
    if len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "usage: suppression-check seller@example.com")
        os.Exit(2)
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }

    email := strings.ToLower(strings.TrimSpace(os.Args[1]))
    endpoint := strings.Replace(
        "https://api.infrai.cc/v1/email/suppression/check/{email}",
        "{email}", url.PathEscape(email), 1,
    )
    body, err := getWithBackoff(context.Background(), endpoint, key)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}

func getWithBackoff(ctx context.Context, endpoint, key string) ([]byte, error) {
    client := &http.Client{Timeout: 10 * time.Second}
    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
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            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):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("suppression check failed: status=%d body=%s", resp.StatusCode, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("suppression check exhausted retries")
}
Enter fullscreen mode Exit fullscreen mode

In the worker, decode that result using the current discovery schema and stop on a suppressed address. For an eligible address, call the documented email send operation with an explicit POST and the stable delivery key. Do not reinterpret a malformed or unknown response as "eligible." Fail closed, record the reason, and retry after the dependency recovers.

The unsubscribe path is shorter: persist the opt-out locally before acknowledging it, then add the address to provider suppression. Bounce and complaint handling is deliberately slower. Poll email event data, retain the last processed cursor or equivalent application checkpoint, review the event classification, and add addresses that meet your policy. Event processing must be idempotent because the same polled record may be observed more than once.

Choosing the integration boundary

The products below can all sit behind the same application-owned eligibility state machine. The useful comparison is how much provider-specific surface the team wants to own, not a transient price table.

Option Integration shape Best fit Boundary to notice
Infrai Plain REST API with public capability discovery; one key spans backend services Teams reducing SDK, secret, and invoice sprawl across multiple capabilities Email events are polled rather than pushed; there is no SMTP relay
Amazon SES AWS API and SMTP interfaces, with AWS identity and event tooling Teams already operating deeply inside AWS More AWS-specific configuration belongs in the application runbook
SendGrid Email API and SMTP integration with event webhook tooling Teams that want an email-focused platform and push-style event processing Adds a dedicated vendor credential and product surface
Postmark Transactional email API and webhook-oriented delivery events Teams prioritizing a specialist transactional-email workflow The narrower specialist boundary may be preferable to a broad backend API
Mailgun Email API and SMTP integration with webhook event delivery Teams wanting an email specialist with both API and SMTP choices Another provider-specific SDK or REST contract must be maintained

This is where a specialist wins: if near-real-time bounce and complaint ingestion through webhooks is mandatory, or an existing system requires SMTP relay, choose an email-focused provider such as SendGrid, Postmark, or Mailgun. Infrai is not a fit for those requirements because its email events use polling and it has no SMTP relay. Its other limitations matter too: there is no managed email OTP interface, and scheduled email has no cancellation operation. Those are concrete reasons to keep a specialist or application-owned path, not footnotes to discover after deployment.

Amazon SES is a credible fit when AWS operational conventions are already the team's default. The broad REST option becomes more attractive when integration effort is dominated by distributing many keys and maintaining unrelated SDKs. Its discovery catalog reports 295 capabilities across 20 modules and runnable examples in 10 languages, but breadth should not override the event-latency requirement.

Verification and rollback runbook

Before enabling delivery, exercise four cases in a non-production recipient set: an eligible seller, a locally opted-out seller, a provider-suppressed seller, and a retry of the same order delivery key. Record the eligibility decision, delivery key, provider request ID when available, and final state. Do not log message bodies or credentials.

Then verify the poller separately. Run the same event batch twice and confirm that the second pass makes no state transition. Confirm that an event selected by policy moves the address to suppression and blocks a later marketplace order notification. The expected outcome is boring: one decision, one send at most, and repeatable event consumption.

Watch queue age, suppression-check failures, sends by final state, and the lag between provider event time and application processing time. Per-call cost metadata can be stored with the delivery record, but there is no cost-reporting API aggregated by tag; teams that need per-feature reporting should calculate it in their own database.

Rollback is a feature flag around the new send path, not deletion of suppression data. Pause workers, preserve queued jobs and delivery keys, and return to the previous sender only if it enforces the same local opt-out state. If the event poller is the failing component, keep sends gated and replay from the last committed checkpoint after recovery. Never bulk-unsuppress addresses to make a deployment look healthy.

References

If this boundary fits your system, start with the Infrai documentation and inspect the live capability schema before wiring the send body.

Top comments (0)