Expiry Windows: Node.js Email Status, SMS Escalation, and Event Traces
Expiry Windows: Node.js Email Status, SMS Escalation, and Event Traces
过期窗口:Node.js 邮件状态、短信升级与事件追踪
TL;DR: For Node.js event notifications, send transactional email first, poll its delivery status, and use SMS fallback only while the password-reset token remains useful. The deciding constraint is the expiry clock: a late fallback can create noise without helping a locked-out customer. 简而言之:对于 Node.js 事件通知,应优先发送事务性邮件,轮询其投递状态,并仅在密码重置令牌仍然有效时才使用短信作为备选方案。决定性约束在于过期时钟:延迟的备选方案不仅无法帮助被锁定的用户,反而会制造干扰。
For a customer-support system, this is a reliability problem with a security boundary. The useful outcome is not “an API accepted a request.” It is a reset message that is observable, bounded by its expiry, and safe to explain when a support ticket arrives. The before/after mental model is small. Before: the application sends email, waits a fixed number of seconds, then sends text. After: it records one notification attempt, advances it through explicit states, and decides from the expiry plus the latest delivery evidence. Different clocks. Different consequences. No guesswork. 对于客户支持系统而言,这是一个兼具可靠性与安全边界的问题。有意义的结果不是“API 接受了请求”,而是一条可观测、受过期时间限制,且在处理支持工单时易于解释的重置消息。前后的思维模型差异很小。之前:应用程序发送邮件,等待固定的秒数,然后发送短信。之后:它记录一次通知尝试,通过明确的状态推进流程,并根据过期时间和最新的投递证据做出决策。不同的时钟,不同的后果,无需猜测。
Start with a state machine, not a timer. Use a durable record keyed by a random notification ID and the reset request ID. Keep the reset secret out of the event payload and logs. A support agent needs to see queued, accepted, delivered, failed, or expired; they do not need a reusable link. Four states carry most of the operational weight: email_pending, email_delivered, text_pending, and complete. A terminal expired state matters just as much. It prevents a delayed worker from turning an old password-reset request into a fresh-looking text alert.
从状态机而非定时器开始。使用以随机通知 ID 和重置请求 ID 为键的持久化记录。不要将重置密钥放入事件负载或日志中。支持人员需要看到的是“已排队”、“已接受”、“已投递”、“失败”或“已过期”;他们不需要可重用的链接。四个状态承担了大部分运营权重:email_pending(邮件待处理)、email_delivered(邮件已投递)、text_pending(短信待处理)和 complete(完成)。终端的“已过期”状态同样重要,它能防止延迟的后台任务将过期的密码重置请求转化为看似新鲜的短信提醒。
Here is the policy in code. The transport functions deliberately expose generic results, because delivery semantics differ across channels and implementations. 以下是代码形式的策略。传输函数特意暴露了通用的结果,因为不同渠道和实现的投递语义各不相同。
type Channel = "email" | "text";
type DeliveryState = "pending" | "accepted" | "delivered" | "failed";
type ResetNotice = {
id: string;
resetRequestId: string;
expiresAt: Date;
email: { state: DeliveryState; receiptId?: string };
text: { state?: DeliveryState; receiptId?: string };
};
function chooseNextChannel(notice: ResetNotice, now: Date): Channel | null {
if (now >= notice.expiresAt) return null;
if (notice.email.state === "delivered") return null;
if (notice.text.state === "delivered") return null;
if (notice.email.state === "failed" && !notice.text.state) return "text";
return null;
}
The trade-off is deliberate. An accepted email is not automatically a text trigger, because acceptance is evidence that the receiving mail system took responsibility, not evidence of inbox placement or human attention. A failed email is a stronger signal. For pending messages, a deadline policy is needed: poll while there is time to act, then stop. 这种权衡是刻意的。邮件被“接受”并不自动触发短信,因为“接受”仅证明接收邮件系统承担了责任,而非证明邮件已进入收件箱或被用户查看。邮件“失败”是一个更强的信号。对于待处理消息,需要一种截止日期策略:在有操作空间时进行轮询,然后停止。
How should Node.js event notifications handle transactional email and SMS fallback? Text should take over after an email failure, or after a defined observation window ends before the reset expiry. Pick the window from the token lifetime and the system’s polling cadence, not from a decorative round number. For example, with a 10-minute reset expiry and 60-second polling, reserving the final 2 minutes means the escalation decision must happen no later than minute 8. This leaves room for a text attempt and avoids sending a link that is likely to be dead by the time it is read. Node.js 事件通知应如何处理事务性邮件和短信备选方案?短信应在邮件失败后,或在重置过期前定义的观察窗口结束后接管。应根据令牌生命周期和系统的轮询频率来选择窗口,而不是随意选择一个整数。例如,若重置有效期为 10 分钟,轮询间隔为 60 秒,预留最后 2 分钟意味着升级决策必须在第 8 分钟前做出。这为短信尝试留出了空间,并避免发送在用户阅读时可能已经失效的链接。
Those values are a policy example, not a universal security setting; the product’s threat model and user behavior should set them. The same rule also prevents an unpleasant support pattern: a customer opens a text message, follows a reset link, and is told it expired because the worker had been retrying a transport state that was no longer actionable. The notification record should make that answer obvious. It should show when the email was accepted or failed, when the fallback was claimed, and how much validity remained at each transition. Support can then distinguish an expired request from a delivery failure without exposing the reset secret. 这些数值仅是策略示例,而非通用的安全设置;应根据产品的威胁模型和用户行为来设定。同样的规则也防止了一种糟糕的支持场景:客户打开短信,点击重置链接,却被告知链接已过期,原因仅仅是后台任务一直在重试一个已无法操作的传输状态。通知记录应使答案一目了然。它应显示邮件何时被接受或失败、何时启用了备选方案,以及在每次状态转换时还剩多少有效期。这样,支持人员无需暴露重置密钥,即可区分是请求过期还是投递失败。
| Evidence | Application decision |
|---|---|
| Email delivered | Mark the notice complete; do not escalate. |
| Email failed before the deadline | Claim one SMS fallback attempt. |
| Email still pending at the escalation deadline | Apply the documented fallback policy once. |
| Any state after expiry | Stop delivery work and mark the notice expired. |
| 证据 | 应用程序决策 |
|---|---|
| 邮件已投递 | 将通知标记为完成;不进行升级。 |
| 截止日期前邮件失败 | 触发一次短信备选尝试。 |
| 升级截止时邮件仍待处理 | 执行一次记录在案的备选策略。 |
| 过期后的任何状态 | 停止投递工作并将通知标记为过期。 |
Do not turn “delivered” into account-recovery proof. SMS over the public switched telephone network has known weaknesses, and NIST classifies PSTN use as restricted for out-of-band authentication in the cited guidance. Here, text is a notification path. The reset flow still needs its own rate limits, single-use token handling, and account-recovery controls. 不要将“已投递”视为账户恢复的证明。通过公共交换电话网络(PSTN)发送的短信存在已知弱点,NIST 在相关指南中将 PSTN 的使用归类为带外认证的受限方式。在此,短信仅作为一种通知路径。重置流程仍需具备自身的速率限制、单次使用令牌处理和账户恢复控制。
Make polling boring and inspectable. Poll only records that are pending and still eligible for escalation. Store the provider receipt ID beside the channel state, normalize each response into the small state set above, and persist the transition atomically. Webhooks can reduce latency where available, but they should feed the same state transition function; a webhook does not remove the need for an expiry check or idempotency. 让轮询变得平淡且可检查。仅轮询那些处于待处理状态且仍有资格升级的记录。将服务商的收据 ID 存储在渠道状态旁边,将每个响应归一化为上述的小型状态集,并以原子方式持久化状态转换。在可用时,Webhook 可以降低延迟,但它们应接入相同的状态转换函数;Webhook 并不能免除过期检查或幂等性的需求。
async function reconcileNotice(notice: ResetNotice, now: Date) {
if (now >= notice.expiresAt) return markExpired(notice.id);
if (notice.email.state === "pending" && notice.email.receiptId) {
const state = await lookupEmailState(notice.email.receiptId);
await transitionEmail(notice.id, state);
}
const fresh = await loadNotice(notice.id);
if (chooseNextChannel(fresh, now) === "text") {
await enqueueTextOnce(fresh.id);
}
}
The subtle pitfall is a duplicate worker. Two workers can observe the same failed email and both try to enqueue text. The database transition or queue must claim the escalation with a uniqueness constraint on (resetRequestId, channel). An in-memory boolean works until a retry, deployment, or concurrent poller makes it irrelevant.
一个微妙的陷阱是重复的后台任务。两个任务可能会同时观察到同一封失败的邮件,并都尝试排队发送短信。数据库转换或队列必须通过 (resetRequestId, channel) 的唯一性约束来锁定升级操作。内存中的布尔值在重试、部署或并发轮询发生时就会失效。
Emit structured events for every transition: notification ID, reset request ID, channel, prior state, next state, receipt ID, and the time remaining before expiry. Omit addresses, phone numbers, and reset URLs. Then create three operational views: pending notices by age, channel failures by normalized reason, and expired notices that never reached delivered. 为每次状态转换发出结构化事件:通知 ID、重置请求 ID、渠道、前一状态、后一状态、收据 ID 以及距离过期剩余的时间。省略地址、电话号码和重置 URL。然后创建三个运营视图:按时长排序的待处理通知、按归一化原因分类的渠道失败,以及从未成功投递的过期通知。