Short answer: for a forgot-password backend in Node.js with Postgres, keep the reset template in the application repository, put a short-lived token record in Postgres, and make every email request look identical to the caller. The least complex design that survives an on-call page is a transaction that records intent, a worker that sends once, and an audit trail that explains each retry.
Start with one invariant: a learner can use one unexpired link once.
At 02:13, the page should not say "SMTP failed" and leave you guessing which student is locked out. It should show a request id, a bounded queue age, and a delivery attempt count. In an edtech system sending a password-reset message with a 10-minute expiry, that distinction matters: a duplicate email creates confusion, while an account-enumeration leak creates a security incident.
How should a forgot-password backend in Node.js and Postgres handle email?
The API returns the same status and body for an existing and unknown address. It also spends roughly the same work on both paths. I record a salted digest of the address, never the raw value, plus the request id, template revision, cooldown decision, and outcome. That gives incident response useful evidence without turning logs into a directory of learners.
The cooldown belongs to the identity digest, not the IP alone. A five-minute cooldown is a practical starting point; tune it from abuse telemetry and support volume. The token row stores a hash, expiry, and consumed timestamp. A unique active-token rule or an explicit revoke step prevents two valid links from racing.
How does template ownership change the failure mode?
Application-owned templates are reviewed with code, tested against the reset contract, and released with a revision id. The messaging transport owns delivery mechanics; it does not silently rewrite copy. This makes a rollback boring: deploy the prior revision, then inspect which revision was sent. A provider-managed editor can be useful for a communications team, but it introduces a second release process and a harder audit question: who approved the text that reached a minor's inbox?
I once assumed a retry was harmless. It was not. A worker retried after a timeout, the first send succeeded, and the second message arrived with a newer token. The fix was idempotency at the job boundary, not a larger timeout.
// ClaimSend atomically leases one delivery attempt.
type Delivery struct {
ID string
Attempts int
}
func shouldRetry(err error, attempts int) bool {
if err == nil || attempts >= 4 {
return false
}
return isTemporary(err)
}
A worker claims a row with FOR UPDATE SKIP LOCKED, increments attempts, and writes the provider response class to the audit table. Retry only transient failures, with exponential backoff and jitter. Permanent address failures become a final event; they do not trigger an endless queue loop.
This design has limits. A five-minute cooldown can frustrate a shared family inbox, and digest-based throttling cannot identify a hostile NAT perfectly. In those cases, add a support-assisted path or a separate risk signal rather than weakening the generic response.
Trade-off: stronger throttling means more legitimate recovery friction.
Which signals catch a bad cooldown or retry policy?
Track request rate by digest and network, queue age, send latency, retry ratio, token-consumption ratio, and the share of generic responses. Alert on changes in distributions, not a single noisy count. A cooldown that is too strict produces support tickets and repeated requests; one that is too loose increases abuse traffic. False positives have a cost: paging on every provider timeout teaches the team to ignore the page.
Run contract tests for response parity, token expiry, one-time consumption, and template revision. In staging, inject a timeout after the transport accepts a message; the expected result is one logical send and two audit events (attempted, then confirmed or reconciled). Keep the reconciliation job separate from the request path so a provider outage cannot block password recovery requests.
The decision rule is simple: own the template where review and rollback are strongest, keep secrets and token hashes out of logs, and make the queue observable enough that an operator can explain one request without reading message content.
Top comments (0)