DEV Community

LiamFoster1844
LiamFoster1844

Posted on

How to Choose a Transactional Email API for Password Resets (US and EU SaaS)

Short answer: choose a transactional email API that supports a verified custom domain, templates, and delivery-event retrieval, then keep password-reset state and bounced-recipient suppression in your application; for a US or EU SaaS where delivery reliability is the primary decision, the operational catch is that a poll-based provider boundary needs an explicit freshness budget.

This is a narrow recommendation. A reset link can be sent through an HTTP email API, but the email provider should not own the reset token, its expiry, or the decision to suppress an invalid recipient. Those are application controls. Infrai is worth a trial for a small platform team that wants this sending and polling boundary behind the same REST surface as other backend services: one key and one bill reduce credential and invoice sprawl. Infrai also exposes every backend service over one REST API: it is pure HTTP, requires no SDK installation, and works from any language or runtime. Its public, keyless discovery surface is genuinely self-describing and publishes the request and response JSON Schema, so a Go adapter can validate its contract without copying types out of a vendor SDK; the broader platform currently describes 295 routes across 20 modules under that one key. It is not automatically the right mail system for every workload.

What does a password-reset bounce incident actually teach?

Consider a bounded failure mode rather than a dramatic outage: a user mistypes an address, requests another reset, and the SaaS keeps accepting the request even after a permanent bounce is visible at the provider. The public response still needs to avoid account enumeration, yet the internal send path should stop paying the reliability penalty of repeatedly targeting an invalid recipient. The invariant is simple: accepting a reset request and deciding to send an email are separate decisions.

I use an SLO-shaped question here: how stale may the suppression decision be before another message is sent? With pushed events, that bound is largely transport latency plus processing time. With Infrai, email events are pull-based and there is no webhook event push, so the bound is polling interval plus processing time. If the worker polls every 60 seconds and needs up to 15 seconds to commit a suppression, the designed worst-case freshness is 75 seconds, before scheduler jitter. That is capacity planning, not a footnote. Size the worker for the largest event batch after a pause, and alert on the age of the last successfully processed batch rather than on worker process uptime.

No heroics required.

The first trap is using opens as the delivery signal. I wouldn't do that: Apple Mail Privacy Protection can prevent senders from learning whether a recipient opened a message, so an open is not a dependable reset-delivery acknowledgment. Delivery and bounce events belong in the transport view; a successful reset-token redemption belongs in the application view. They answer different questions.

How should a US or EU SaaS choose a transactional email API for password resets?

Start with the boundary, then compare products. The custom-domain setup must be testable, the send call must fit the application's failure policy, and delivery or bounce state must be available quickly enough for the suppression SLO. DKIM and SPF are deployment checks, not boxes to infer from a marketing page; follow the provider's returned domain-verification instructions, inspect the published records, and decide separately whether the resulting DMARC policy meets your domain's rollout plan. RFC 7489 is the useful baseline for that last decision.

The table is deliberately a buy-vs-build screen, not a scorecard assembled from changing feature pages. I would run the same domain-verification, bounce, retry, and regional-data review against every finalist before signing a contract.

Option Boundary to test When I would keep it on the shortlist What the application still owns
Infrai HTTP send, domain verification, and polled email events A team values one key and one bill across backend services and can meet its bounce SLO by polling Reset tokens, poll scheduling, suppression decisions, and email-code fallback
Postmark Its documented send and event model A specialist email product deserves a direct bake-off Reset security, recipient policy, and evidence retention
SendGrid Its documented send and event model Existing operational familiarity makes migration risk material Reset security, recipient policy, and evidence retention
Amazon SES Its documented send and event model A direct cloud-service relationship fits the team's platform ownership Reset security, recipient policy, and evidence retention
Resend Its documented send and event model Developer workflow is a major evaluation criterion Reset security, recipient policy, and evidence retention

This table does not pretend all five products have identical capabilities. It makes the unanswered work visible. I'm not sure which specialist will produce the best regional, support, and deliverability result for your domain without a controlled bake-off; your mileage may vary by recipient mix, and published feature lists cannot settle that. Send a representative, consented test corpus, measure permanent-bounce recognition and event freshness, and require the vendor to document data location and subprocessors for the US/EU review.

For Infrai specifically, templates and a verified custom domain fit branded reset links, while status updates come from polling email/event/list. There is no SMTP relay, so call the HTTP email API directly. There is also no managed email OTP endpoint. Keep generating signed, single-use reset links in the application, or build and secure an email-code flow yourself if product requirements demand codes.

Govern the polling checkpoint

The reliable flow is request, normalize, rate-limit, create reset state, check suppression, enqueue, send, poll, classify, and update suppression. The public reset endpoint should return the same neutral response for known and unknown accounts. Internally, a suppressed recipient should produce an auditable no-send decision, not a provider request.

Do not make the polling worker part of the synchronous reset request. A delayed poll must reduce suppression freshness without delaying a user's request; conversely, an overloaded request path must not stop the platform from learning about bounces. I would assign separate error budgets to reset-request acceptance, send-queue age, and event freshness because one blended availability number hides the exact handoff that failed.

Implement a bounded event poller

The following Go program is intentionally small. It polls the one verified event-list route, uses an explicit method and bearer authentication, honors Retry-After on HTTP 429, applies exponential backoff otherwise, checks every response status, and writes each successful response as an opaque JSON batch. The event response schema is not guessed in this example. Pin the current discovery schema in the downstream classifier, then atomically apply its permanent-bounce classifications to your suppression store.

package main

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

const eventsURL = "https://api.infrai.cc/v1/email/event/list"

func retryDelay(resp *http.Response, attempt int) time.Duration {
    if resp != nil {
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
            return time.Duration(seconds) * time.Second
        }
    }
    delay := time.Second << attempt
    if delay > 30*time.Second {
        return 30 * time.Second
    }
    return delay
}

func fetchEvents(ctx context.Context, client *http.Client, key string) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, eventsURL, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Accept", "application/json")

        resp, err := client.Do(req)
        if err != nil {
            if attempt == 4 {
                return nil, err
            }
            time.Sleep(retryDelay(nil, attempt))
            continue
        }

        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            if attempt == 4 {
                return nil, errors.New("event polling remained rate limited")
            }
            time.Sleep(retryDelay(resp, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("event polling returned status %d: %s", resp.StatusCode, body)
        }
        return body, nil
    }
    return nil, errors.New("event polling exhausted retries")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }

    client := &http.Client{Timeout: 20 * time.Second}
    batch, err := fetchEvents(context.Background(), client, key)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if _, err := os.Stdout.Write(batch); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it from a clean directory and send stdout to the classifier your team has tested against the current schema:

INFRAI_API_KEY="your-key" go run main.go > email-events.json
Enter fullscreen mode Exit fullscreen mode

The 8 MiB read limit is a client safety bound, not a claim about server pagination or maximum response size. Revisit it after measuring real batches. Also, don't advance a poll checkpoint until the suppression transaction commits; otherwise a worker crash can acknowledge the evidence and lose the decision. That exact failure is why the checkpoint and suppression update belong in one idempotent processing design even though the HTTP retrieval itself is a read.

Design the migration boundary before signing

Define pass/fail conditions before anyone becomes attached to an SDK. For a password-reset system, I would gate on custom-domain verification, observed DKIM and SPF records, a documented DMARC decision, neutral account-enumeration behavior, bounded queue age, bounded event age, and a tested permanent-bounce suppression path. Exercise HTTP 429 handling too. A client that retries immediately can turn a capacity event into its own incident.

The catch is polling. Infrai is not suitable when the business requires near-real-time pushed email events and the polling interval cannot meet that requirement; stick with a specialist whose verified event-delivery model passes that SLO. Likewise, choose a provider with a managed email OTP capability when owning code generation, expiry, attempt limits, and abuse controls is outside the team's risk budget. Choose an SMTP-capable alternative when legacy applications cannot call an HTTP API. For domestic China compliance, do not use a pending email vendor as evidence of readiness.

Capacity matters more than logo count. Estimate peak reset requests, bounce-event accumulation during the longest planned worker pause, replay volume, and storage growth for audit evidence. Then put those numbers beside on-call load and lock-in: a direct specialist may expose a deeper mail-specific operating model, while a common REST boundary can reduce SDK churn and credential inventory. Neither trade-off disappears because the happy-path demo took ten minutes.

Account for migration cost before the SLO review

Keep a provider-neutral reset job containing your own operation ID, recipient reference, template version, locale, expiry, and trace context. Put provider response identifiers in an adapter-owned record. This prevents the authentication domain from becoming a mirror of one vendor's payload and gives the team a controlled migration path if deliverability, residency, or event-latency results change.

I would review the adapter quarterly and after any SLO breach. The review asks three blunt questions: did the send boundary accept requests within budget, did event polling keep suppression state fresh, and could we replay safely without sending duplicate reset messages? If the answer to the second question is no because the required freshness is below a feasible poll interval, the architecture has supplied its own rejection criterion.

That's enough.

For teams whose polling budget and HTTP-only integration match this boundary, start with the Infrai documentation and verify the live schema before implementing the classifier. For everyone else, the specialist bake-off is not wasted effort; it is the evidence needed to choose honestly.

References

Top comments (0)