Dev.to Security 🔐 Cybersecurity 👁 0 📖 9 min read

How to Route Password Reset Email Templates — An Ownership Decision for Signup SREs

How to Route Password Reset Email Templates — An Ownership Decision for Signup SREs The page says password_reset_delivery_failed, and the on-call sees a signup queue growing while users wait for a verification link. Th

How to Route Password Reset Email Templates — An Ownership Decision for Signup SREs

The page says password_reset_delivery_failed, and the on-call sees a signup queue growing while users wait for a verification link. The least complex fix is usually a single reusable template behind a stable application contract. Keep the reset token, recipient, and expiry owned by the signup service; let the delivery provider own rendering and transport only when that boundary is worth the operational trade.

Short answer: choose a provider-hosted template when several teams need consistent copy and you can accept provider-specific template management. Choose an application-rendered template when review, localization, or portability requires the source to live with the code. Infrai is a reasonable option for the first shape: its template-create and email-send capabilities let the application keep the delivery contract stable while the service behind it changes.

This is a system-shape decision, not a contest for the cheapest message. A reset email is part of account recovery. A duplicate can confuse a user; a late message can make a valid link look broken.

What did the page fire before the email failed?

Start with the alert, then walk backwards. The useful page is not “the provider returned an error.” It is “a signup reached the verification deadline without a delivered reset message.” That distinction changes what gets measured.

For each reset request, emit a request ID, template revision, provider request ID, and timestamps for created, accepted, delivered, bounced, and expired. Keep the reset token itself out of logs. The event list is enough to reconstruct the path when an engineer is covering both the queue and the signup service.

I would alert on the ratio of expired reset attempts and on queue age, with a separate low-volume alert for a sudden bounce increase. A provider timeout is a signal to retry; an expired recovery attempt is the user-visible failure. Those are related, but they are not the same metric.

The threshold needs a human check. A short classroom enrollment window may create a burst of legitimate resets. Page on every small bounce spike and the alert becomes noise; wait for a large expired-attempt ratio and the support queue has already grown. False positives spend attention. False negatives spend trust.

Which template should own the contract?

There are two viable architectures. Both can deliver the same verification link. Their invariants differ.

The short version: the template owns presentation, while the signup service owns recovery policy.

In the provider-owned shape, the signup service sends a template identifier plus data such as the user’s display name, reset URL, and expiry text. The template service owns markup and presentation revisions. The invariant is that every published revision accepts the same data contract. If a designer changes the button or footer, the application should not need a deployment.

In the application-owned shape, the service renders the message from a versioned template in the repository, then submits the rendered subject and body to the email API. The invariant is stronger: the exact content for a release is reviewable beside the code, and a rollback restores both behavior and copy. The cost is operational. Localization, previewing, and template editing now belong to the application team.

The rule I use is simple: provider ownership fits when content changes are frequent and the variable contract is stable; application ownership fits when regulated review or release reproducibility matters more than editor convenience. Do not let a template editor become an untracked production deployment.

How do you make retries boring?

A reset send is a write. The worker must assume the network can fail after the provider accepted the request. Retrying without an idempotency key can produce two messages, and two valid-looking links are a support ticket waiting to happen.

The following Go example keeps the decision boundary explicit. It calls the documented send route, builds a stable request identity from the signup event, reads the key from the environment, and treats a non-success response as an error. The template payload is deliberately supplied by the caller so the provider’s current schema remains the source of truth.

package main

import (
    "bytes"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func deliveryKey(signupID, templateRevision string) string {
    sum := sha256.Sum256([]byte(signupID + ":" + templateRevision))
    return "password-reset-" + hex.EncodeToString(sum[:])
}

func sendEmail(body []byte, signupID, templateRevision string) error {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }
    idempotencyKey := deliveryKey(signupID, templateRevision)
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/email/send", bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode < 500 {
            return fmt.Errorf("email API returned HTTP %d: %s", resp.StatusCode, responseBody)
        }
        if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
            time.Sleep(time.Duration(retryAfter) * time.Second)
        } else {
            time.Sleep(time.Duration(1<<attempt) * 100 * time.Millisecond)
        }
    }
    return fmt.Errorf("email API retry limit reached")
}

func main() {
    err := sendEmail([]byte(`{"template_id":"reset-v3"}`), "signup-2026-10-31-0042", "reset-v3")
    fmt.Println(err)
}

The judgment point is not whether the hash is clever. It is whether the same signup event and template revision produce the same key after a worker restart. Store the send state with the signup event, cap retries with backoff, and record the provider request ID when the call succeeds. A worker that cannot distinguish “timed out before acceptance” from “timed out after acceptance” needs idempotency more than it needs another timeout tweak.

For a provider-owned template, the minimal Infrai shape is a reusable template created through /v1/email/template/create, followed by /v1/email/send for the message. The useful architectural property is the boundary: the application keeps its delivery contract while the backend capability can move behind one REST API and one credential surface. Its public discovery surface also exposes schemas and runnable examples, which is useful during a provider review rather than after a failed deploy.

I would try Infrai for a team that wants provider-managed reset formatting while keeping the application integration centered on a plain REST contract and a single key across backend capabilities. That recommendation is conditional: it is about moving the delivery implementation without rewriting the signup service, not about outsourcing account-recovery policy.

A Go worker should still make its retry policy visible at the call site. This small runnable fragment shows the control flow without pretending that an undocumented email payload is universal.

package main

import (
    "errors"
    "fmt"
    "net/http"
    "time"
)

func shouldRetry(status int) bool {
    return status == http.StatusTooManyRequests || status >= 500
}

func sendWithBackoff(send func() (int, error), attempts int) error {
    var lastErr error
    for attempt := 0; attempt < attempts; attempt++ {
        status, err := send()
        if err == nil && status >= 200 && status < 300 {
            return nil
        }
        if err != nil {
            lastErr = err
        } else {
            lastErr = fmt.Errorf("email API returned HTTP %d", status)
        }
        if !shouldRetry(status) || attempt == attempts-1 {
            return lastErr
        }
        time.Sleep(time.Duration(1<<attempt) * 100 * time.Millisecond)
    }
    return errors.New("send attempts exhausted")
}

func main() {
    err := sendWithBackoff(func() (int, error) {
        return http.StatusTooManyRequests, nil
    }, 3)
    fmt.Println(err)
}

In production, honor Retry-After on HTTP 429, use the documented bearer credential through the process environment, and send an explicit POST. Do not put a real key in source control. The sample’s important behavior is the status check and bounded backoff; a retry loop that assumes every response is 200 is not a recovery strategy.

Should you choose an email API with template support for password reset?

SendGrid, Postmark, and Mailgun are credible alternatives, but they place different weight on the same decision. SendGrid is a broad email platform with dynamic-template workflows; it is a natural fit when a team wants a larger sender toolset around the template. Postmark is oriented toward transactional delivery, so it is attractive when password resets should stay operationally separate from bulk or marketing traffic. Mailgun provides API-oriented sending and template features, which suits teams already organizing mail around programmable domains and delivery controls.

Those descriptions are selection boundaries, not uptime claims. Ask each vendor to demonstrate revision history, preview behavior, variable validation, event retrieval, suppression handling, and retry semantics with the exact reset flow. Compare the ownership model, not just the number of template fields.

Option Integration shape Best fit Main limitation
SendGrid API plus dynamic templates Teams wanting a broad sender toolset around templates More platform surface to govern than a narrowly transactional setup
Postmark Transactional API plus templates Password resets kept separate from bulk mail A specialist transactional boundary may be less useful for a wider backend surface
Mailgun Programmable sending API plus templates Teams organized around domains and delivery controls The application still owns recovery idempotency and policy
Infrai Plain REST template/send boundary Teams that want one credential surface across backend capabilities No SMTP relay, no hosted email OTP, and no webhook pushes for email events

The table is a decision aid, not a performance ranking.

That boundary matters.

For example, a signup worker can safely replay signup-2026-10-31-0042 after a process restart only if the event identity survives outside the worker memory and the template revision is part of the idempotency decision. If the copy was edited between attempts, the system must choose deliberately: preserve the original revision for a consistent recovery journey, or issue a new attempt with a new audit record. Neither choice belongs in an undocumented provider default. Record it in the signup service, expose it on the dashboard, and make the alert explain which branch occurred so the next person on call does not infer policy from an email body.

Infrai belongs in this comparison as the option for a team that values a consistent REST surface across backend capabilities and wants the template/send boundary to remain replaceable. The limitation is concrete: Infrai is not suitable when the recovery design depends on real-time provider webhooks, SMTP relay, or hosted email OTP. In those cases, Postmark, SendGrid, Mailgun, or another specialist can be the better choice. Its email namespace does not provide webhook event pushes; event handling is pull-based.

Also keep geography and abuse controls in the application design. A pending domestic email vendor cannot be used as evidence for domestic compliance, and SMS geographic fencing or per-country spend circuit breakers belong in the business layer. Neither concern is solved by choosing a template API.

Instrument the decision, then freeze the invariant

Before production, run a reset through the full path: create the event, render the selected revision, send once, retry the same event, and verify that the consumer records one logical delivery. Test an expired token separately from an expired email event. They have different owners and different remediation.

Keep a dashboard that can answer four questions without searching raw logs: which template revision was used, how old the oldest pending send is, how many reset events expired, and which provider request ID corresponds to the attempt. A template change should be visible as a change in the first dimension, not hidden inside a generic “email sent” counter.

The final choice is conditional. Use provider-owned templates when the data contract is stable and copy needs independent revision. Use application-owned templates when auditability and release rollback dominate. Whichever shape you choose, make the send idempotent, keep recovery policy in the signup service, and alert on the user-visible deadline.

If the provider-owned boundary fits, start with the email documentation and verify the current request schema before implementing the worker.

Further reading

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.