DEV Community

dawn li
dawn li

Posted on

Property Report Notifications: User Preferences, Email/SMS Opt-Out, and Suppression

Short answer: Build the Node.js event notification system around a durable delivery intent, but resolve user channel preferences, email or SMS opt-out state, and the suppression list again immediately before each send; for property reports, retain one immutable attachment object rather than a copy per recipient.

The bill starts with multiplication, not an API choice. A report run creates R rendered bytes, N recipient deliveries, A attempts, and D retention days. If the system keeps a private attachment copy and a rendered email for every attempt, retained byte-days trend toward R × N × A × D. If it keeps one content-addressed report plus small delivery records, the attachment term becomes R × D, while recipient and attempt data remain rows rather than blobs. For a concrete planning case, a 3 MB report sent to 10,000 recipients creates 30 GB of duplicate attachment data per run before retries; one immutable 3 MB object and 10,000 references preserve the same send input without that multiplier. This is arithmetic for capacity planning, not a benchmark or a vendor price claim.

Bytes are the multiplier.

Delivery reliability still wins the design argument. A smaller storage bill is useful, but sending a report after a resident has opted out is an integrity failure, and losing the evidence that explains a skipped message is an audit failure. The least complex design that avoids both is an outbox, an immutable report object, and a late consent gate.

What does the attachment bill retain?

Separate costs by the resource that causes them: report rendering, attachment storage, queue operations, email attempts, SMS segments, and audit retention. Don't fold them into one vague “notification cost.” Email attachments inflate transferred bytes and may trigger retries of the same payload. SMS is different: a report cannot be carried as an attachment, so the text channel should announce availability or direct the recipient to an authenticated property portal, provided that purpose and consent permit it. The channels can share policy code without pretending they have the same payload.

The storage model should distinguish evidence from bulk data. A delivery record needs the event identifier, recipient identifier, chosen channel, policy version, consent decision, suppression reason, report object digest, attempt number, and provider receipt identifier when one exists. It does not need another copy of the PDF. The report object should be immutable for the duration of the replay window; otherwise a retry can send different bytes under the same event ID, which makes reconciliation nearly impossible.

Retained item Why keep it? Safe reduction Failure cost after deletion
Report object Retry the exact attachment and verify its digest One object per generated report Exact attachment replay becomes impossible
Rendered MIME message Diagnose encoding and header construction Keep briefly, or regenerate deterministically Byte-for-byte reconstruction may be unavailable
Delivery decision Explain sent, skipped, or suppressed outcomes Store compact fields, not payloads Consent disputes become hard to resolve
Provider response metadata Reconcile accepted and rejected attempts Retain only fields needed by policy Provider-side investigation loses context

The dominant term depends on workload, so measure byte-days and attempts separately. If reports are large and recipient fan-out is high, attachment duplication is the obvious target. If reports are small but addresses are stale, retries and downstream rejection work may dominate. I'm not sure which term dominates your estate until production telemetry separates them; a week of counts by report size, fan-out, channel, and attempt is enough to replace intuition with a defensible model.

One change usually matters most: put the immutable attachment behind a digest-keyed object reference, then let each delivery intent point to that reference. The mail adapter reads it only when the late consent check passes. The system deliberately stops keeping per-attempt attachment copies and long-lived rendered MIME after the defined investigation window. The catch is real — once those artifacts expire, an operator can prove which digest was selected but may be unable to reproduce the exact wire bytes of an old message.

How can a Node.js event notification system implement user channel preferences?

Treat preference and suppression as different kinds of state. A preference answers “where may ordinary property notifications go?” A suppression entry answers “must this destination not be contacted?” The latter is a hard deny. An email address on the suppression list must remain blocked even if a stale profile says email is enabled; an SMS opt-out must beat an event rule that prefers text. That ordering belongs in one policy function, not in every provider adapter.

There are two checks because time passes. At event ingestion, evaluate eligibility and write the event plus delivery intents in one database transaction, commonly called the transactional outbox pattern. At dispatch, reload the current user preference and suppression records before touching an external channel. The second read closes the race in which a resident opts out after a property report was queued but before a worker sends it.

It is a small race. It matters.

The implementation below is deliberately a provider-neutral Python model of the policy boundary; the same database transaction and compare-before-send sequence belongs in a Node.js worker. lookup_policy and mark_result should use a transaction or a consistency mechanism that prevents a concurrent opt-out from being reordered behind the final send claim. The adapter receives bytes only after policy approval, keeping suppression logic out of transport-specific code.

from dataclasses import dataclass
from enum import Enum
from typing import Protocol


class Channel(str, Enum):
    EMAIL = "email"
    SMS = "sms"


@dataclass(frozen=True)
class PolicySnapshot:
    version: int
    channel_enabled: bool
    destination_suppressed: bool
    purpose_allowed: bool


@dataclass(frozen=True)
class DeliveryIntent:
    intent_id: str
    user_id: str
    channel: Channel
    destination: str
    report_digest: str


class Repository(Protocol):
    def lookup_policy(self, intent: DeliveryIntent) -> PolicySnapshot: ...
    def read_report(self, digest: str) -> bytes: ...
    def claim_if_policy_current(
        self, intent_id: str, policy_version: int
    ) -> bool: ...
    def mark_result(self, intent_id: str, result: str) -> None: ...


class Transport(Protocol):
    def send(self, destination: str, payload: bytes) -> str: ...


def dispatch(
    intent: DeliveryIntent,
    repository: Repository,
    transport: Transport,
) -> str:
    policy = repository.lookup_policy(intent)

    if policy.destination_suppressed:
        repository.mark_result(intent.intent_id, "skipped:suppressed")
        return "skipped:suppressed"
    if not policy.channel_enabled or not policy.purpose_allowed:
        repository.mark_result(intent.intent_id, "skipped:not_allowed")
        return "skipped:not_allowed"

    if not repository.claim_if_policy_current(intent.intent_id, policy.version):
        return "retry:policy_changed"

    report = repository.read_report(intent.report_digest)
    receipt_id = transport.send(intent.destination, report)
    repository.mark_result(intent.intent_id, f"accepted:{receipt_id}")
    return "accepted"
Enter fullscreen mode Exit fullscreen mode

claim_if_policy_current is the hinge. A compare-and-set claim can fail when the policy version changes, causing the worker to reload policy rather than send under an obsolete decision. Don't hold a database transaction open across a network call. Instead, claim a uniquely identified attempt, make dispatch idempotent at the application boundary, and reconcile ambiguous outcomes with the provider receipt. Exactly-once network delivery is not a credible promise; a durable state machine with explicit uncertainty is.

Govern suppression data as a state machine

A single subscribed flag cannot explain why delivery is forbidden or which channel the decision covers. Model destination suppression as append-only events projected into current state: user opt-out, administrative block, hard delivery failure, complaint, and later re-consent where policy allows it. Keep purpose and channel scoped. A resident who declines promotional text messages may still have a separately governed operational-email preference, but the application must not infer that permission; the purpose taxonomy and applicable rules decide it.

Email and SMS also expose different control surfaces. For email, RFC 8058 defines one-click list unsubscribe through specific headers and an HTTPS operation, while MIME standards define the multipart structure used for attachments. For text messaging, CTIA publishes messaging interoperability and compliance principles. Those sources set protocol and ecosystem expectations; counsel and the organization's policy owner must determine the exact consent duties for the jurisdictions and message classes involved.

Processing must converge from every ingress path. A settings-page opt-out, an inbound SMS keyword handled by the messaging provider, a list-unsubscribe request, and an operator action should all append the same normalized suppression event and increment the same policy version. Otherwise one database says “blocked” while another queue or vendor list still says “send.” Provider-managed suppression can be valuable defense in depth, but it isn't the system of record for cross-channel intent.

Name the failure modes during design review:

  • A stale queue item bypasses a new opt-out because consent was checked only when enqueued.
  • Email normalization differs between the profile table and suppression index, so equivalent destinations do not match.
  • A retry creates a new logical attempt and sends the same attachment twice.
  • The PDF object changes in place, so the retry no longer matches the audited digest.
  • A webhook arrives twice and reverses a terminal state because handlers are not idempotent.
  • SMS and email share a global Boolean, erasing channel-specific and purpose-specific choices.

No transport adapter should be able to override these decisions. Keep the adapter dull: construct a standards-compliant message, submit it, normalize the receipt, and report status.

No send means no send.

Test the no-send path before trusting delivery

Most notification demos test that a message arrives. Reliability work spends at least as much effort proving that a forbidden message cannot leave. Build a table-driven test matrix across channel enabled or disabled, purpose allowed or denied, destination clear or suppressed, policy unchanged or changed, and first attempt or retry. The invariant is sharper than “the worker behaves”: Transport.send has zero calls for every denied combination.

Then test concurrency. Pause a worker after its first policy read, commit an opt-out that increments the version, and resume it. The claim must fail and the worker must reload. Repeat with two workers claiming one intent; only one may own the active attempt. Inject a timeout after external acceptance but before the local result commit, then verify that reconciliation uses the stable intent and provider receipt instead of blindly generating another send.

Observability should follow the same state machine. Count intents by accepted, skipped:suppressed, skipped:not_allowed, retry:policy_changed, and unresolved outcome; measure queue age and report-object read failures separately from provider rejection. Logs should carry opaque user and intent identifiers, not raw addresses or report contents. An audit query should answer one narrow question without joining blobs: “Why did event E for user U use email, skip SMS, and reference report digest H?”

This architecture is not suitable when an existing system cannot provide a strongly ordered policy version or an atomic outbox write. In that case, stick with a simpler synchronous workflow for low-volume reports until the data layer can establish those invariants; adding a queue first would widen the opt-out race. It is also a poor fit for emergency communications governed by a separate legal basis and escalation procedure. Those need an explicit policy domain, not a hidden bypass in ordinary notification code.

Deployment can be incremental. Shadow-evaluate the new policy function against current decisions without sending, compare reason codes, backfill suppression projections, and only then route one property cohort through the new dispatcher. Rollback should stop claims while preserving intents and immutable report objects. Never roll back by disabling the suppression check.

The final retention decision should be written down: keep compact decision events for the required audit period, keep one immutable report object through the allowed retry and investigation window, and expire duplicate renderings and transient MIME. You give up indefinite byte-for-byte replay. You keep the more important evidence — which policy version permitted or denied delivery, which content digest was selected, and what happened to each attempt.

References

Further reading

Top comments (0)