A fallback that sends SMS whenever email is merely slow will duplicate alerts and train families to ignore both channels. The practical rule is narrower: poll the email attempt until it reaches a terminal outcome or a deadline, suppress addresses that return a permanent recipient failure, and use SMS only when the event is still relevant and the policy allows it. Delivery reliability comes from explicit state, not from racing two sends.
This walkthrough uses an edtech example: a class-cancellation alert must reach a guardian, while a bounced school email address must not be retried forever. The same design fits a small SaaS because it needs no webhook receiver. A worker records the event and email attempt, polls status through a provider-neutral adapter, then makes one durable decision about SMS.
How should multi-channel event notifications handle email fallback?
Email acceptance, delivery, and human attention are different events. A provider may accept a message before the receiving system produces a final result. SMTP enhanced status codes separate persistent failures from temporary failures; RFC 3463 defines the first digit 5 as permanent failure and 4 as persistent transient failure. Treating both as failed destroys useful information.
Slow is not bounced.
Time matters too. A cancellation notice five minutes before class has a different fallback deadline from a weekly progress report. Store that deadline on the event instead of burying it in a worker timeout. Then a retry, deploy, or process restart cannot quietly reset the clock.
The suppression rule should be equally specific. Suppress an email address after a terminal failure classified as an invalid or unavailable recipient, not after a network timeout and not because one status lookup failed. Yahoo's sender guidance calls for prompt removal of invalid recipients and monitoring delivery responses. That is an operational requirement, not a reason to guess from ambiguous status text.
Build the smallest durable state machine
Keep three records separate: the notification event, each channel attempt, and the recipient's channel eligibility. This costs a few more rows, but it prevents a single overloaded status column from answering incompatible questions such as "Was email delivered?" and "May this address ever receive email again?"
A useful event state set is pending_email, polling_email, delivered, pending_sms, delivered_sms, and exhausted. Attempts retain raw provider status plus a normalized result. Recipient suppression is durable and carries a reason and timestamp. Four invariants do most of the work:
- One event has at most one successful terminal outcome.
- Every send uses an idempotency key derived from the event and channel.
- A temporary lookup or delivery result schedules another poll with backoff.
- Only a classified permanent recipient failure changes email eligibility.
That last distinction is the trap. Keep it visible.
Deadlines win.
For a polling-only deployment, the data flow is plain: the API inserts an event, the worker claims it, renders and submits email, and stores the remote attempt ID. Later jobs ask the adapter for status. A delivered result closes the event; a permanent invalid-recipient result suppresses the address and queues SMS; an inconclusive result polls again until the event deadline. Claim rows with a lease or transactional lock so two workers cannot make the fallback decision together.
A runnable TypeScript orchestration core
The example below keeps storage and transport behind interfaces. It uses an abstract repository so the decision logic stays portable; a production implementation must make save and job claiming atomic in durable storage. Status values are normalized at the adapter boundary. Provider-specific strings never leak into policy code.
type EmailResult =
| { kind: "pending" }
| { kind: "delivered" }
| { kind: "temporary_failure"; reason: string }
| { kind: "invalid_recipient"; reason: string };
type EventState =
| "pending_email"
| "polling_email"
| "delivered"
| "pending_sms"
| "delivered_sms"
| "exhausted";
type AlertEvent = {
id: string;
guardianId: string;
email: string;
phone?: string;
className: string;
startsAt: string;
fallbackAfter: string;
expiresAt: string;
emailAttemptId?: string;
state: EventState;
};
interface EmailPort {
send(input: {
to: string;
subject: string;
body: string;
idempotencyKey: string;
}): Promise<{ attemptId: string }>;
getResult(attemptId: string): Promise<EmailResult>;
}
interface SmsPort {
send(input: {
to: string;
body: string;
idempotencyKey: string;
}): Promise<void>;
}
interface AlertStore {
get(id: string): Promise<AlertEvent>;
save(event: AlertEvent): Promise<void>;
suppressEmail(input: {
guardianId: string;
email: string;
reason: string;
observedAt: string;
}): Promise<void>;
}
const render = (event: AlertEvent) => ({
subject: `Class cancelled: ${event.className}`,
body: `${event.className} at ${event.startsAt} has been cancelled.`
});
async function advanceAlert(
eventId: string,
now: Date,
store: AlertStore,
email: EmailPort,
sms: SmsPort
): Promise<EventState> {
const event = await store.get(eventId);
if (["delivered", "delivered_sms", "exhausted"].includes(event.state)) {
return event.state;
}
if (now >= new Date(event.expiresAt)) {
event.state = "exhausted";
await store.save(event);
return event.state;
}
if (!event.emailAttemptId) {
const message = render(event);
const attempt = await email.send({
to: event.email,
...message,
idempotencyKey: `${event.id}:email`
});
event.emailAttemptId = attempt.attemptId;
event.state = "polling_email";
await store.save(event);
return event.state;
}
const result = await email.getResult(event.emailAttemptId);
if (result.kind === "delivered") {
event.state = "delivered";
await store.save(event);
return event.state;
}
if (result.kind === "invalid_recipient") {
await store.suppressEmail({
guardianId: event.guardianId,
email: event.email,
reason: result.reason,
observedAt: now.toISOString()
});
event.state = "pending_sms";
} else if (now < new Date(event.fallbackAfter)) {
return event.state;
} else {
event.state = "pending_sms";
}
if (!event.phone) {
event.state = "exhausted";
await store.save(event);
return event.state;
}
await sms.send({
to: event.phone,
body: render(event).body,
idempotencyKey: `${event.id}:sms`
});
event.state = "delivered_sms";
await store.save(event);
return event.state;
}
The sample makes one deliberate compromise: send completes before the event record stores the remote attempt ID. A process crash in that gap can repeat the call. The idempotency key gives the transport adapter a stable deduplication identity, but only if the chosen transport honors it or the adapter persists its own send ledger. For tighter control, use a transactional outbox: commit the event and an email command together, then let a dispatcher claim the command.
Do not infer success from a successful submission response. That response belongs to the handoff, not necessarily the destination. The adapter must map documented remote states into the four results above and retain the raw value for diagnosis. Unknown values stay pending or become an operator-visible exception; they must never trigger recipient suppression.
Never guess here.
Polling without creating a second outage
Polling needs a budget. A fixed one-second loop multiplies status traffic precisely when a mail system is slow, while very wide intervals can miss the alert's useful window. Schedule persisted jobs with bounded exponential backoff and jitter, capped by fallbackAfter and expiresAt. The exact intervals depend on the event's urgency and the transport's published limits, so they belong in configuration and load tests rather than in a universal recipe.
Count attempts, but decide from time. If a worker is unavailable for ten minutes, an attempt counter alone makes a stale cancellation look fresh. On recovery, compare the stored deadline with the current clock before sending anything. This is why fallbackAfter and expiresAt are data.
The clock is policy.
The cost model is also clearer with persisted polling. Track status reads per submitted email, SMS fallbacks per event class, time from submission to terminal state, and suppression changes. Those measures expose a noisy adapter or an overly aggressive deadline without requiring message-content logs. Avoid putting student names, guardian addresses, phone numbers, or rendered bodies in ordinary logs; use event IDs and classified outcomes.
Templates deserve the same discipline. Mustache escapes variables by default, while triple braces render unescaped content. Keep untrusted school or class data in normal escaped variables, validate required fields before submission, and render the email and SMS variants from one event payload so their facts cannot drift.
Failure tests that earn their keep
Happy-path tests prove little here. Drive a fake clock and scripted adapters through pending, delivered, temporary failure, invalid recipient, and expiry. Run two workers against the same event to verify that claiming prevents duplicate decisions. Then inject a crash after email submission and before persistence; the repeated call should carry the same idempotency key.
One table keeps policy review concrete:
| Observed result | Event action | Recipient action |
|---|---|---|
| Delivered | Close as delivered | None |
| Pending before fallback deadline | Schedule another poll | None |
| Temporary failure before deadline | Retry status later | None |
| Invalid recipient | Queue eligible SMS | Suppress that email |
| Deadline reached, still unresolved | Queue eligible SMS | None |
| Event expired | Close as exhausted | None |
Also test missing phone consent or eligibility, a status adapter returning an unknown value, template validation failure, and an SMS submission error. SMS failure should remain a retryable attempt until its own policy expires; it should not reopen email or erase the suppression decision. Channel policy and recipient hygiene are separate state transitions.
Operate the workflow as a delivery system
Before deployment, define the event deadline with the people who own the notification, document which enhanced email outcomes qualify as invalid recipients, and decide how a guardian can correct contact details. Confirm that the SMS path has the required consent and content rules for the regions served. None of those decisions belongs in a transport adapter.
During rollout, use synthetic recipients and adapter fakes to verify state transitions, then watch the age of the oldest polling job and the count of events past their fallback deadline. Alert on stuck work, unknown status mappings, and sudden changes in suppression rate. Review samples by opaque event ID, with personal data kept out of dashboards.
Finally, rehearse recovery. Pause the worker, let events cross both deadlines, restart it, and verify that expired alerts do not send late. Rotate a transport credential in a staging environment. Reprocess the same queue item twice. A small system that survives those three exercises is more useful than a complicated channel graph whose failure policy exists only in a diagram.
Polling is the wrong trade-off when a transport offers reliable event delivery and the team can operate an authenticated receiver; repeated reads add load and delay. It is also unsuitable when the event requires immediate, safety-critical confirmation. In those cases, use acknowledged delivery events or a purpose-built incident channel, while keeping the same durable policy state. For ordinary class changes, polling remains attractive because the deployment surface is small and every decision can be replayed.
The decision rule remains compact: poll while email has a credible chance to finish, escalate once while the alert is useful, and suppress only on a classified permanent recipient failure. That gives a solo team predictable delivery behavior without coupling business policy to one messaging service.
Top comments (0)