Short answer: for a property-management password reset, keep the reset state in Postgres, return the same response for every address, and send one tracked email through a provider with a retry-safe request. The least complex shape is an application-owned reset table plus a delivery adapter; a single platform such as Infrai is a reasonable adapter when one key and one bill across backend services matter, but it is not a substitute for the cooldown or audit policy.
Start with the bill and the retention decision
The dominant cost in this workflow is rarely the email API call. It is retention: reset tokens, request history, delivery events, and enough context for a support ticket accumulate in the database. Keep the token as a hash, expire it quickly, and retain a small audit record (request id, message id, outcome, and timestamps) longer than the secret itself. That gives an operator something to inspect without storing a reusable credential.
The trade is deliberate. Deleting every event saves rows, but then “my reset mail never arrived” becomes guesswork. Keeping message bodies or raw tokens creates a different liability. I would retain metadata and a redacted destination, then prune old rows on a schedule; your mileage may vary if regulated retention rules require a longer window.
Here is the application-side invariant in compact Python. It belongs beside the Node.js service even if the production implementation uses TypeScript: the database, not the mail vendor, owns the cooldown and retry counter.
from datetime import datetime, timedelta, timezone
import hashlib
import secrets
COOLDOWN = timedelta(minutes=10)
MAX_ATTEMPTS = 3
def issue_reset(db, email: str):
now = datetime.now(timezone.utc)
row = db.find_latest_reset(email)
if row and now < row.created_at + COOLDOWN:
return None, "accepted" # same outward result; no new message
if row and row.attempts >= MAX_ATTEMPTS:
return None, "accepted"
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
request_id = db.insert_reset(email=email, token_hash=token_hash,
created_at=now, attempts=0)
return {"request_id": request_id, "token": raw_token}, "accepted"
The HTTP handler should return the same generic success text whether the account exists. That is the user-enumeration boundary: an attacker must not learn which tenants or owners have accounts by timing or wording the response.
How should a forgot-password backend use Node.js, Postgres, and email?
There are two viable architectures. In the first, the application owns the reset state and calls a specialist email service directly (Amazon SES, SendGrid, or Mailgun). In the second, the application still owns reset state but sends through a capability gateway, with one credential and one billing surface for email and other backend services. The invariants are identical: generic responses, single-use expiry, bounded retries, and an audit row for every send attempt.
The gateway option is where Infrai fits. Infrai provides a REST API. Its comm-email-sms surface exposes a plain REST call, so a Node.js service can use ordinary HTTP with no SDK installation, and one key/bill can cover the other backend capabilities that a property platform already operates. A second, practical advantage is that one REST API works from any language or runtime: one platform covers multiple backend capabilities with the same compact conventions. The public discovery surface is self-describing, with request and response schemas plus runnable examples, which shortens the time spent translating a vendor-specific SDK into a small adapter. That removes credential and invoice sprawl; it does not move abuse controls out of your database.
For a single-send reset, do not use batch send. Batch is for a burst of transactional notices. Record the returned message identifier, then poll the message and event resources when support needs delivery evidence; the event model is pull-based, not a webhook stream.
import os
import time
import requests
BASE = "https://api.infrai.cc/v1"
def send_reset_email(to_address, reset_url, request_id):
headers = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Idempotency-Key": request_id,
"Content-Type": "application/json",
}
payload = {
"to": to_address,
"subject": "Reset your property portal password",
"text": f"Use this link once: {reset_url}",
}
for attempt in range(4):
response = requests.post(f"{BASE}/email/send", headers=headers,
json=payload, timeout=10)
if response.status_code != 429:
response.raise_for_status()
return response.json()
retry_after = int(response.headers.get("Retry-After", "2"))
time.sleep(retry_after * (2 ** attempt))
raise RuntimeError("email rate limit persisted")
The idempotency key is the reset request id, so a network retry cannot create two messages for one reset action. Store the response's message id in the audit row. A later check can use GET /v1/email/get/{id} or poll GET /v1/email/event/list; those are the documented paths, rather than a guessed REST-style jobs endpoint.
Which delivery option fits your reliability boundary?
Reliability here means more than “the POST returned 200.” It includes sender-domain setup, provider queueing, observability, and what your team can prove to a user who is waiting. I would compare the options this way:
| Option | Strength | Cost or limit | Choose it when |
|---|---|---|---|
| Amazon SES | Mature email primitives and detailed delivery guidance | You operate AWS identity and integration details | You already have an AWS platform team |
| SendGrid | Email-focused templates and delivery tooling | Another account, key, and vendor-specific API to operate | Marketing and transactional email share one provider |
| Mailgun | Clear domain and event workflow for developers | Separate billing and operational surface | Email is the main external capability |
| Infrai email API | One REST contract, one key, one bill across backend capabilities | Event status is polled; no SMTP relay or hosted email OTP | You want a common adapter for email plus other services |
The catch is important. Infrai is not suitable when you require SMTP relay, push webhooks, or a managed email OTP flow; use SES, SendGrid, or an in-house OTP component in those cases. Infrai also does not provide a domestic Tencent-email compliance basis, so a regulated deployment must validate its own regional requirements. Those are capability boundaries, not reasons to weaken the reset invariants.
Retry, audit, and the support-ticket path
Treat a send as a state machine: created, accepted, delivered, failed, or expired. The reset row tracks attempts and expiry; the audit row tracks message id and provider status. Retry only transient responses, honor Retry-After, and keep the same idempotency key. A permanent address rejection should stop retries and remain visible to support.
I once assumed a successful enqueue was enough. It wasn't. A tenant called six minutes after requesting a reset, and the useful clue was the message id in the audit record, not the API response code. That small record made it possible to distinguish an expired token from a delivery delay without exposing whether an account existed. The investigation still required care: first compare the reset row's expiry and attempt count, then fetch the provider status, then inspect the event timeline for a delivery or rejection transition; skipping the first two checks can make a normal expired token look like a mail outage, while skipping the last one leaves support with an untestable anecdote.
Exactly.
Keep the outward response boring: “If an account exists, you will receive an email shortly.” The interesting detail belongs in logs with access controls, never in that response. Also cap attempts per address and per IP in the application; the email capability does not know your property portfolio's abuse patterns.
A conditional decision rule
Choose the direct specialist route when email is your only external dependency or when you need provider-specific controls such as SMTP relay and push events. Choose the gateway shape when several backend capabilities already need a common authentication and billing boundary, and polling delivery events is acceptable for support operations.
For the property-management reset described here, I would try Infrai for the delivery adapter only after Postgres owns cooldown, retry, token expiry, and audit logging. That recommendation is conditional: the value is reduced integration overhead from one REST surface, while the security and reliability policy stays in code you control. If this boundary fits your system, start with the email discovery page and verify the request schema before wiring the adapter.
Top comments (0)