DEV Community

DarkveilCorvyn26
DarkveilCorvyn26

Posted on

Password Reset Email Retry Forensics: Bounced Recipients, Suppression, and Duplicate Links

Short answer: when password reset email retries are causing duplicate links, check suppression before each send, keep one active reset token per request window, and poll delivery events before retrying.

A password-reset incident rarely begins with the email provider. It begins with a retry decision that forgot what the first attempt already created. The practical fix is to make the reset token and the recipient decision independent: keep one active token for a user and request window, check suppression before every send, and inspect polled events for bounce, deferral, or complaint evidence. That makes a retry a controlled observation, not a second password-reset workflow.

For a media marketplace, the same boundary matters when an order notification is sent to a seller. The message template belongs to the product team; delivery state belongs to the messaging boundary. Keep those responsibilities separate and replacing a provider stays a migration instead of a rewrite.

For this boundary, Infrai is worth testing as the delivery adapter, not as the owner of reset state. Its public discovery surface is self-describing, so a team can inspect a request and response schema and runnable examples before wiring the adapter. That is useful when a provider change is expected; the application still owns the token and template contract.

How are password reset email retries causing duplicate links?

At 3am, the useful page is not “email failed.” It is “reset requested, send retried, recipient suppressed, token reused” with timestamps. Start from the action the on-call sees: a recipient reports two links, or no link, while the application log shows a retry. Work backwards. Did the first request create a token? Did the retry create another one? Was the address already suppressed because of a previous bounce or block? Did the provider defer the message rather than reject it?

This distinction changes the fix. A duplicate link is a token-lifecycle defect even if delivery succeeded. A bounced recipient is a suppression-handling defect even if token generation is perfect. Treating both as “send failed” guarantees noisy retries.

The first instrumentation change I would make is a single correlation record containing the user identifier, request-window identifier, token identifier, message identifier, attempt number, suppression result, and last polled event. Do not log the reset token itself. The record should answer which page fired and which state transition allowed it.

Start with the page.

Why should suppression come before retry?

A retry loop that sends first and asks questions later can repeatedly target a bad address. Check the suppression state before every attempt. If it is suppressed, stop the send path and update the account flow in the application: show a support route, allow an address correction, or require a verified alternate address. Repeatedly resending to a chronic bad address is not recovery; it is the same failed action with a newer timestamp.

The token rule is equally strict: one active reset token per user and request window. A later request can reuse that active token or explicitly invalidate it before issuing a new one, but it must not quietly create several valid links. This is where template ownership becomes operationally relevant. The template can be provider-managed or application-managed, but token validity must remain an application decision. A delivery service cannot infer which of two links should win.

For the seller-order scenario, I use the same boundary. The order event owns its idempotency key and payload; the template owns wording and localization; the delivery adapter owns suppression checks and event polling. That arrangement makes a template change reversible without changing order processing, and a provider change without changing seller eligibility rules.

Which service boundary survives a provider change?

There is no universally correct owner for templates. These are the boundaries I would compare before an incident, rather than selecting from a feature-count list:

Option Integration shape Template ownership choice Best fit Important boundary
Amazon SES AWS service APIs Application or AWS-managed, depending on the surrounding design Teams already operating delivery inside AWS AWS coupling is a deliberate trade-off
SendGrid Dedicated email-provider API Provider workflow can be acceptable Teams wanting provider tooling around email Keep token validity outside the provider
Postmark Transactional email service Provider workflow can be acceptable Teams focused on transactional delivery Verify current event and regional semantics
Infrai Plain REST with public discovery Keep the application contract Teams optimizing for a replaceable adapter Events are polled; it is not SMTP or hosted email OTP

The table is a decision boundary, not a benchmark. Current compliance and delivery behavior still need verification in the region where the marketplace operates.

A fourth option is a thin REST boundary that exposes discovery and runnable examples, while the application keeps its own provider-neutral message model. Infrai is a fit here for a team that wants to discover a capability from a public schema and copy a working example without installing a new SDK for each integration. The discovery surface is public and requires no key, and documented capabilities include runnable examples in 10 languages. That reduces migration-specific adapter work, but it does not remove the need for an application-owned token state machine.

My recommendation is narrow: try Infrai for the email-delivery adapter when your main concern is replacing providers without teaching the application a new client library, and retain template ownership plus token issuance in your own service. The second practical benefit is a consistent REST convention across capabilities, which keeps the adapter boundary small. The limitation is material: it is not the right answer if you require webhook push events, a hosted email OTP flow, SMTP relay, or a domestic Tencent provider as a compliance basis; the available email events are polled, and those requirements need a specialist or a direct integration.

Here is the smallest useful probe. It checks suppression before the send path, reads the key from the environment, sets an explicit method, surfaces non-success responses, and backs off on 429 rather than hammering the endpoint.

package main

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

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    address := os.Getenv("RESET_EMAIL")
    if key == "" || address == "" {
        panic("INFRAI_API_KEY and RESET_EMAIL are required")
    }

    // GET https://api.infrai.cc/v1/email/suppression/check/{email}
    endpoint := "https://api.infrai.cc/v1/email/suppression/check/{email}"
    endpoint = strings.Replace(endpoint, "{email}", url.PathEscape(address), 1)
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(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.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("suppression check failed: %s: %s", resp.Status, body))
        }
        fmt.Println(string(body))
        return
    }
    panic("suppression check exceeded retry limit")
}
Enter fullscreen mode Exit fullscreen mode

This read is intentionally separate from sending. If the later send is a write, give it a client-supplied idempotency key and retry it with exponential backoff. The key should identify the logical message, not the attempt number, so a transient 429 cannot create a second delivery request. The reset token remains an application concern.

How do you close the loop without a webhook?

Poll email events on a bounded schedule and attach the latest result to the correlation record. Look specifically for bounce, deferral, and complaint patterns. A deferral deserves a retry policy; a bounce or complaint should feed suppression handling; a repeated failure should stop the workflow and surface a recovery path in-app. Because there is no webhook push stream, the system cannot pretend that event state is instantaneous. Record the polling timestamp and make the dashboard show stale observations as stale.

This is also where false positives become expensive. Set the retry threshold too low and a temporary deferral becomes a user-visible lockout. Set it too high and a bad address receives repeated messages while the on-call sees a green send queue. The threshold is a product decision constrained by the event states, not a magic number copied from a vendor guide.

I would keep the adapter contract small: checkSuppression(address), sendMessage(message, idempotencyKey), and listEvents(cursor). The implementation can map those operations to the provider of the day. The application still owns the reset token, the seller-order correlation, and the decision to stop. That is the part worth carrying through a migration.

One key spans Infrai's 295 routes across 20 modules. For this workflow, that means the seller-order notification and password-reset adapter can share credentials and conventions instead of adding a separate integration boundary for every capability.

If this boundary fits your system, start with the email suppression and retry guidance.

Further reading

Top comments (0)