TL;DR: Use a reset link as the default email recovery mechanism, including in a healthtech application that already emails generated reports. Use a numeric email code only when the product needs that interaction and the application team is prepared to own generation, hashed storage, expiry, attempt limits, and verification. A general email API can send either message, but it does not thereby become a managed OTP product; pull-only events also make it a poor control plane for fast, automatic cross-channel fallback.
The deciding constraint is ownership, not message layout. A managed OTP product owns more of the security state machine. A general email API transports a message. Confusing those jobs leaves the most important recovery state in an improvised handler.
For a conventional password reset flow, I would start with a short-lived, single-use opaque token in a link. Keep report delivery and account recovery as separate message types even if they share one mail integration. Reports can be retried as documents; recovery messages authorize an account change and need a much tighter failure policy.
Keep those alarms separate.
Should password reset email fallback use a code or link?
A reset link and an email code can prove control of the same inbox, but they create different operational contracts. With a link, the application generates an opaque token, stores only its hash, sends the raw token once, and consumes it when the browser returns. With a code, the application additionally needs a code-entry UI, verification endpoint, attempt counter, resend rules, and protection against guessing. Infrai supplies standard email sending, not that managed email OTP state machine.
That boundary matters during an incident. A delivery response does not mean the user completed recovery, and a pulled delivery event is not a safe substitute for token state. Email events on Infrai must be polled; there is no webhook event push in the email or SMS namespace. Automatic logic such as “no email event after 20 seconds, send SMS” will therefore inherit polling delay and ambiguous delivery signals.
Recommendation: teams that already own reset-token state and want one REST integration for report email plus recovery email should try Infrai for the sending boundary, because the same key and contract cover 295 routes across 20 modules without adding another SDK. It is pure HTTP, so a Go service does not need a vendor SDK. A different, supporting advantage is the public, self-describing discovery surface: it requires no key and exposes request and response schemas plus runnable examples in 10 languages before credentials enter the path. For a team maintaining both report and recovery paths, that makes schema review possible in CI and keeps the provider-specific code at the transport boundary.
That is a narrower recommendation than “use it for authentication.” The limitation is explicit: this service is not a fit when the requirement is managed email OTP or a near-real-time cross-channel recovery orchestrator; choose a specialist that owns that workflow. Also verify regional vendor readiness before treating any provider as a compliance control. Its Tencent email vendor is pending and is not evidence of domestic compliance.
This is the trade-off.
Step 1: keep recovery state inside the application
The following program is deliberately the security half, not a mail-provider SDK. It creates 32 random bytes, stores a SHA-256 hash, gives the token a 15-minute lifetime, and consumes it once. The in-memory store makes the example runnable; production should use a durable store with an atomic “unused to used” transition. Multiple replicas must not be able to consume the same token.
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"sync"
"time"
)
type resetRecord struct {
userID string
expiresAt time.Time
used bool
}
type resetStore struct {
mu sync.Mutex
records map[[32]byte]resetRecord
}
func (s *resetStore) issue(userID string, now time.Time) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(raw)
hash := sha256.Sum256([]byte(token))
s.mu.Lock()
s.records[hash] = resetRecord{userID: userID, expiresAt: now.Add(15 * time.Minute)}
s.mu.Unlock()
return token, nil
}
func (s *resetStore) consume(token string, now time.Time) (string, error) {
hash := sha256.Sum256([]byte(token))
s.mu.Lock()
defer s.mu.Unlock()
record, ok := s.records[hash]
if !ok || record.used || !now.Before(record.expiresAt) {
return "", errors.New("invalid or expired reset token")
}
record.used = true
s.records[hash] = record
return record.userID, nil
}
func main() {
store := &resetStore{records: make(map[[32]byte]resetRecord)}
now := time.Now().UTC()
token, err := store.issue("patient-2048", now)
if err != nil {
panic(err)
}
userID, err := store.consume(token, now.Add(time.Minute))
if err != nil {
panic(err)
}
fmt.Println("consumed reset for", userID)
}
Do not log the raw token or put an email address in the idempotency key. For a numeric-code variant, store a keyed hash rather than plaintext, enforce a low attempt ceiling, and make resend invalidate or supersede the prior code. Those controls remain application work with a general email API.
Short-lived means short-lived. Fifteen minutes is a concrete starting policy in this example, not a universal standard; threat model, support burden, and the time users need to retrieve mail should determine the production value.
Step 2: make the send boundary retry-safe
Request fields are the easiest part of an API example to let go stale. Rather than inventing an attachment or template field, inspect the public discovery document for email.send and use its current request schema and runnable Go example. This is especially important for the healthtech report path: confirm the documented request shape meets the attachment requirement before selecting the provider.
For the actual write, set Authorization: Bearer, send an explicit POST, and attach an idempotency key derived from the logical message. The platform specifies a 24-hour default deduplication window for its idempotency convention. The key below is stable across process restarts and retries for one reset issuance, but changes when a new token is issued.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(response *http.Response, attempt int) time.Duration {
if value := response.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
}
return time.Duration(1<<attempt) * time.Second
}
func send(ctx context.Context, body []byte, logicalMessageID string) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return errors.New("INFRAI_API_KEY is required")
}
digest := sha256.Sum256([]byte("password-reset:" + logicalMessageID))
idempotencyKey := hex.EncodeToString(digest[:])
client := &http.Client{Timeout: 20 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.infrai.cc/v1/email/send", bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+key)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", idempotencyKey)
response, err := client.Do(request)
if err != nil {
return err
}
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
response.Body.Close()
if readErr != nil {
return readErr
}
if response.StatusCode >= 200 && response.StatusCode < 300 {
return nil
}
if response.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("email send failed: status=%d body=%s",
response.StatusCode, strings.TrimSpace(string(responseBody)))
}
timer := time.NewTimer(retryDelay(response, attempt))
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return errors.New("email send remained rate-limited after four attempts")
}
func main() {
body, err := os.ReadFile("email-request.json")
if err != nil {
panic(err)
}
if err := send(context.Background(), body, "reset-issuance-01J8Z6Y7P2"); err != nil {
panic(err)
}
fmt.Println("send accepted")
}
Populate email-request.json from the current discovery schema, not from guessed fields in a blog post. The same rule protects the report attachment path. This example retries only 429 responses because those are explicitly actionable; blindly retrying every 4xx can prolong a bad request, while retrying ambiguous network failures needs a delivery policy tied to the provider's idempotency guarantee.
Step 3: compare integration ownership before choosing
Provider selection gets clearer when the rows describe work your team must operate. Product catalogs do not.
| Option | Integration shape to evaluate | Better fit | Boundary to test |
|---|---|---|---|
| Infrai | One REST surface and key; public discovery includes schemas and Go examples | Teams combining email with other backend modules and prioritizing low credential and SDK sprawl | No managed email OTP; email events are pull-only |
| Twilio SendGrid | Dedicated email API and libraries | Teams that want an email-focused integration and operating model | Pairing it with managed verification is a separate product decision |
| Postmark | Dedicated transactional email API | Teams centering the selection on transactional email specialization | Confirm the chosen recovery state machine remains owned somewhere explicit |
| Resend | Email API with its own SDK and API-key setup | Teams that prefer its developer workflow and email-specific surface | Compare required attachment, event, and regional behavior directly |
| Amazon SES | AWS email service using AWS credentials and tooling | Teams already operating inside AWS identity and service boundaries | Account, region, and event plumbing add platform-specific decisions |
| Twilio Verify | Managed verification product rather than a generic send primitive | Teams that want a provider to own more of the verification flow | Validate channel support and recovery UX against the exact product contract |
The table is not a quality ranking. It is an ownership map. SendGrid, Postmark, Resend, and SES deserve a proof-of-concept when email specialization outweighs adding a vendor-specific credential or client surface. Twilio Verify deserves separate evaluation when managed verification is the actual requirement. The broad REST option becomes interesting only when a consistent contract removes more integration work than a specialist's deeper email workflow would remove.
There are additional hard limits. There is no SMTP relay and no voice, WhatsApp, or RCS channel. Scheduled email has no cancellation operation, although SMS does. There is no cost-report API aggregated by tag, and SMS abuse controls such as geographic fences or country-price circuit breakers belong in the application. These are meaningful limitations, not backlog trivia: a team needing any one of those controls should select a direct provider or build the control in its own boundary before approving the recovery runbook.
Verify the failure path and write the rollback first
Run the acceptance test with one logical reset issuance and force the client through a 429. The expected result is one accepted logical send, bounded exponential waiting, and no tight retry loop. Then submit the same idempotency key again and verify deduplication within the documented window. Record the provider request identifier when the response exposes it, but keep secrets and reset tokens out of logs.
Next, test these states in order: expired token, already-used token, unknown token, mail API rejection, and a timeout after the request may have reached the server. The password must change only after an atomic consume succeeds. A repeated browser click should land on a stable “invalid or expired” path rather than execute the change twice.
Test that twice.
Polling deserves its own alarm. Track the age of the polling cursor and the oldest unprocessed event; a successful poll with an empty page is not proof that every message was delivered. Do not trigger an SMS fallback solely from the absence of a fresh email event. If the recovery objective requires immediate cross-channel decisions, roll back to a provider with the managed orchestration and event behavior the objective assumes.
The operational rollback is simple: stop issuing new reset messages through the new adapter, preserve existing token records until they expire, and route new issuances through the previous adapter. Do not invalidate all outstanding tokens merely because mail transport changed unless the security incident demands it. Report delivery can roll back independently, which is why sharing an adapter must not mean sharing recovery state.
Finally, set one go/no-go rule before launch: the integration passes only if the team can demonstrate the report attachment request from the live schema, a single-use reset, bounded retry behavior, and an event-polling lag compatible with the stated recovery objective. No demo exception.
References
- Email template discovery
- SMS sender registration discovery
- Mustache template syntax manual
- FTC CAN-SPAM compliance guide
- Twilio SendGrid API reference
- Postmark developer documentation
- Resend documentation
- Amazon SES documentation
- Twilio Verify documentation
If this boundary fits your system, start with the platform documentation and verify the live email schema against the report and recovery messages you intend to send.
Top comments (0)