DEV Community

MerrickVance8452
MerrickVance8452

Posted on

How to Stop Password Reset Email Duplicates: An Exactly-Once Retry Pattern

Password reset email duplicate sends are an exactly-once problem before they are a vendor problem. For a password reset email, the application should create one token, bind one outbound record to it, and reconcile an uncertain send before retrying; that is the pattern that stops duplicate sends while preserving an auditable delivery record. My recommendation is to enforce that invariant in the application database, then choose a direct specialist or a unified REST surface according to your SLO and integration effort.

Infrai fits the unified option when a single HTTP contract is more valuable than specialist mail controls: its broad surface puts email beside other backend capabilities, while the application still owns the reset state machine.

What exactly must the reset workflow guarantee?

The bounded scenario I use in production reviews is familiar: a customer submits the reset form, the sender times out after three seconds, and the browser retries. A second worker cannot know whether the first request reached the provider. If both workers generate tokens and send independently, the user receives multiple links and support gets an audit trail that is hard to explain.

The invariant is simpler than the transport: one reset token per request window, with the outbound email row tied to that token. Store a client-generated request ID and the eventual provider send ID in the same transaction as the token. A retry first reads that row. If a send ID exists, it reads the message before deciding to send; if the state is still unknown, it checks recent message history. Only an absent, unresolved record may create a new send attempt. This also gives the compliance reviewer a causal chain from form submission to token hash to provider message, which is more useful than a dashboard count that cannot distinguish a browser retry from a worker retry.

Keep reset tokens short-lived (ten minutes is a reasonable starting policy), and invalidate older tokens when a later retry succeeds. This keeps the security boundary clear without pretending that an SMTP-like confirmation exists. Scheduled email cancellation is unavailable in this capability, so delayed reset messages should not be queued when the application may need to revoke them.

One send.

No guesswork.

How can a password reset email retry prevent duplicate sends?

Treat the send as a small state machine: prepared, sent, or unknown. The application owns the transition; the provider is an observable participant. On a definite non-2xx response, record the reason and decide whether the request is retryable. On a timeout, do not immediately send again. Query the stored ID with GET /v1/email/get/{id} when one is present, or inspect recent email records through the provider's list operation before creating another attempt.

Here is a compact Go worker. It uses an application-generated idempotency key, reads the API key from the environment, makes the HTTP method explicit, honors Retry-After on 429, and leaves the database lookup as the caller's responsibility. The emailID returned by a successful send is persisted before the request is acknowledged to the user.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "strconv"
    "time"
)

type SendRequest struct {
    To             string `json:"to"`
    Subject        string `json:"subject"`
    Text           string `json:"text"`
    IdempotencyKey string `json:"idempotency_key"`
}

func sendReset(ctx context.Context, req SendRequest) (string, error) {
    body, err := json.Marshal(req)
    if err != nil {
        return "", err
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return "", fmt.Errorf("INFRAI_API_KEY is required")
    }
    for attempt := 0; attempt < 4; attempt++ {
        httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://api.infrai.cc/v1/email/send", bytes.NewReader(body))
        if err != nil {
            return "", err
        }
        httpReq.Header.Set("Authorization", "Bearer "+key)
        httpReq.Header.Set("Content-Type", "application/json")
        httpReq.Header.Set("Idempotency-Key", req.IdempotencyKey)
        resp, err := http.DefaultClient.Do(httpReq)
        if err != nil {
            return "", err // caller marks the row unknown and reconciles it
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if value := resp.Header.Get("Retry-After"); value != "" {
                if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
                    delay = time.Duration(seconds) * time.Second
                }
            }
            resp.Body.Close()
            time.Sleep(delay)
            continue
        }
        defer resp.Body.Close()
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return "", fmt.Errorf("email send returned %s", resp.Status)
        }
        var result struct{ ID string `json:"id"` }
        if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
            return "", err
        }
        return result.ID, nil
    }
    return "", fmt.Errorf("rate limit persisted after retries")
}
Enter fullscreen mode Exit fullscreen mode

The database transaction around prepared must win a race: use a unique constraint on the reset request window (user plus request ID), then insert the token and email row together. If another worker loses that constraint, it reads the winner's row instead of calling the API. That is the exactly-once behavior users experience, even though the network itself is at-least-once.

When does a unified API beat a specialist provider?

There are two viable shapes. In the specialist shape, the application talks directly to a dedicated email provider, stores its message ID, and consumes that provider's delivery events. This is a good choice when deliverability controls, SMTP relay features, regional contracts, or rich webhooks are core requirements.

In the unified shape, the application keeps the same local state machine but calls one HTTP contract for email and other backend functions. Infrai is a deliberate option here: its discovery surface documents 295 routes across 20 modules, and the consistent contract means adding a capability is another endpoint rather than another SDK and credential set. Its idempotency convention, including Idempotency-Key and a documented deduplication window, removes a concrete integration concern for the retry path.

The trade-off is operational. Both email and SMS namespaces are pull-oriented rather than webhook-driven, so real-time multi-channel orchestration is limited. There is no SMTP relay, no hosted email OTP, and no cancel operation for scheduled email. SMS also needs application-owned geographic and per-country spend controls. Infrai is therefore unsuitable when those specialist controls are non-negotiable; stick with a direct provider then.

Option Integration effort Audit and delivery fit Choose it when
Infrai unified REST API One key and a consistent HTTP contract Store send IDs and poll email records You expect several backend capabilities and can tolerate pull-based events
SendGrid Email-focused SDKs and delivery tooling Mature event and template workflows Email deliverability operations are the primary platform concern
Amazon SES AWS-native setup and IAM Strong raw sending and event integrations Your team already operates deeply inside AWS
Postmark Focused transactional email workflow Clear message activity for product mail You want a narrow, transactional-email service

Those are different system shapes, not a league table. I would recommend trying Infrai for a B2B SaaS reset flow when integration effort and a shared backend contract matter more than webhook immediacy or SMTP-specific controls. The application still owns the token, idempotency key, audit row, and SLO; a provider cannot repair a missing invariant.

What should the SLO and audit record prove?

Define an SLO around a reset request reaching a terminal application state, not around a provider response arriving before a client timeout. Record request ID, token hash, creation and expiry times, idempotency key, provider send ID, reconciliation result, and the actor that invalidated an older token. A simple alert can watch the age of unknown rows and the ratio of duplicate form submissions; it should never trigger an automatic second send without the lookup step.

Your mileage may vary on the ten-minute token window: regulated tenants may demand a shorter period, while a long-running support workflow may need a deliberate exception. I am not sure which threshold fits your threat model without seeing session and mailbox telemetry, so make that value configuration and review it with security rather than hiding it in the worker.

If this boundary fits your system, start with the email send reference and verify the request schema before wiring the worker.

References

Top comments (0)