A marketplace password-reset flow needs one durable job, an email-first policy, and a narrow SMS escalation path. The least complex option is to store notification state beside its expiry, submit email once, poll the resulting message status, and send SMS only while the reset is still useful. No webhook receiver is required.
TL;DR: model the workflow as persisted transitions, not a sleep inside a request handler. A worker owns EMAIL_PENDING -> EMAIL_ACCEPTED or SMS_PENDING -> COMPLETE; an expiry check can end either path. Treat provider acceptance as a transport observation, not proof that a person read the message.
The integration boundary stays small: the application writes a reset notification, a worker calls generic email and SMS adapters, and that worker polls email status on a bounded schedule. The database coordinates the work.
How should multi-channel event notifications poll email status before fallback?
A transport status should trigger a policy decision, never an assumption about the user. Use three inputs: the reset's absolute expiry, the latest normalized email status, and the number of checks already attempted. If email reaches the application's accepted state, stop. If it reaches a failure state while useful time remains, claim the SMS transition. If it remains unresolved when the polling budget ends, escalate or expire according to a policy chosen before deployment.
Polling cannot create certainty that an upstream system does not expose. An accepted email can still be unread. EMAIL_ACCEPTED should therefore mean only that the adapter returned a status the integration classifies as accepted. It must not mean inbox placement, account recovery, or human action.
Keep that boundary explicit.
For a concrete policy, assume the reset expires 10 minutes after creation. Poll at 15, 45, and 105 seconds after email submission. These are example settings, not delivery guarantees. They leave most of the token lifetime for SMS while bounding status traffic at three reads per reset.
The critical race is mundane: two workers observe the same failure and both send a text. Prevent it with a conditional database update that lets only one worker move EMAIL_PENDING to SMS_PENDING. Pass a stable attempt key through each transport adapter where its interface supports idempotent submission.
| Observed state | Time remains? | Next action |
|---|---|---|
| Accepted | Either | Stop polling |
| Failed or rejected | Yes | Claim SMS work |
| Still pending after the poll budget | Yes | Apply the preselected escalation policy |
| Any nonterminal state | No | Expire the notice |
This polling workflow has a real limitation: it is unsuitable when the email transport has no stable status lookup, when fallback must happen faster than the shortest sensible polling interval, or when status-read volume is operationally unacceptable. In those cases, use a transport event receiver or a single-channel reset flow instead. The trade-off is integration effort: polling avoids a public callback surface, but spends scheduled work and status requests to do it. It also cannot report a state richer than the upstream status model. No adapter can repair that information gap.
Short boundaries help.
A runnable Python state machine
This example uses the standard library and an in-memory adapter. In production, repository methods map to conditional updates in a transactional store, and a scheduler calls advance at the persisted next_check_at time.
from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from enum import Enum
class Phase(str, Enum):
NEW = "new"
EMAIL_PENDING = "email_pending"
EMAIL_ACCEPTED = "email_accepted"
SMS_PENDING = "sms_pending"
COMPLETE = "complete"
EXPIRED = "expired"
@dataclass(frozen=True)
class Notice:
notice_id: str
email: str
phone: str
reset_url: str
expires_at: datetime
phase: Phase = Phase.NEW
message_id: str | None = None
polls: int = 0
next_check_at: datetime | None = None
DELAYS = (15, 45, 105)
def advance(notice, channels, now):
if notice.phase in {Phase.EMAIL_ACCEPTED, Phase.COMPLETE, Phase.EXPIRED}:
return notice
if now >= notice.expires_at:
return replace(notice, phase=Phase.EXPIRED, next_check_at=None)
if notice.phase == Phase.NEW:
message_id = channels.send_email(
to=notice.email,
subject="Reset your marketplace password",
body=f"Use this link before it expires: {notice.reset_url}",
key=f"{notice.notice_id}:email:1",
)
return replace(notice, phase=Phase.EMAIL_PENDING,
message_id=message_id,
next_check_at=now + timedelta(seconds=DELAYS[0]))
if notice.phase == Phase.EMAIL_PENDING:
if notice.next_check_at and now < notice.next_check_at:
return notice
status = channels.get_email_status(notice.message_id)
if status == "accepted":
return replace(notice, phase=Phase.EMAIL_ACCEPTED,
next_check_at=None)
if status in {"failed", "rejected"} or notice.polls + 1 >= len(DELAYS):
return replace(notice, phase=Phase.SMS_PENDING,
next_check_at=None)
count = notice.polls + 1
return replace(notice, polls=count,
next_check_at=now + timedelta(seconds=DELAYS[count]))
if notice.phase == Phase.SMS_PENDING:
channels.send_sms(to=notice.phone,
body=f"Reset your password: {notice.reset_url}",
key=f"{notice.notice_id}:sms:1")
return replace(notice, phase=Phase.COMPLETE)
raise ValueError(f"Unexpected phase: {notice.phase}")
class DemoChannels:
def __init__(self):
self.polls = 0
def send_email(self, **message):
print("email submitted", message["key"])
return "email-001"
def get_email_status(self, message_id):
self.polls += 1
return "failed" if self.polls == 2 else "pending"
def send_sms(self, **message):
print("sms submitted", message["key"])
return "sms-001"
now = datetime.now(timezone.utc)
notice = Notice("reset-7f31", "buyer@example.test", "+15555550123",
"https://accounts.example.test/reset/opaque-token",
now + timedelta(minutes=10))
channels = DemoChannels()
for tick in (0, 15, 60, 61):
notice = advance(notice, channels, now + timedelta(seconds=tick))
print(tick, notice.phase.value)
The demo submits email at second 0, sees a pending state at second 15, sees failure at second 60, and submits SMS on the next pass. That extra pass is intentional. Persisting SMS_PENDING before the external call creates a place to claim work atomically.
The immutable object helps evaluation, but a real repository must compare the current phase in its update. An unconditional save can overwrite another worker's progress. Update only when the phase is still EMAIL_PENDING, inspect the affected-row count, and let the winner schedule SMS.
Keep templates and tokens out of orchestration
Pass a small rendering model to a template layer instead of assembling channel copy throughout the state machine. Mustache is a logic-less template language: variables are escaped by default, while triple braces or ampersand tags render unescaped content. That distinction matters for marketplace display names and other user-controlled fields. Escaping by default still does not make arbitrary values safe in every markup context.
A compact model can contain reset_url, expires_in_minutes, and a support label. Do not log the rendered reset URL. Put the absolute expiry in the job so delayed workers cannot revive stale messages.
Tokens stay secret.
Email and SMS need different copy. Email can explain why the recipient received the message and provide support context. SMS appears on devices that may show lock-screen previews, so keep it focused on the requested action and expiry. Share the data model, not one rendered body.
Integration effort belongs in the eval harness
A notebook can validate transitions with statuses, times, and expected phases. Production readiness starts when those cases run against the repository boundary with a fake clock. I would make five cases release-blocking: two workers race for one failed email; expiry occurs before the SMS claim; email status polling times out; SMS submission returns an ambiguous error; and a template receives characters with HTML meaning.
This is also where prompt-cost awareness helps, even though no model belongs in this delivery path. Account-recovery copy should be deterministic and reviewed. A generative call adds latency, variable output, another failure boundary, and token spend without improving the state decision. Keep any copy experiment outside the reset credential and measure it separately.
Integration effort is broader than SDK call count. Measure the statuses adapters must normalize, credentials and network paths operators must maintain, fixtures needed for deterministic tests, and work required to investigate one notification by notice_id. A three-method interface can hide substantial operational variation. Keep it small anyway; its purpose is to contain that variation.
Operate the policy, not just the worker
Record one structured event per transition with the notice ID, old and new phases, attempt key, normalized status, poll count, and timestamp. Exclude the token, rendered body, email address, and phone number. Monitor pending-work age, expiry transitions, polling volume, SMS escalation rate, and duplicate-claim conflicts. These are workflow signals, not claims about inbox delivery.
Three polls per unresolved email makes the maximum status-read load three times the attempts that remain unresolved through the full window. SMS volume depends on the unresolved-state policy. Evaluate both “escalate after the budget” and “expire without escalation” against controlled tests and support outcomes before changing it.
Sender operations still matter. Yahoo's published guidance describes authentication and other sender requirements, including additional requirements for bulk senders. Read the current guidance directly and make sender configuration an owned production concern; orchestration cannot compensate for poor sending practices.
Before deployment, confirm that the database transition is conditional, each external attempt has a stable key, every run checks absolute expiry, logs omit recovery secrets and contact data, templates escape untrusted values in the intended context, and dashboards distinguish transport acceptance from user completion. Then race two workers under a fake clock. The resulting design is bounded, inspectable, and replaceable.
Further reading
- Mustache template syntax manual: https://mustache.github.io/mustache.5.html
- Yahoo sender best practices and requirements: https://senders.yahooinc.com/best-practices/
Top comments (0)