Set Up an Email and SMS Notification System in Node.js: Compliance Ledger
Short answer: start with one notification service, a channel-neutral event record, and a delivery ledger. Email and SMS adapters should report the same state transitions. For a logistics compliance notice, that is the least complex design that still answers the hard question later: what was sent, through which channel, and what did the provider acknowledge?
| Option | Pick this when | Main trade-off |
|---|---|---|
| One Node.js worker with email and SMS adapters | Volume is moderate and the team owns the event workflow | The worker needs disciplined retry and idempotency rules |
| Queue-backed notification workers | Bursts, provider latency, or independent channel scaling are expected | More deployment and replay machinery |
| Separate channel services | Email and SMS have different owners, compliance rules, or release cycles | Cross-channel state becomes harder to correlate |
The first option is a good starting point for a compliance notice after a shipment status changes. Move to the second when the queue becomes the pressure valve. Choose the third only when the organizational boundary is real. Splitting a small workflow into services does not make its evidence better.
Keep it boring.
What must the delivery record prove?
A logistics notice usually has two different objects: the business event and the delivery attempt. A status event might say that shipment LX-20481 reached a regulated checkpoint. It does not say that the consignee received a notice. The ledger must preserve that distinction.
I start with a stable notification ID, a shipment reference, a recipient reference, the selected channel, the template version, and timestamps for each transition. The useful state path is short:
created -> queued -> submitted -> acknowledged
Failure paths need equal status: failed for a terminal rejection and retryable for a temporary failure. Keep provider message IDs as evidence, but do not make them your primary key. A provider can change its identifier format; your audit trail should not.
This is where observability earns its keep. Logs answer “what happened to this notification?” Metrics answer “is the system getting worse?” Alerts answer “should someone act now?” Mixing those jobs produces noisy dashboards and weak investigations.
How should email and SMS share one Node.js workflow?
Use a small interface and put channel details behind it. The application decides that a compliance notice is due. The adapter decides how to submit it.
type Channel = "email" | "sms"; type DeliveryState = "created" | "queued" | "submitted" | "acknowledged" | "retryable" | "failed"; type Notice = { id: string; shipmentId: string; recipientRef: string; channel: Channel; subject?: string; body: string; templateVersion: string; }; type DeliveryResult = { state: "submitted" | "acknowledged" | "retryable" | "failed"; providerMessageId?: string; reason?: string; }; interface ChannelAdapter { submit(notice: Notice): Promise<DeliveryResult>; } async function deliver( notice: Notice, adapter: ChannelAdapter, alreadySubmitted: (id: string) => Promise<boolean>, record: (id: string, state: DeliveryState, data?: object) => Promise<void>, ): Promise<void> { if (await alreadySubmitted(notice.id)) return; await record(notice.id, "queued"); try { const result = await adapter.submit(notice); await record(notice.id, result.state, { providerMessageId: result.providerMessageId, reason: result.reason, }); } catch (error) { await record(notice.id, "retryable", { reason: error instanceof Error ? error.message : "unknown error", }); throw error; } } The alreadySubmitted check is not optional. A worker can time out after the remote API accepted a request but before your process received the response. Retrying blindly may create duplicate notices. Use an idempotency key when the channel API supports one. Otherwise, persist the submission attempt and apply a documented duplicate policy. Compliance teams prefer an explicit limitation to a ledger that quietly claims certainty.
Do not put full message bodies or phone numbers in ordinary logs. Store a recipient reference, hash or redact sensitive fields where appropriate, and keep access to the delivery ledger narrow. The audit record needs to be useful without becoming a second copy of personal data.
Start with three dimensions: delivery state counts, latency from event creation to submission, and retryable failure rate. Break them down by channel, template version, and operational region only when those dimensions help someone act. High-cardinality shipment IDs belong in logs or traces, not metric labels.
A log entry should carry the notification ID, shipment ID, channel, state, attempt number, and correlation ID. A metric can count notification_submissions_total{channel="email",state="acknowledged"} without naming a customer. An alert should point to a runbook: check the queue depth, inspect recent adapter errors, and compare the affected channel with the other channel.
I initially treated provider acknowledgement as the finish line. That was too neat. Submission confirms a request reached the channel boundary; it does not establish that a person read the notice. Name the boundary honestly in the state model.
How do retries avoid making compliance notices worse?
Retry only errors classified as temporary. A malformed address, an invalid phone number, or a rejected template needs correction, not a faster retry loop. For a starting policy, wait 5 seconds before the first retry, increase the delay after each attempt, and stop after a documented maximum. Add exponential backoff with a maximum attempt count, then route exhausted records to a review queue. Keep the original event and every attempt linked to the same notification ID.
Test the awkward timing explicitly: the adapter returns a timeout after acceptance, the worker restarts after recording queued, and a callback arrives before the local submission response. These are ordinary distributed-system races. A useful test fixture contains one shipment, two channels, three attempts, and one duplicate worker execution. The expected result is one logical notice with a complete attempt history.
For email, sender-domain authentication is part of delivery reliability. SPF is defined by RFC 7208 and describes how a receiving system can evaluate whether sending hosts are authorized for a domain. Treat that configuration as deployment infrastructure, document ownership, and test changes before production traffic.
Limits and a practical decision rule
This design does not make delivery guaranteed. It gives the team a defensible record of what its system attempted and what the channel boundary reported. It also does not replace retention policy, consent rules, regional messaging requirements, or a human review process for disputed notices. Those decisions belong outside the adapter interface.
Use one worker while the workflow and ownership are close. Add a queue when bursts or slow APIs threaten event processing. Split services when policy or team boundaries justify the operational cost. Keep the ledger stable across all three choices. That is the part an audit, an on-call engineer, and a future migration can all use.
Further reading
- RFC 7208: Sender Policy Framework (SPF): https://datatracker.ietf.org/doc/html/rfc7208
- Node.js documentation: https://nodejs.org/docs/latest/api/