DEV Community

UlyssesBlack2385
UlyssesBlack2385

Posted on

Password Reset Email Duplicate Sends: 3-State Postgres Retry After Timeout

TL;DR: Treat a password-reset email as a state transition, not a function call. Create one short-lived token and one outbound-email row in the same request window, move that row through ready, sending, and accepted, and never mint another token merely because an HTTP response went missing. On a timeout, reconcile the stored provider send ID and recent message history before retrying. That is the practical exactly-once pattern: one logical recovery notification, even though the network can deliver an ambiguous result.

For a marketplace, this matters beyond the link itself. A seller-account recovery message may also carry a generated security report as an attachment, so a duplicate is both confusing and a needless repeat of sensitive material. The same control record should bind the account, token hash, report digest, request window, and provider send ID.

Infrai can fit when a team wants email alongside other backend services under one REST API, one key, and one bill; its documented idempotency convention and pull-based email lookup support this ledger pattern. The operational trade-off is important: email events are polled rather than pushed, scheduled email cannot be canceled, and there is no SMTP relay. Those constraints should decide the design before any vendor comparison does.

How should password reset email retry prevent duplicate sends?

The useful page is not "email API timed out." That alert describes transport noise and gives the responder no safe action. Page when a recovery row remains in sending beyond the reconciliation budget, or when the same request window acquires more than one active token. Both conditions point to an invariant violation or a stuck workflow.

Timeouts lie.

Consider a bounded incident reconstruction. At 03:07, the marketplace API commits reset token T1, generates report digest R1, and starts the send. The provider accepts the message, but the response is lost. At 03:08, a generic retry wrapper creates T2 and sends again. The customer now sees two plausible links, the responder sees two successful requests on a dashboard, and neither graph answers which link should work.

This is the invariant I would put in the postmortem: for one account and one reset-request window, there is exactly one active token and one logical outbound message. A retry may repeat an attempt; it may not create a new intent. The report bytes must also match the recorded digest.

Stop there.

Dashboards are weak evidence here. They can show latency and error counts while hiding the uncertainty between provider acceptance and client acknowledgement. Ask a harder question: what durable fact makes the next send safe? If the answer is only "the previous call timed out," it is not safe.

The ledger is the control plane

A compact Postgres record is enough. Give it a unique constraint on the request-window key, store only a hash of the reset token, and retain the provider ID as soon as one is returned. The three primary states have deliberately narrow meanings:

State Meaning Permitted next action
ready Intent, token hash, and attachment digest are committed Claim once and start a send
sending An attempt may have reached the provider Reconcile first; do not mint or blindly resend
accepted Provider acceptance was confirmed by response or lookup Invalidate older tokens and stop retrying

A terminal failed outcome can exist for a confirmed rejection, but it is not a synonym for timeout. Ambiguity belongs in sending. Short token expiry limits exposure, while invalidating older tokens after acceptance prevents a late recovery attempt from leaving two valid links.

The database transaction should create or return the existing intent. A unique key such as (account_id, request_window) closes the race between two application instances. The provider's idempotency key, where supported, should be derived from that same stable intent ID. It is a second guard, not a replacement for the ledger, because the application still owns token validity and attachment identity.

Do not delay these messages with a schedule you may need to revoke. If the email provider surface has no scheduled-send cancellation, enqueue only work the application can suppress before the worker claims it; once claimed, send immediately and reconcile uncertainty.

A preventative Go path

The provider boundary below is intentionally small. It avoids guessing a vendor request schema and makes the reliability contract testable: a timeout returns ErrUnknown, a definite rejection returns another error, and reconciliation uses the stored send ID or the intent key.

package recovery

import (
    "context"
    "errors"
    "fmt"
)

var ErrUnknown = errors.New("send outcome unknown")

type Intent struct {
    ID, AccountID, TokenHash, ReportSHA256, ProviderSendID, State string
}

type Store interface {
    GetOrCreate(context.Context, string, string, string, string) (Intent, error)
    ClaimReady(context.Context, string) (bool, error)
    SaveProviderID(context.Context, string, string) error
    MarkAcceptedAndInvalidateOlder(context.Context, string, string) error
    ReleaseForConfirmedRetry(context.Context, string) error
}

type Sender interface {
    Send(context.Context, Intent, []byte, string) (string, error)
    Find(context.Context, string, string) (accepted bool, found bool, err error)
}

type Service struct { Store Store; Sender Sender }

func (s Service) Request(ctx context.Context, accountID, window, tokenHash, digest string, report []byte) error {
    intent, err := s.Store.GetOrCreate(ctx, accountID, window, tokenHash, digest)
    if err != nil { return fmt.Errorf("create recovery intent: %w", err) }
    if intent.State == "accepted" { return nil }
    if intent.State == "sending" { return s.reconcile(ctx, intent) }

    claimed, err := s.Store.ClaimReady(ctx, intent.ID)
    if err != nil { return fmt.Errorf("claim recovery intent: %w", err) }
    if !claimed { return nil }

    providerID, err := s.Sender.Send(ctx, intent, report, intent.ID)
    if err != nil {
        if errors.Is(err, ErrUnknown) { return s.reconcile(ctx, intent) }
        if releaseErr := s.Store.ReleaseForConfirmedRetry(ctx, intent.ID); releaseErr != nil {
            return fmt.Errorf("send rejected: %v; release intent: %w", err, releaseErr)
        }
        return fmt.Errorf("send rejected: %w", err)
    }
    if err := s.Store.SaveProviderID(ctx, intent.ID, providerID); err != nil {
        return fmt.Errorf("save provider id: %w", err)
    }
    return s.Store.MarkAcceptedAndInvalidateOlder(ctx, intent.ID, intent.AccountID)
}

func (s Service) reconcile(ctx context.Context, intent Intent) error {
    accepted, found, err := s.Sender.Find(ctx, intent.ProviderSendID, intent.ID)
    if err != nil { return fmt.Errorf("reconcile send: %w", err) }
    if accepted {
        return s.Store.MarkAcceptedAndInvalidateOlder(ctx, intent.ID, intent.AccountID)
    }
    if !found { return ErrUnknown }
    return s.Store.ReleaseForConfirmedRetry(ctx, intent.ID)
}
Enter fullscreen mode Exit fullscreen mode

There is no tight retry loop. ErrUnknown leaves the intent quarantined for a later reconciliation worker with bounded exponential backoff; if a provider supplies Retry-After for rate limiting, the adapter honors it. A worker may release an intent only after lookup establishes a retryable, non-accepted result. This distinction is the entire mechanism.

One detail deserves suspicion: saving a provider ID after the remote call still has a crash gap. A stable client idempotency key closes that gap when the provider honors it; otherwise, recent-message lookup by the stable intent attributes is required before another attempt. If neither facility exists, exactly-once external delivery cannot be promised. Keep the token invariant anyway, so an accidental duplicate does not create multiple valid credentials.

For an Infrai adapter, reconciliation can query the verified email lookup route without assuming any undocumented response fields. This runnable transport helper returns the JSON body to the adapter's schema-aware decoder; it retries only rate limits, honors Retry-After, and surfaces every other non-success response.

package recovery

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

func getInfraiEmail(ctx context.Context, sendID string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" { return nil, fmt.Errorf("INFRAI_API_KEY is required") }
    base := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    if base == "" { return nil, fmt.Errorf("INFRAI_BASE_URL is required") }
    endpoint := base + "/" + path.Join("email", "get", url.PathEscape(sendID))

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil { return nil, fmt.Errorf("build request: %w", err) }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := http.DefaultClient.Do(req)
        if err != nil { return nil, fmt.Errorf("query email: %w", err) }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        closeErr := resp.Body.Close()
        if readErr != nil { return nil, fmt.Errorf("read response: %w", readErr) }
        if closeErr != nil { return nil, fmt.Errorf("close response: %w", closeErr) }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 { return body, nil }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("query email: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        } else if at, err := http.ParseTime(resp.Header.Get("Retry-After")); err == nil && time.Until(at) > 0 {
            delay = time.Until(at)
        }
        timer := time.NewTimer(delay)
        select {
        case <-ctx.Done(): timer.Stop(); return nil, ctx.Err()
        case <-timer.C:
        }
    }
    return nil, fmt.Errorf("query email: rate limit retry budget exhausted")
}
Enter fullscreen mode Exit fullscreen mode

How do the provider choices change the failure mode?

The right comparison is recovery evidence, not the prettiest activity graph. These products can all send transactional email and attachments, but their surrounding contracts lead to different responder workflows. Verify the current API details against the linked documentation before implementing an adapter.

Option Useful fit Reliability boundary to design around
Amazon SES Teams already operating in AWS that want API or SMTP submission and event publishing through AWS destinations Application-level token and intent deduplication still belongs in Postgres; configure event publication rather than treating submission as delivery
Twilio SendGrid Teams that want a mature mail-send API plus Event Webhook telemetry Secure and deduplicate webhook consumption, and do not confuse an accepted API request with inbox delivery
Postmark Transactional-mail-focused teams that value message retrieval and delivery webhooks Keep the reset-token invariant locally; webhook delivery and the original send are separate processing concerns
Resend Teams preferring a compact HTTP API with documented idempotency keys and webhook events Match the idempotency retention behavior to the application's request window and verify webhook signatures
Infrai Backends consolidating multiple service categories under one key and one bill, with a consistent idempotency convention Email status is pull-based, so reconciliation latency and polling load are application concerns; no SMTP relay or scheduled-email cancellation

The differences are not cosmetic. Webhooks can shorten detection time, but they introduce signature verification, replay handling, and event deduplication. Polling is simpler at the ingress boundary but slower and easier to overrun during an incident. An SMTP relay can ease migration from legacy software, yet SMTP acceptance does not supply the application transaction you need for one active reset link.

No vendor turns two distributed systems into a single atomic commit. Choose the one whose evidence you can reconcile under pressure, then enforce identity and token validity in your own database.

Where this pattern stops

This design is for security-sensitive transactional mail whose duplicate side effect matters. It is excessive for a newsletter, where recipient-level campaign deduplication and unsubscribe enforcement dominate. It also does not claim exactly-once inbox placement: forwarding, mailbox rules, and provider retries are outside the application's transaction.

It is insufficient when policy requires immediate revocation of a message already scheduled at the provider. Use an application-owned queue and suppress before dispatch, or select a provider with a cancellation contract that meets that requirement. For a marketplace operating in a jurisdiction with provider-specific compliance obligations, a pending regional vendor is not evidence of readiness; legal and deliverability review must happen separately.

The final runbook can be short. On an alert, inspect the ledger row, ask what page fired, reconcile the provider ID or recent history, and preserve the existing token unless a confirmed terminal result permits a new intent. Three states beat thirty charts.

Sources

References:

Top comments (0)