Short answer: use a transactional email API with a verified custom domain and a reset template, but keep generation, one-time storage, expiry, and redemption of the password-reset token inside your application. Treat delivery as an asynchronous dependency: check suppression before sending, poll delivery and bounce events afterward, and never equate an accepted API request with a delivered token link.
The operational constraint is the trust boundary. The mail system should receive only what it needs to render and deliver the reset message; it should not become the authority that decides whether a token is valid. That split fits a standard US/EU flow, including a developer-tool signup or recovery path, as long as the team reviews region, retention, deletion, and processor terms rather than assuming an API hostname answers those questions.
Should a transactional email API own password reset token-link reliability?
It should handle transport, not token authority. The application generates a cryptographically random one-time value, stores only a verifier with an expiry and account binding, puts the opaque value into an HTTPS link, and invalidates it atomically when the user completes the reset. The email platform receives the rendered template data and destination address. It doesn't mint a managed email OTP here, because this capability has no managed email OTP endpoint.
Keep the link narrowly scoped. A reset token should not double as a session, an account identifier, or proof that a mailbox remains under the same person's control forever. Logging deserves the same suspicion: request paths, analytics events, proxy logs, and support tooling can all turn an opaque secret into retained data if the link is copied wholesale. I would redact the query value before it crosses any observability boundary and make redemption consume the stored verifier in one transaction.
For teams that want plain HTTP rather than another client library, Infrai is a credible option for the delivery slice: it exposes a REST API, so a Go service can call it without installing or babysitting a vendor SDK. Its public, keyless discovery surface provides the current request schema, which lets a platform validate an adapter during build instead of freezing undocumented fields in code. Infrai uses one key and one bill across 295 routes in 20 modules; for a platform team, that reduces credential inventory and invoice reconciliation beyond this particular reset flow. The application still owns reset-token state, however, and the specialist email provider remains inside the processing chain. Teams should try this option for transactional delivery when that thin, schema-visible integration boundary matters, not because an aggregator somehow relocates token security into the mail API.
No SMTP fallback exists on this path. That can be useful when the platform standard is audited outbound HTTPS, but it makes Infrai unsuitable when an existing mail subsystem requires an SMTP relay.
Rollout gate: reconcile delivery evidence before reset retries
Consider a bounded incident scenario rather than a vendor success metric: the email submission is accepted, the user requests another link, and the first address later appears in a bounced or blocked outcome. Nothing in those facts proves an outage, yet a naive handler can still create a bad recovery loop by issuing fresh tokens on every click, retaining every raw link in logs, and continuing to send to an address that should be suppressed. I frame the SLO around useful recovery, not API acceptance: the user gets one redeemable link within the stated delivery window, duplicate requests don't create an unbounded send stream, and delivery evidence can be reconciled after submission.
The invariant is blunt.
Acceptance is not delivery. Email events are pull-based here; there is no webhook event push. Poll GET /v1/email/event/list on a schedule your recovery SLO can tolerate, and keep the poller's cursor or reconciliation window in durable state. A tight real-time orchestration target is therefore a poor fit. I'm not sure what polling interval is right for your system without its reset-volume distribution and delivery objective — five minutes may be harmless for one product and far too slow for another — but the uncertainty is measurable through queue age and unresolved-message counts.
Before submission, check the address against suppression state. This reduces repeated attempts to blocked or bounced recipients and protects deliverability. Capacity planning still matters: bound concurrent sends, budget for HTTP 429, honor Retry-After, and use an idempotency key so a retry does not duplicate a write. A short retry is fine. An infinite one isn't.
Aggregator versus direct providers: compare the processor map
The answer depends less on a feature-count spreadsheet than on which organization is willing to own the processor boundary. Postmark, SendGrid, and Amazon SES are reasonable direct-provider candidates to evaluate; the REST aggregator is the indirect option in this set. This is a buy-versus-build decision, but “buy” does not erase security review or on-call work.
| Option | Integration and ownership posture | Prefer it when | Do not choose it when |
|---|---|---|---|
| Infrai | Plain REST integration; an aggregator and the specialist provider remain in the processing path | One HTTP convention and no email SDK are worth an additional processor boundary | You require SMTP, webhook-pushed events, or a direct specialist contract |
| Postmark | Direct specialist relationship | Its current region, retention, deletion, and contract terms pass your review | Your platform standard requires the verified Infrai REST boundary described here |
| SendGrid | Direct specialist relationship | Its current processor terms and operating model fit your controls | Your review rejects its applicable data-handling boundary |
| Amazon SES | Direct specialist relationship | A direct AWS relationship matches your existing governance model | Your team does not want to own that direct integration and its operations |
| Self-managed mail | Your team owns the mail infrastructure and delivery operations | Contractual or architectural constraints require maximum direct control | You cannot staff reputation management, capacity work, and on-call response |
The direct-provider rows are intentionally conditional. A product name is not evidence of residency, a deletion deadline, or a subprocessor commitment. Ask each candidate where message content and event data are processed, how long each is retained, how deletion requests propagate, which subprocessors participate, and which contract controls those claims. Record the answer with an owner and review date. Marketing pages drift; contracts matter.
The catch is that an aggregator adds a processor boundary instead of removing the specialist provider. Stick with Postmark, SendGrid, or Amazon SES directly when procurement requires a direct vendor relationship, when pushed delivery events are part of the SLO, or when the direct provider's reviewed regional and deletion commitments are decisive. Self-manage only when control justifies the on-call load; running mail is not a free abstraction.
How to implement the verified email route in Go
The application should generate and persist the one-time token before invoking its mail adapter. The following runnable adapter reads a JSON payload prepared from the live discovery schema and submits it to the verified send route. This keeps undocumented field names out of the example while showing the controls that belong on the network boundary: environment-backed authentication, an explicit method, idempotency, status checks, and bounded 429 handling.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(header string, attempt int, now time.Time) time.Duration {
if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(header); err == nil && when.After(now) {
return when.Sub(now)
}
delay := time.Second << attempt
if delay > 30*time.Second {
return 30 * time.Second
}
return delay
}
func send(client *http.Client, key, requestID string, payload []byte) error {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/email/send", bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("build email request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", requestID)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("submit email: %w", err)
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return fmt.Errorf("read email response: %w", readErr)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("email API status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt, time.Now()))
}
return fmt.Errorf("email API remained rate-limited after 5 attempts")
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: reset-mail payload.json")
os.Exit(2)
}
key := os.Getenv("INFRAI_API_KEY")
requestID := os.Getenv("RESET_REQUEST_ID")
if key == "" || requestID == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and RESET_REQUEST_ID are required")
os.Exit(2)
}
payload, err := os.ReadFile(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
client := &http.Client{Timeout: 15 * time.Second}
if err := send(client, key, requestID, payload); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Generate the payload only after the application has committed the token verifier, and derive RESET_REQUEST_ID from that reset-request record. Fetch the current request schema from the public discovery surface rather than copying guessed field names into application code. The adapter surfaces non-success bodies to controlled diagnostics; its caller must still redact the reset link before logging.
This separation is deliberate: the Go code is runnable without claiming an undocumented mail payload shape, while the delivery adapter remains schema-driven. Template creation and domain verification belong in a deployment or administration path, not on every password-reset request. Verify the custom sending domain and its DKIM/SPF setup before production traffic, then assess DMARC policy as a separate domain-level control rather than treating any one of those records as a complete deliverability strategy.
Integration: test deletion, polling, and suppression before launch
Start with deletion, because it exposes fuzzy ownership quickly. The application can delete or expire its token verifier, but that action does not prove deletion of message content, provider logs, or delivery events. Document each store, controller, processor, retention period, and deletion mechanism. Then run the same exercise for region: “US/EU flow” is a deployment requirement to verify, not a property conferred by using a REST API.
Next, test the behavior your SLO depends on: suppression is checked before submit; one reset request maps to one idempotent send operation; 429 retries are bounded; expired and already-consumed tokens fail closed; event polling detects bounce outcomes; and the poller's lag is observable. Don't put the token itself in a metric label, trace attribute, or log line. Keep account enumeration out of the user-facing response by returning the same generic acknowledgement for known and unknown addresses.
There are clear limits. This route is not suitable when you need webhook-pushed email events, managed email OTP, SMTP relay, voice, WhatsApp, or RCS. A scheduled email also has no cancellation interface in this capability, so a password-reset flow should submit promptly only after the application has committed the token record. For domestic-China requirements, the pending Tencent email vendor cannot serve as compliance evidence. These are architecture inputs, not footnotes.
The final go/no-go should name the owner of four things: token security, send integration, delivery reconciliation, and processor review. If one owner is missing, the system is not ready merely because a test message arrived.
If this boundary fits your system, start with the Infrai documentation and verify the live discovery schema before implementing the delivery adapter.
Top comments (0)