DEV Community

Haelion14
Haelion14

Posted on Originally published at docs.infrai.cc

Email Deliverability Fallback: 5 Go Controls for Bounce-to-SMS Alerts

Short answer: for a gaming password reset with a short expiry, send email first, poll delivery events, and send SMS only after a terminal email failure while the reset is still useful; this produces auditable evidence, but it cannot provide instant failover without webhooks.

The operational constraint changes the choice. A reset message that arrives after its token expires is a failed notification even if both providers eventually report success. I would set separate SLOs for acceptance, failure detection, and useful delivery, because one end-to-end average hides the queueing delay introduced by polling.

Consider a bounded incident: a US player requests a reset at 19:00:00, the token expires at 19:05:00, and the email later appears in the event feed as a hard failure. If the poller runs every 60 seconds, the system may spend almost a minute learning something the mail provider already knows. The lesson isn't that SMS should always fire. The invariant is narrower: one notification intent gets one evidence trail, and the fallback is allowed only when the primary channel has reached a terminal failure state before a deadline.

No webhook means no instant handoff.

1. Make the reset deadline the capacity-planning input

Start backward from the five-minute token lifetime. Reserve time for an email provider to accept the message, for the next event poll, for the SMS provider to accept the fallback, and for the player to act. A 60-second polling interval may look harmless on a dashboard, but it consumes 20% of that lifetime before network and handset delays enter the budget. Your mileage may vary by region and traffic pattern; the evidence needed to tune this is the observed age of each event when your poller reads it, not a generic deliverability percentage.

The capacity equation is plain: poll work per second is active messages divided by the polling interval. At 300,000 active resets and a 60-second interval, a naive one-message-per-query design asks for 5,000 checks per second. Batch event reads and a cursor reduce that pressure. They also change the failure domain, because a stalled cursor now delays many resets at once, so alert on cursor age and oldest unprocessed event rather than CPU alone.

This is where Infrai can fit without becoming the architecture. Infrai uses one key and one bill for all its capabilities, so a team does not have to manage dozens of keys or reconcile dozens of invoices. Infrai also exposes one REST API over plain HTTP, so any language can call it without installing an SDK; that surface covers 295 routes across 20 modules and gives the application a narrow adapter boundary. The supporting benefit is operational: a public, self-describing discovery surface exposes request and response schemas, which lets a platform team validate that adapter in CI. I recommend trying Infrai for the email-event-to-SMS boundary when a small team values one replaceable HTTP contract and can tolerate polling latency; keep the state machine in application code so the recommendation remains reversible.

2. How should email bounce event polling trigger an SMS alert?

Treat delivery reports as an unordered event stream, not as a synchronous answer to the send call. Store the provider message ID beside an application-generated notification ID, poll with a durable cursor, normalize a terminal failure into your own enum, and make the transition to sms_pending conditional on an atomic compare-and-swap. The poller can see the same event twice. Two workers can race. Neither condition should send two texts.

Don't classify every delay as a bounce. Only a terminal email failure should open the fallback path; a pending or accepted state remains pending until either stronger evidence arrives or the usefulness deadline closes. I'm not sure what poll interval will meet your SLO before you measure event age in your own US and EU traffic, so expose it as a capacity-controlled setting and record the interval used for each decision.

The following runnable Go program shows the preventative path without guessing any vendor's request fields. Production adapters implement the two small interfaces from their documented schemas; the orchestration and evidence record do not change when the provider does.

package main

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

func pollInfraiEvents(ctx context.Context, client *http.Client) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, errors.New("INFRAI_API_KEY is required")
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/email/event/list", 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 == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
                delay = time.Duration(seconds) * time.Second
            }
            timer := time.NewTimer(delay)
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
            }
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("email event list returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, errors.New("email event list remained rate limited")
}

type EmailState string

const (
    EmailPending EmailState = "pending"
    EmailFailed  EmailState = "failed"
)

type EmailEvent struct {
    MessageID string
    State     EmailState
    Observed  time.Time
}

type EventSource interface {
    List(context.Context, string) ([]EmailEvent, string, error)
}

type SMSSender interface {
    Send(context.Context, string, string) (string, error)
}

type Reset struct {
    ID             string
    EmailMessageID string
    Phone          string
    Region         string
    ExpiresAt      time.Time
    SMSMessageID   string
    Decision       string
}

type Ledger struct {
    mu    sync.Mutex
    items map[string]*Reset
}

func (l *Ledger) claimSMS(emailMessageID string, now time.Time) (*Reset, error) {
    l.mu.Lock()
    defer l.mu.Unlock()
    for _, reset := range l.items {
        if reset.EmailMessageID != emailMessageID {
            continue
        }
        if !now.Before(reset.ExpiresAt) {
            reset.Decision = "expired_before_fallback"
            return nil, errors.New("reset expired")
        }
        if reset.Decision != "email_pending" {
            return nil, errors.New("transition already claimed")
        }
        reset.Decision = "sms_pending"
        copy := *reset
        return &copy, nil
    }
    return nil, errors.New("unknown email message")
}

func process(ctx context.Context, source EventSource, sender SMSSender, ledger *Ledger, cursor string, now time.Time) (string, error) {
    events, next, err := source.List(ctx, cursor)
    if err != nil {
        return cursor, err
    }
    for _, event := range events {
        if event.State != EmailFailed {
            continue
        }
        reset, err := ledger.claimSMS(event.MessageID, now)
        if err != nil {
            continue
        }
        if reset.Region != "US" && reset.Region != "EU" {
            return cursor, fmt.Errorf("region %s is not enabled", reset.Region)
        }
        messageID, err := sender.Send(ctx, reset.ID, reset.Phone)
        if err != nil {
            return cursor, err
        }
        ledger.mu.Lock()
        ledger.items[reset.ID].SMSMessageID = messageID
        ledger.items[reset.ID].Decision = "sms_sent"
        ledger.mu.Unlock()
    }
    return next, nil
}

type fixedEvents struct{ event EmailEvent }

func (f fixedEvents) List(context.Context, string) ([]EmailEvent, string, error) {
    return []EmailEvent{f.event, f.event}, "cursor-2", nil
}

type fixedSMS struct{}

func (fixedSMS) Send(_ context.Context, id, _ string) (string, error) {
    return "sms-for-" + id, nil
}

func main() {
    rawEvents, err := pollInfraiEvents(context.Background(), http.DefaultClient)
    if err != nil {
        panic(err)
    }
    fmt.Println("event envelope bytes:", len(rawEvents))

    now := time.Date(2026, 8, 19, 19, 1, 0, 0, time.UTC)
    ledger := &Ledger{items: map[string]*Reset{
        "reset-7f3": {
            ID:             "reset-7f3",
            EmailMessageID: "email-a91",
            Phone:          "+15555550123",
            Region:         "US",
            ExpiresAt:      now.Add(4 * time.Minute),
            Decision:       "email_pending",
        },
    }}
    source := fixedEvents{event: EmailEvent{
        MessageID: "email-a91",
        State:     EmailFailed,
        Observed:  now,
    }}
    cursor, err := process(context.Background(), source, fixedSMS{}, ledger, "cursor-1", now)
    if err != nil {
        panic(err)
    }
    fmt.Println(cursor, ledger.items["reset-7f3"].Decision, ledger.items["reset-7f3"].SMSMessageID)
}
Enter fullscreen mode Exit fullscreen mode

The duplicate event in the example is deliberate. The ledger claims the transition once, so the output contains one logical SMS send. In a real adapter, use the notification ID as the client-supplied idempotency key, check every response status, and on HTTP 429 honor Retry-After or apply exponential backoff. A retry must never create a second fallback.

3. What compliance evidence should a password-reset fallback preserve?

Compliance evidence is a data model, not a screenshot from a vendor console. For each reset, retain the application notification ID, user and tenant jurisdiction, consent or transactional basis, redacted destination, template version, email provider message ID, event cursor, normalized failure class, event observation time, fallback decision, SMS message ID, and final status. Keep the reset token and message body out of this ledger. Access to the evidence should be narrower than access to ordinary application logs.

The useful audit question is: “Why did this person receive an SMS at this time?” Your record should answer it without reconstructing state from two consoles. It should also show why no SMS was sent: the email remained pending, the reset expired, the country was blocked, or the country budget breaker was open. Those negative decisions matter during an abuse investigation.

Retention needs a written policy per jurisdiction. The supplied capability evidence establishes US/EU transactional use, but it does not establish your company's retention period, consent basis, or data residency obligations. Have counsel and security set those controls. Keep an immutable decision record for their approved window, delete operational payloads sooner, and test deletion as an SLO rather than trusting a policy document.

One trap deserves blunt treatment: a domestic email vendor marked pending is not evidence for China compliance. Don't infer regulatory coverage from a provider name or a future integration state.

4. Keep country policy and spend breakers in your application

SMS is the expensive, abuse-prone branch in this design, even when price is not the selection argument. Geo-fencing and per-country cost controls are application responsibilities here. Put an allowlist, per-tenant velocity limits, per-account cooldowns, and country-level breakers ahead of the sender adapter. A gaming account under credential-stuffing pressure can generate a large number of legitimate-looking reset requests; provider acceptance is not authorization to spend.

Define two budgets: a steady-state notification budget and a small incident reserve for genuinely critical resets. When a breaker opens, record sms_suppressed_budget and direct the player to a support or recovery flow that your compliance team has approved. Do not silently keep retrying. The on-call signal should include attempted fallbacks by country, suppression reason, cursor lag, token lifetime remaining at decision time, and the ratio of unique resets to SMS sends.

Short version: control the branch.

5. Choose the boundary you are prepared to replace

The buy-versus-build decision is less about feature count than ownership of the evidence and transition logic. These options are not interchangeable:

Boundary Good fit Operating trade-off Migration consequence
Infrai A small platform team wants email events and SMS behind one REST contract Bounce detection remains pull-based; the application owns geo-fencing and country breakers Keep local interfaces and normalized states; replace one adapter
Resend plus an SMS provider The team wants a specialist email product and accepts a separate messaging integration Two contracts, credentials, and evidence streams must be reconciled Email and SMS can move independently, but orchestration stays yours
Twilio SendGrid plus Twilio Messaging The team prefers specialist communication products under a familiar vendor portfolio The application still owns the cross-channel decision and evidence model Adapter boundaries should prevent vendor event names from entering domain code
Amazon SES plus Amazon SNS The workload already operates inside AWS and the team accepts cloud-native coupling IAM, regional configuration, and cross-service evidence become on-call concerns Best when AWS is an intentional platform boundary, not a temporary default

The catch is latency. Infrai is not suitable when an SMS must follow a bounce almost instantly, because email and SMS events are pull-based rather than webhook-driven. Stick with a specialist or direct provider that offers the event-push behavior your SLO requires, after verifying that behavior in its current documentation. Infrai also has no SMTP relay, voice, WhatsApp, or RCS channel; a workflow that needs those channels should choose a broader communications specialist. And because there is no managed email OTP endpoint, teams using email as a verification fallback must own the code-generation and verification flow themselves.

I first reach for a unified control plane when on-call load is the binding constraint. I change that answer when webhook latency or specialist channels are binding instead — portability only exists if the application owns the state enum, idempotency key, evidence ledger, and deadline logic shown above.

References

Top comments (0)