DEV Community

knoxblackwood2375
knoxblackwood2375

Posted on

Event Notifications for Transactional Email and SMS — Polling Delivery Safely

Short answer: for a marketplace seller alert, use a durable outbox, an at-least-once retry queue, and a bounded cron poller; keep template ownership in your service and treat delivery status as evidence, not proof that anyone read the message.

Template ownership is the first decision. The seller-facing email and SMS templates should live in versioned application code or a controlled content store, while the transport adapter owns only addressing and provider-specific status mapping. That boundary lets a media marketplace change wording, localization, and legal footer without changing queue semantics. It also gives the platform team one place to review who may send which alert.

The event is order.created, with an order ID, seller ID, locale, region (US, EU, or South Africa), and a template version. Persist the event and its notification intent in one database transaction. A process crash cannot then leave an order visible in the marketplace with no corresponding alert job.

Keep the payload small.

How should template ownership shape an order alert?

The first failure is usually not the carrier. It is a missing boundary in the application: the order commits, the HTTP request times out, and nobody can tell whether an alert was created. The second is a worker that sends successfully but dies before recording its message ID. The third is a status poll that rewrites a newer observation with an older one. These are evidence failures, not merely delivery failures. Draw the timeline from order commit through template render, queue claim, transport acceptance, status observation, and retention expiry; every gap needs an owner, a timestamp, and a recovery action.

Measure twice.

An SRE review should ask what an auditor can reconstruct after 30 days, when the original seller message has expired. If the answer is only a green counter, the design is not observable enough. Keep the event ID, template version, routing decision, hash, message ID, and state transitions; those fields explain the decision without creating a permanent archive of personal data.

Evidence retention for a Node.js notification pipeline

Use a stable event ID and a separate channel key, such as evt_7f2:email and evt_7f2:sms. Store the template version, a hash of rendered content, region, attempt count, provider message ID, normalized status, raw status, and observation time. Encrypt message bodies and phone numbers, give them a stated expiry, and retain routing evidence longer only when policy requires it. An email address is not an idempotency key; one seller can receive two legitimate orders in the same minute.

The outbox row is claimed with a lease. The worker sends with the same idempotency token on every retry, records the returned message ID, and releases the lease. Exactly-once delivery across a database and an external transport is not a credible SLO; at-least-once execution plus deduplication is the useful contract.

Here is the transport-neutral core in Go. The adapter can point at an email or SMS implementation, and the store can be exercised in tests without contacting a carrier.

package notify

import "time"

type Job struct {
    EventID string
    Channel string
    Attempt int
}

type Transport interface {
    Send(channel, token string) (messageID string, err error)
}

type Store interface {
    Accepted(token string) bool
    MarkAccepted(job Job, messageID string, at time.Time)
    Reschedule(job Job, at time.Time, reason string)
    MarkFailed(job Job, reason string, at time.Time)
}

func nextRetry(attempt int, now time.Time) time.Time {
    ceiling := 1 << min(attempt, 8)
    if ceiling > 900 {
        ceiling = 900
    }
    return now.Add(time.Duration(ceiling) * time.Second)
}

func Handle(job Job, transport Transport, store Store, now time.Time) {
    token := job.EventID + ":" + job.Channel
    if store.Accepted(token) {
        return
    }
    messageID, err := transport.Send(job.Channel, token)
    if err == nil {
        store.MarkAccepted(job, messageID, now)
        return
    }
    job.Attempt++
    if job.Attempt <= 8 {
        store.Reschedule(job, nextRetry(job.Attempt, now), classify(err))
        return
    }
    store.MarkFailed(job, classify(err), now)
}

func min(a, b int) int { if a < b { return a }; return b }
func classify(err error) string { return err.Error() }
Enter fullscreen mode Exit fullscreen mode

The real implementation should classify errors before this boundary: a rate-limit response or timeout is transient, while an invalid destination is permanent. A timeout leaves acceptance uncertain, so reuse the token and reconcile by message ID when the adapter supports lookup. Store a redacted reason code, never an unfiltered response that might contain contact data.

How should Node.js event notifications poll email and SMS delivery status?

Cron discovers work; the queue applies back-pressure; workers perform network I/O. Every minute, cron selects only non-terminal records whose next_check_at is due and whose age is within the evidence-retention window. It claims them with a lease and enqueues a bounded batch. If scheduling pauses for ten minutes, the next run catches up up to that cap instead of issuing an unbounded burst.

The long tail matters more than the happy path. Suppose 50,000 email and SMS records are open at once, half in the EU and half in the US, with a five-minute target interval. A naive scan emits roughly 167 lookups per second before retries, then adds a second wave when a carrier returns 429. A lease prevents duplicate claims, but it does not create capacity; workers still need per-region limits, a queue-age alarm, and a poll budget that can be reduced during an incident. Keep a dead-letter review path for records whose evidence is incomplete, and make the poller idempotent so a repeated cron invocation changes no terminal state. That is the difference between “we asked for status” and “we can explain status.”

Normalize transport states to queued, sent, delivered, failed, or unknown, but retain the original state beside it. An older queued observation must not overwrite a later delivered one. Record your observation timestamp separately from any provider timestamp. Polling is useful only when a stable message identifier and a status lookup operation exist; otherwise, report unknown and stop pretending the signal is stronger than it is.

I once assumed a five-minute poll interval was harmless. Capacity math changed my mind: 50,000 open messages at that interval create about 167 status requests per second before retries, and a regional carrier limit can turn that into a queue spiral. Set per-region concurrency, use jitter, honor Retry-After, and alert on queue age and retry depth. A 99% delivery SLO without a time-to-terminal-state target is not an SLO; define both.

Email and SMS signals need different levels of trust. DMARC evaluates domain alignment using SPF and DKIM policy; RFC 7489 describes the model and its reporting. A pass is an authentication result, not a receipt. Similarly, an accepted transport state means the message entered a delivery system, not that the seller saw it.

Open tracking is weaker still. Apple's Mail Privacy Protection can fetch remote content privately in the background, so an open event may be generated without a human reading the alert. Use opens for aggregate diagnostics, not for order-critical workflow transitions. For SMS, keep sent, delivered, and unknown distinct in dashboards and exports.

Operational limits belong in the same runbook as signal interpretation.

Constraint Prefer Trade-off to document
Seller must be paged within seconds A controlled callback or paging channel Public ingress, authentication, and audit work
Team cannot operate inbound endpoints Cron polling behind a queue Delayed status and outbound lookup load
Years of searchable message content A governed records system Higher retention, access, and deletion burden
Rapid copy changes with strict review Versioned templates owned by the application Release coordination and localization testing

The catch is latency: minute-level polling is not suitable for a seconds-level escalation. Choose an inbound mechanism or a dedicated paging path there. Owned templates are also a poor fit when non-engineers need unrestricted, instant editing; use a reviewed content system with versioning and rollback instead.

Verification should replay an order-created event, kill a worker after transport acceptance, return a timeout, return a rate limit, and feed stale status observations. Confirm that one message ID is retained, retries remain bounded, and terminal states stay monotonic. Roll back a template by selecting its previous version; do not mutate historical evidence. Your mileage may vary across carriers, so measure terminal-state latency by channel and region before setting poll intervals.

The decision rule is simple: own the template when consistency and review matter, outsource only the transport, and retain the smallest evidence set that proves routing and delivery. Stop keeping readable bodies and direct phone numbers after their approved windows unless a legal hold says otherwise.

References

Top comments (0)