Short answer: a Node.js transactional app should treat a compliance notice as a small evidence pipeline, not as a successful send() call. Keep a durable send ledger, poll delivery feedback into an idempotent suppression list, and make the final send decision against that list. The record should explain what the app intended, what the mail system reported, and why a later send was allowed or blocked.
This decision is about evidence first. A message accepted by an API is not proof that it reached a mailbox, and a delivery event is not proof that the application observed it. Those are different facts with different owners. Keeping them separate makes an audit defensible and makes a deliverability incident diagnosable.
The decision record: preserve facts at each boundary
The application needs four durable records, even if they live in two database tables at first:
- A notice intent, with the recipient, purpose, template version, and a stable application message ID.
- A provider submission result, including the request time, response status, and provider message ID when one exists.
- A delivery observation, keyed by the provider event ID and normalized to an internal outcome.
- A suppression decision, with its source event, reason, and creation time.
The invariant is simple: a known bad recipient must not receive another routine transactional message. The second invariant is less visible but just as important: replaying a feedback event must not create a second suppression decision or move the polling cursor past uncommitted work.
There is a useful failure boundary here. The send path can record an accepted submission even when delivery is still unknown. The feedback worker can later add a bounce or complaint observation without rewriting the original submission. If the worker stops after 63 of 100 events, the cursor stays before that page; the next run repeats the page and the event IDs make the replay harmless. In practice, that means the worker's transaction should include the event insert, the suppression upsert, and the cursor update, with the cursor update last. Suppose a database connection drops after the event row and suppression row are durable but before the cursor write: the next poll sees the same event, rejects the duplicate event ID, confirms the existing suppression, and can safely advance the cursor. Suppose the cursor is written first instead: a process crash can make the system believe all 100 records were handled even though records 64 through 100 never reached durable state. That is not an abstract consistency concern when the missing records are the very complaints that should stop a repeat notice. The application should make the safe replay path boring and observable, with a metric for replay count and a log field for the cursor range.
That is the evidence chain.
It is not a promise of inbox placement.
| Design choice | What it gives the compliance record | What it cannot prove |
|---|---|---|
| Application send ledger | Intent, template, recipient, and submission timing | That a recipient read the notice |
| Provider event polling | A recorded delivery outcome after observation | That an event was visible before the next poll |
| Suppression table | A durable reason to block future routine sends | That every address on an imported list is valid |
| Stable event IDs and cursor | Replay-safe ingestion with a bounded freshness window | Instant reaction to a newly created event |
How should a Node.js app poll email bounce and complaint records?
Use a background worker rather than polling inside a request handler. The worker reads from the last committed cursor, converts provider-shaped data into a narrow internal event, writes observations and suppression changes in one transaction, and advances the cursor only after those writes commit. The Node.js process can use its existing queue or scheduler; the important part is the transaction order.
The following Python model shows the policy boundary without pretending that one provider's JSON is universal. In a real Node.js service, the adapter would parse the provider response and call the same application-level operation.
from dataclasses import dataclass
from typing import Iterable
BAD_OUTCOMES = {"permanent_bounce", "complaint"}
@dataclass(frozen=True)
class DeliveryEvent:
event_id: str
message_id: str
recipient: str
outcome: str
class DeliveryEvidence:
def __init__(self) -> None:
self.seen_event_ids: set[str] = set()
self.suppressed: dict[str, str] = {}
self.cursor: str | None = None
def commit_page(
self, events: Iterable[DeliveryEvent], next_cursor: str
) -> None:
for event in events:
if event.event_id in self.seen_event_ids:
continue
normalized_recipient = event.recipient.casefold()
if event.outcome in BAD_OUTCOMES:
self.suppressed.setdefault(
normalized_recipient, event.outcome
)
self.seen_event_ids.add(event.event_id)
# Use one database transaction for these writes in production.
self.cursor = next_cursor
def should_send_routine_notice(self, recipient: str) -> bool:
return recipient.casefold() not in self.suppressed
def example() -> None:
evidence = DeliveryEvidence()
page = [
DeliveryEvent("evt-204", "notice-77", "user@example.com", "delivered"),
DeliveryEvent("evt-205", "notice-78", "typo@example.com", "permanent_bounce"),
DeliveryEvent("evt-206", "notice-79", "report@example.com", "complaint"),
]
evidence.commit_page(page, next_cursor="page-9")
evidence.commit_page(page, next_cursor="page-9") # Safe replay.
assert evidence.should_send_routine_notice("user@example.com")
assert not evidence.should_send_routine_notice("TYPO@example.com")
assert len(evidence.seen_event_ids) == 3
if __name__ == "__main__":
example()
The sample's sets are deliberately small and in memory. Production storage needs a unique constraint on the event ID, a normalized recipient key, and a transaction that covers event insertion, suppression upsert, and cursor advancement. A crash after the database commit but before the worker acknowledges the job is fine; the replay hits the unique event ID and produces the same state.
The adapter should also distinguish a permanent bounce from a temporary delivery delay. A temporary failure belongs in retry policy and observability, not automatically in a permanent suppression list. The exact provider taxonomy must be mapped from its current event schema; do not infer a permanent failure from a vague human-readable string.
What should the compliance notice workflow record before and after polling?
Before submission, create the notice intent. Include the legal or product purpose, recipient address as supplied by the triggering record, a message or case ID, and the content version. Do not overwrite these fields when a retry occurs. A retry is another attempt related to the same intent, not evidence that the first attempt never existed.
After submission, store the provider response separately. A request timeout is an unknown submission state, so retrying blindly can create duplicates. Use an idempotency strategy supported by the sending boundary, or reconcile the application message ID against the provider's records before retrying. If neither is available, record the uncertainty and apply a bounded retry policy rather than claiming exactly-once delivery.
After polling, retain the raw event or a privacy-reviewed digest alongside the normalized outcome. The digest should be enough to connect the observation to the send ledger while respecting retention and access controls. Compliance evidence has a lifecycle: define who can inspect it, how long it is retained, and how deletion requests interact with audit obligations before production launch.
I once reduced a failing investigation to ERR_TIMEOUT in an application log and learned almost nothing from it. A useful record would have included the notice ID, attempt number, provider message ID, cursor position, and the last known suppression decision. The number matters. So does the ordering.
For authentication messages, the notice is also part of an authenticator flow. Code generation, expiry, retry limits, and verification policy stay in the application unless a selected service explicitly owns them. NIST's digital identity guidance is a reasonable source for the policy questions, but it does not turn email delivery into proof of identity by itself.
Where does polling stop being the right tool?
Polling gives a measurable freshness window, not immediate protection. If the worker runs every five minutes, an event can remain unseen for nearly that interval, plus queue and processing time. Choose the interval from the maximum acceptable repeat-send risk, then test it against event volume, provider limits, and worker recovery behavior. I'm not sure a universal interval exists; the right value depends on those constraints.
The design is not suitable when a complaint must trigger an immediate cross-channel action, or when the product requires provider-initiated delivery with a strict latency guarantee. Choose a delivery system with a verified push mechanism for that requirement, and keep polling as reconciliation if the system supports both. Stick with this pull model when a bounded delay is acceptable and the team values a simple, inspectable worker.
Rate limiting belongs in the failure model. A 429 response should pause the worker and honor Retry-After when present; otherwise use capped exponential backoff with jitter. Do not move the cursor on a rate-limit response. A worker that skips a page to stay available has traded a visible retry for invisible compliance evidence loss.
There are quieter boundaries too. Suppression is not list hygiene, consent management, domain authentication, or an unsubscribe policy. Those controls need their own owners and records. The suppression check should protect routine notices, while carefully defined security-critical flows need a separate policy that cannot be invented by an email controller under deadline pressure.
Why reject polling from the request path?
The tempting implementation fetches feedback during a user request, updates the suppression list, and sends the notice in the same handler. It duplicates work under traffic, couples page latency to a remote dependency, and still leaves a gap between the poll and the send. A request retry can then repeat the whole sequence with no clear owner for the evidence.
Keep request-time refresh for an operator diagnostic screen that explicitly asks for a snapshot and does not send mail as a side effect. That is a valid use case. It is a poor protection loop.
The production ownership split is easier to audit: a scheduler invokes the poller, the database owns event identity and suppression state, and the send worker performs the last blocking check. Log the decision, not just the outcome. A blocked send with reason complaint is more useful than a successful HTTP status with no explanation.
References
- Amazon SES official documentation: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- NIST SP 800-63B Digital Identity Guidelines: https://pages.nist.gov/800-63-3/sp800-63b.html
Top comments (0)