Sponsored Content

DEV Community

BrockFletcher1438
BrockFletcher1438

Posted on

Message ID Receipts for Node.js Transactional Email: Delivered or Bounced?

Short answer: poll the mail transport from a background collector, store immutable receipts under your own message ID, and let the Node.js dashboard read a local projection of sent, delivered, and bounced. Polling the transport from each browser tab looks simpler, but it couples user traffic to rate limits and turns an incomplete upstream timeline into application truth.

A delivery dashboard is an evidence viewer, not an inbox detector. sent says that a send attempt advanced through one stage; delivered normally records acceptance at the receiving side; neither proves that a person saw the message. That distinction is small enough to fit in a tooltip and important enough to shape the data model.

Keep that boundary honest.

What should a Node.js SaaS transactional email dashboard poll by message ID?

The dashboard should poll an endpoint owned by the SaaS, using the SaaS message ID. A worker behind that endpoint polls the transport's documented event source and translates transport-specific receipts. The browser never receives transport credentials, and its refresh rate cannot multiply upstream requests.

Create the internal ID before submitting mail. One logical message may have several attempts, and each attempt may receive a different transport ID, so a one-to-one mapping is a trap. The useful hierarchy is logical_message -> attempt -> transport_message_id -> receipts. Tenant ID belongs on every level. An opaque identifier still needs an authorization check; unguessable isn't the same as authorized.

Store receipts as immutable observations. A row needs the internal message ID, attempt number, transport ID, source event ID when one exists, normalized kind, source timestamp, ingestion timestamp, and a sanitized copy of the original payload. Put a unique constraint on the source event ID within its transport account. If the source doesn't promise stable event IDs, use a documented deduplication fingerprint and accept that replay behavior may vary.

The read model is deliberately less detailed than the receipt log. It can expose a current state, last_event_at, attempt count, and a short timeline. Preserve unknown rather than guessing from elapsed time. Preserve the raw kind when a newly introduced event maps to unknown, too; otherwise a parser update cannot recover meaning later.

This is where OTP systems get awkward. A retry can be delivered after the user has already requested another code, a delayed bounce can arrive after a newer attempt succeeds, and two tabs can request the same status at once. The current badge should describe a specific attempt or apply an explicit logical-message rule. It should never silently merge evidence until the answer looks green.

Consider an illustrative trace rather than an ideal timeline. Attempt 1 is submitted at 10:00:00 and produces sent; the user sees no code and requests another at 10:00:25; attempt 2 produces sent at 10:00:26 and delivered at 10:00:31; then attempt 1 reports bounced at 10:00:40. A reducer scoped only to the logical message can now make either bad choice: show bounced, hiding the useful evidence for attempt 2, or show delivered, hiding why the first code never arrived. The dashboard should show attempt 2 as the latest successful attempt while retaining the attempt 1 bounce in the timeline. Authentication logic must still decide which code remains valid; delivery evidence must not make that security decision. This separation also gives support a truthful answer: the second attempt reached the receiving system, the first did not, and neither receipt proves that the user opened anything. I've seen enough OTP delivery gaps to avoid compressing those statements into one cheerful badge — the edge case is the model, not an exception to it.

Make the state machine tolerate duplicates and disorder

Email events are observations from distributed systems. They may be duplicated, delayed, or delivered out of order. Treating the last row received as the current state means an old sent receipt can visually reverse a later delivered receipt.

The following Python reducer is intentionally transport-neutral. A Node.js service can implement the same transition table; using a pure reducer keeps the rule easy to test without a network or database. It also refuses to manufacture progress from an unfamiliar receipt.

from dataclasses import dataclass
from datetime import datetime


RANK = {
    "unknown": 0,
    "queued": 1,
    "sent": 2,
    "delivered": 3,
}


@dataclass(frozen=True)
class Receipt:
    event_id: str
    kind: str
    occurred_at: datetime


def project(receipts: list[Receipt]) -> dict:
    unique = {receipt.event_id: receipt for receipt in receipts}
    ordered = sorted(
        unique.values(),
        key=lambda receipt: (receipt.occurred_at, receipt.event_id),
    )

    state = "unknown"
    terminal = None
    for receipt in ordered:
        if receipt.kind in {"bounced", "complained"}:
            terminal = receipt.kind
            continue
        if terminal is None and RANK.get(receipt.kind, 0) > RANK.get(state, 0):
            state = receipt.kind

    return {
        "state": terminal or state,
        "last_event_at": ordered[-1].occurred_at if ordered else None,
        "timeline": ordered,
    }
Enter fullscreen mode Exit fullscreen mode

This example makes bounce terminal within one attempt. That is a product policy, not a universal law. A soft bounce may lead to another attempt, while a complaint should generally affect more than a dashboard badge. Keep transport classification and business policy in separate functions so changing one doesn't rewrite history.

Test the reducer with reversed input, duplicate event IDs, equal timestamps, unknown kinds, an empty history, and two attempts for one logical message. Then test the storage transaction: insert new receipts, update the cursor, and rebuild the projection atomically. If the process stops between receipt insertion and cursor advancement, replay should be harmless.

I've learned to treat 429 as a scheduling signal, not a generic failure. Honor a documented retry delay when the source provides one, add jitter, cap concurrency per transport account, and persist the next eligible poll time. Don't let one noisy tenant consume every worker slot. A rate-limit response is also worth its own metric because normal latency charts can look fine while useful delivery evidence grows stale.

Polling cadence is a budget and freshness decision

Start with the freshness the workflow actually needs. A support dashboard may tolerate tens of seconds; an OTP recovery flow may need a quicker signal but still shouldn't promise inbox arrival. Poll recently submitted attempts more often, back off as they age, and stop normal polling after a terminal receipt or a defined observation deadline. A manual support refresh should read the local projection, not reset the upstream schedule.

Cursors are durable state.

Account-stream polling is usually more efficient than one schedule per message because each page can carry receipts for many active messages. It does require careful tenant routing and a cursor scoped to the correct transport account. Per-message polling is reasonable for low-volume internal tooling or when that is the only documented retrieval model. The catch is linear request growth: ten times as many unresolved messages can mean roughly ten times as many scheduled lookups at the same cadence.

Webhooks change the failure ownership. They reduce repeated reads and can lower event latency, but the receiver must authenticate requests using the source's documented mechanism, reject replays, absorb bursts, and acknowledge only after durable handoff. A hybrid uses webhooks for speed and a slower cursor poll for reconciliation. It is not suitable for a small team that cannot operate and test two ingestion paths; stick with polling when modest staleness is acceptable and the request budget is predictable. Choose webhooks when the source supports them and near-immediate updates materially change the user flow.

I'm not sure there is a universal crossover point. The answer depends on active-message count, event retention, pagination, rate-limit scope, and the freshness promised to users. Measure upstream requests per active message, pages per poll, oldest cursor age, and event lag before changing patterns.

Pattern Good fit Main limitation Recovery drill
Per-message poll Small support tools Requests scale with unresolved messages Restart without duplicating receipts
Account-stream poll Steady multi-tenant collection Cursor and tenant routing are coupled Restore the last committed cursor
Webhook Low-latency updates Public receiver and replay defense Replay authenticated fixtures out of order
Hybrid High-value status workflows Two paths share one deduplication contract Disable either path, then reconcile

Cost belongs in this decision, but provider pricing is too changeable to hard-code into architecture. Model request volume from cadence, active duration, page size, and retries. Also model database retention and support traffic. The cheapest upstream read pattern can still be expensive operationally if nobody can explain cursor ownership during an incident.

Show evidence, failure modes, and compliance signals

The interface should show the normalized state and its timestamp, then make the receipt timeline available for diagnosis. Label delivered as accepted by the receiving system unless the transport documentation establishes a narrower meaning. Never rename it “read” or “in inbox.” For bounced, show a stable category and a scrubbed reason rather than dumping an upstream payload into the browser.

Stale means unknown.

Useful operational views group outcomes by receiving domain, template version, attempt, and normalized bounce category. Watch collector lag, cursor age, duplicate rate, unknown-event rate, unresolved-message age, and 429 count. Alerting only on process uptime misses the failure that matters: a healthy worker can repeatedly read an old page while the dashboard becomes stale.

Deliverability work also sits outside the event collector. Google's sender guidance covers authentication, TLS, spam-rate control, and subscription-message requirements. A perfect receipt pipeline cannot compensate for weak sender practices. Keep configuration checks and sending-domain health beside the dashboard, while making clear that they are diagnostics rather than proof about one message.

Minimize what support can see. Hash or mask recipient addresses in list views, restrict access to raw receipts, omit message bodies, define retention, and audit privileged reads. Transactional and subscription traffic need distinct policy handling. If SMS later shares the communications view, don't reuse email assumptions: SMS encoding affects segmentation, so persist encoding and segment count per attempt and explain them separately from email delivery states.

Roll out the collector without betting the send path

Begin by assigning internal IDs and shadow-writing receipts while the existing status remains visible. Replay captured, sanitized fixtures through the reducer and compare projections. The test set should include duplicate delivery, reversed order, unknown kinds, a 429 retry, cursor replay after restart, two attempts, and cross-tenant access denial.

Next, expose the new read model to staff behind a flag and monitor event lag and disagreement counts. Investigate disagreements from the immutable timeline. Do not choose whichever state appears more favorable.

After the observation window, switch dashboard reads to the projection, retain a short rollback period, and stop writing the legacy mutable status from multiple code paths. Backfill only what the retained receipts support; mark gaps unknown. Finally, rehearse cursor restoration and projection rebuild before removing the old field. No grand rewrite is required. The target is a dashboard that can say what evidence exists, which attempt it describes, and how stale that evidence is.

References

Top comments (0)