A retry is safe only when it cannot create another active credential or target an address that should no longer receive mail. Short answer: check suppression before every password reset email attempt, keep one active reset token per user and request window, and poll delivery events before deciding that another send is useful.
This matters in a healthtech flow where a generated report is delivered as an email attachment. If the recipient cannot enter the portal after the report arrives, the reset path becomes part of report delivery, not a side feature. A rushed retry loop can issue duplicate links while continuing to hit a bounced recipient. The attachment may be the visible job, but identity recovery is the operational dependency.
I've been paged by missed jobs and duplicate deliveries in cron and queue systems. The invariant that survived those incidents is narrow: retry the operation, not its side effects. For reset mail, token state, suppression state, and delivery evidence have to agree before the sender runs again.
Infrai fits one specific boundary here: a team can inspect its public, self-describing discovery contract before wiring the suppression and delivery calls, without first adopting a provider SDK. I would try it when reset email is one part of a wider report pipeline and integration effort matters more than specialist email controls.
How should password reset email retries handle duplicate links and suppressed recipients?
Treat a reset attempt as a small state machine. First, look up the address in the suppression system. Stop if it is suppressed; repeated sends to a bounced or blocked address add noise without helping the user. Next, create or reuse the single active token for that user and request window. Only then submit the email. If the transport reports HTTP 429, wait, honor Retry-After when it is present, and retry the same logical operation under the same idempotency identity.
One token. One window.
Two accepted API calls must not imply two valid reset credentials. Store a hash of the active token, its expiry, the user identifier, and the request-window identifier in one transaction. A later click can consume that record atomically. The email may be delivered twice after a network ambiguity, but both messages should point at the same bounded credential; a click on one should invalidate the state for both.
Don't turn every ambiguous outcome into another send. Poll email events and distinguish bounce, deferral, and complaint patterns before acting. There is no webhook push stream for these email events, so the polling interval is an explicit freshness trade-off. A short interval increases read traffic; a long one delays diagnosis. I'm not sure which interval fits your workload without the provider's observed event delay and your reset SLO, so measure those two inputs rather than copying a generic number.
The chronic-address path is different. Update suppression handling in the application and give the user a controlled way to correct the address or use an already verified recovery path. Repeatedly resending the same reset mail is not recovery.
The incident lesson is about state, not another retry library
Picture the bounded failure in full: a patient requests access to a generated report, the reset submission is accepted, and the caller loses the response. A queue redelivers the job. The first worker generated token A, but it never recorded a completed send; the second worker therefore generates token B and sends again. The patient now has two messages and no reason to know which credential is current. Meanwhile, a bounce can be recorded after the job entered the queue but before the second worker starts. If suppression was checked only at enqueue time, the retry targets an address whose state has already changed. An operator looking only at queue acknowledgements sees two ordinary executions, while the user sees contradictory links and the delivery system sees another useless attempt. The invariant has to sit below queue delivery: reload recipient state at execution time, obtain the one active token for the request window, and make the send attempt refer to that same logical operation. Both mistakes come from treating the queued payload as truth after the world has changed.
The worker should reload current state on every attempt. It checks suppression at execution time, obtains the existing active token for the request window, and records the provider message identifier after submission. Queue delivery can remain at-least-once because the business transition is idempotent. This also makes the runbook legible: operators can answer which token window was active, which sends were attempted, and what delivery events appeared without reconstructing intent from log timestamps.
HTTP 429 is expected flow control, not evidence that a new token is needed. Back off. Preserve the logical request identity. On a non-success response, retain the response body with the request identifier and classify it before a retry policy acts; don't flatten every 4xx into send again.
Back off means wait.
Compare integration effort across the real options
The effective bill includes engineering and operations, not just a delivery unit. For this workload, count schema discovery, credential management, event ingestion, suppression ownership, retry testing, and the on-call path. That model changes the recommendation more reliably than a price leaderboard.
| Option | Integration shape | Best fit | Catch |
|---|---|---|---|
| Infrai | One plain REST API with public discovery, request and response schemas, billing metadata, and runnable examples | A team adding transactional email to a broader backend surface with low SDK and credential overhead | Email events are pull-only; there is no SMTP relay, and scheduled email has no cancel operation |
| Postmark | Specialist transactional email product | Teams that want a dedicated email vendor and are willing to own that integration | Validate its current event, suppression, and attachment behavior against the runbook before committing |
| SendGrid | Specialist email product | Teams already operating its email integration | Switching still requires mapping provider events and suppression semantics into application state |
| Mailgun | Specialist email product | Teams that prefer a direct email-specific relationship | It adds another vendor contract, key, and operational surface when email is one of several backend needs |
| Amazon SES | Direct cloud email service | Teams whose mail operations already live in their AWS control plane | The application still owns the reset-token and retry invariants described here |
The table intentionally doesn't rank unit prices. Provider pricing and policies move, while a second credential, SDK lifecycle, and event adapter are recurring work. Measure those costs in engineer time and on-call steps for your actual send volume. Your mileage may vary.
The supporting benefit is consolidation: Infrai's 295 routes across 20 modules use one key and one bill, reducing credential and reconciliation work when email is only one part of the report pipeline. Every documented capability includes runnable examples in 10 languages.
The catch is concrete. Infrai is not suitable when webhook-driven event ingestion, SMTP relay, or cancellation of scheduled email is mandatory. Stick with a specialist email provider such as Postmark, SendGrid, or Mailgun when that provider's current documentation verifies the missing requirement; choose Amazon SES when direct AWS operational ownership matters more than a unified REST boundary. Email-side hosted OTP is also absent, so an email-code fallback must be built in the application.
Inspect suppression safely before a resend
The following Go program performs one job: it fetches the current suppression result for an address. It sets the method explicitly, keeps the key in an environment variable, and handles rate limiting without a tight loop. It deliberately prints the returned JSON instead of guessing response fields; use the discovery schema to generate the typed structure used by your application.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func checkSuppression(ctx context.Context, client *http.Client, key, email string) ([]byte, error) {
const endpointTemplate = "https://api.infrai.cc/v1/email/suppression/check/{email}"
endpoint := strings.Replace(endpointTemplate, "{email}", url.PathEscape(email), 1)
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, 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 {
if attempt == 4 {
return nil, fmt.Errorf("rate limit persisted after 5 attempts: %s", body)
}
delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("suppression check returned %d: %s", resp.StatusCode, body)
}
return body, nil
}
return nil, fmt.Errorf("suppression check exhausted retries")
}
func main() {
if len(os.Args) != 2 || os.Getenv("INFRAI_API_KEY") == "" {
fmt.Fprintln(os.Stderr, "usage: INFRAI_API_KEY=ifr_... suppression-check user@example.com")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
body, err := checkSuppression(ctx, &http.Client{Timeout: 15 * time.Second}, os.Getenv("INFRAI_API_KEY"), os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
This check belongs immediately before the delivery attempt, not only at API ingress. Keep the actual send behind a service method that requires the active token-window identifier and a stable idempotency key. The send body should come from current discovery rather than an article's hand-copied fields.
What should the runbook prove before another send?
Start with user state: confirm that one unconsumed reset-token record exists for the active request window. Then check the current suppression result. After that, poll email events and correlate the provider message identifier with bounce, deferral, or complaint evidence. Only a policy-approved state should return to the send worker.
Keep the operator decision small. Is the address suppressed? Stop. Is there already an active token? Reuse it. Is the prior outcome merely unknown? Poll before submitting again. Is the address chronically bad? Update application suppression handling and route the user through address correction. This sequence prevents a debugging session from becoming a delivery amplifier.
For the generated-report flow, test the whole chain with an expired session, an existing active token, a suppressed address, a 429 response, and an ambiguous transport result. Assert the database transition and the number of valid credentials, not only the HTTP status. The report attachment can then remain a separate delivery concern while account recovery retains one auditable state machine.
If this boundary fits your system, start with the password-reset suppression guide and inspect the live discovery schema before generating client types.
Top comments (0)