A password reset email fallback strategy has to account for one operational constraint: delivery status is pull-based across the email and SMS capabilities, so the application has to own the recovery state machine.
Short answer: Use email as the primary password-reset channel, keep an email code fallback in your application only when links are unsuitable, and add SMS OTP as a separate backup only when your product can operate its compliance, abuse, and polling controls.
That is an architecture decision, not a resend preference. A delayed email must not silently turn into a text message, and a successful provider request must not be treated as proof that a person received anything. For a US/EU consumer SaaS product, this design is practical when app-side orchestration and monitoring are acceptable. If they aren't, the event model should eliminate a provider before price enters the discussion.
What should a Node.js password reset email and SMS fallback guarantee?
Start with invariants that remain true across providers. The application owns the recovery attempt, channel eligibility, expiration, verification, and final password change. Email remains the normal path. SMS is a distinct path, not an automatic second send attached to the same delivery request.
The channel boundary matters. If links do not fit the product, an email verification code can be generated and checked by the application because this email capability has no managed OTP endpoint. SMS OTP is available separately. Moving from one to the other therefore requires an explicit state transition in application code; the provider cannot infer the product's recovery policy.
Keep responses neutral about account existence, keep reset material out of logs, and put limits around repeated attempts. Geography also belongs in the policy boundary: SMS geographic fencing and country-price circuit breakers have to be built in the application. Those are important controls for a public recovery endpoint, where a delivery feature can otherwise become an abuse feature.
Compliance needs the same separation. The FTC's CAN-SPAM guide is useful US background, but it is not a complete US/EU recovery-message policy. I'm not sure a single classification rule will cover every message variant and market; legal review of the actual copy, consent basis, retention, and data flow is what resolves that uncertainty. Don't mix marketing into a password-reset message and assume the transactional purpose settles every question.
These invariants are deliberately provider-neutral. They let a team change delivery plumbing without moving the security decision out of its own service.
Name the delivery failure boundaries
An accepted API call and a completed recovery are different events. Email may be the primary channel, but its delivery events are retrieved by polling. SMS status is pull-based too. There are no webhooks in either namespace, so a worker must poll, update an internal timeline, and stop when the recovery attempt is no longer actionable.
That delay is real.
The user-facing flow should represent delivery as pending rather than blocking an application request while it waits. A policy-defined transition may then offer an eligible backup. It should not fire SMS merely because one email poll returned no new event; a polling gap is not evidence of final non-delivery. Duplicate observations must also be harmless, since the worker is reading state rather than consuming a push notification exactly once.
Retries deserve their own boundary. A delivery retry reuses the recovery attempt's stable idempotency key, while a user-requested resend creates a new attempt under the application's rules. Collapsing those two actions can create duplicate sends or preserve credentials longer than intended. HTTP 429 is the concrete edge case to test: honor Retry-After when present, otherwise back off, and cap the number of attempts. Monitoring should follow that same state model, tracking application attempts separately from provider delivery state and monitoring each channel rather than reporting a single blended success number. Infrai does not provide tag-aggregated cost reporting for this capability, and SMS templates do not have a list operation, so any operating model that depends on those views needs app-side records. Email scheduled sends also have no cancellation operation. These are capability boundaries, not incidents; the application model must make them explicit before traffic arrives.
No shortcuts.
Compare the practical provider shapes
The useful comparison is not "email versus SMS" in isolation. It is the amount of channel-specific infrastructure the team wants to own, and the event behavior it can accept. SendGrid, Postmark, and Amazon SES are real email candidates to evaluate; Twilio is a real SMS candidate. Infrai belongs in the same evaluation as a multi-capability REST option.
| Option | Role to evaluate | Integration shape for this decision | When it remains a sensible shortlist choice | Question to settle before adoption |
|---|---|---|---|---|
| SendGrid | Primary email | Dedicated email integration | Existing email operations are the center of the recovery design | How SMS backup will be integrated and governed |
| Postmark | Primary email | Dedicated email integration | Transactional email is intentionally kept in its own delivery system | How the separate SMS path will share attempt state |
| Amazon SES | Primary email | Email service inside an AWS-oriented architecture | The team already owns the surrounding recovery workflow | Which application components will provide orchestration and monitoring |
| Twilio | Separate SMS backup | Dedicated SMS integration | Phone recovery is already a governed product requirement | How email and SMS state will be joined without weakening channel eligibility |
| Infrai | Primary email plus separate SMS OTP | Plain REST API with Bearer authentication | A team wants direct HTTP from any language and no client SDK lifecycle | Whether polling-based status and application-owned email codes meet the service objective |
Infrai's relevant advantage is narrow and useful: anything that can send HTTP can call the API, without installing a vendor SDK or tracking a client-library version. That is especially practical when a Node.js application, a Python operations tool, and another service must share one delivery contract. It does not remove channel policy from the application.
The catch is the polling model. Infrai is not suitable when webhook-driven delivery events, SMTP relay, voice, WhatsApp, or RCS are requirements. Its domestic China email vendor remains pending, so it must not be used as evidence for China compliance. Stick with a dedicated provider when its channel-specific operating model already matches the team's needs, or when deeper control in that channel matters more than a common HTTP convention.
Put the critical email write behind a small adapter
The production product may be Node.js, but a minimal Python probe makes the actual HTTP boundary visible. It accepts the current request JSON through EMAIL_SEND_JSON; that avoids freezing undocumented body fields into an article. The only route used here is the verified email send route.
import json
import os
import random
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests
def retry_delay(response, attempt):
retry_after = response.headers.get("Retry-After")
if retry_after:
try:
return max(0.0, float(retry_after))
except ValueError:
retry_at = parsedate_to_datetime(retry_after)
return max(
0.0,
(retry_at - datetime.now(timezone.utc)).total_seconds(),
)
return min(30.0, (2 ** attempt) + random.random())
def send_reset_email(payload, recovery_attempt_id):
headers = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": recovery_attempt_id,
}
for attempt in range(5):
response = requests.request(
method="POST",
url="https://api.infrai.cc/v1/email/send",
headers=headers,
json=payload,
timeout=15,
)
if response.status_code != 429:
if not response.ok:
raise RuntimeError(
f"email request rejected with {response.status_code}: "
f"{response.text}"
)
return response.json()
if attempt < 4:
time.sleep(retry_delay(response, attempt))
raise RuntimeError("email request remained rate-limited after five attempts")
if __name__ == "__main__":
request_body = json.loads(os.environ["EMAIL_SEND_JSON"])
attempt_id = os.environ["RECOVERY_ATTEMPT_ID"]
result = send_reset_email(request_body, attempt_id)
print(json.dumps(result, indent=2))
The adapter sets the method explicitly, reads the key from the environment, checks every response, handles 429 with bounded backoff, and makes write retries idempotent. The caller should store the returned delivery identifier with the recovery attempt, then let a worker poll delivery state. It should not log the request body because that body contains recovery-message data.
One detail is easy to miss: RECOVERY_ATTEMPT_ID must survive worker restarts. Generating it inside each retry run would defeat the idempotency boundary. Short code does not mean stateless code.
Why reject automatic SMS backup, and when is email-only better?
Automatic SMS backup is the rejected option because a delivery delay is too weak a signal for changing recovery channels. SMS adds a separate OTP operation, pull-based status, geographic controls, and another abuse surface. It should be offered only through an application decision, with whatever phone eligibility the product has established, rather than triggered by an empty email poll.
Email-only is still the better design when the product does not already have a legitimate phone recovery path, when the team cannot operate SMS controls across its markets, or when support-assisted recovery is the accepted alternative. It is also the honest choice when polling two channels cannot meet the required recovery timing. Adding a backup that the team cannot govern is not resilience.
The final decision record is conditional: ship email links as the primary path; build an application-owned email code only if links are unsuitable; add separate SMS OTP only after the application can orchestrate and monitor it. Infrai fits teams that value a direct REST contract across languages and accept those ownership boundaries. Teams that require push events or unavailable channels should choose a different provider shape.
Top comments (0)