DEV Community

ValorD33
ValorD33

Posted on

Transactional Email Delivery Status with a Cron Reconciliation Loop

Short answer: poll transactional email delivery status from a durable queue with a cron-triggered worker, exponential backoff, and a hard observation deadline; use a webhook when freshness or volume makes repeated API reads too expensive. A Node.js cron job can wake the loop, but it should not own message state.

That distinction matters for welcome email. A successful submission only proves that an email service accepted a request. It does not prove delivery, and it definitely does not prove that a person read the message. The provider's message ID is the correlation key; everything else belongs in your own state machine.

Why does a cron job need a delivery state machine?

Cron is a clock, not a work queue. Two replicas can wake in the same minute, a deploy can interrupt a process after the lookup returns, and a slow API can run past the next tick. If the schedule lives only in memory, those ordinary events create duplicate reads and missing updates. The durable record has to carry the timing decision across restarts, which is why I treat the scheduler as a nudge and the database as the authority. That choice also makes a hand-run recovery job use the same claim and lease rules as the normal cron process, so an operator is not bypassing safeguards during an incident.

Store one row per submitted message with an internal ID, provider message ID, normalized state, provider state, attempt count, next_check_at, an observation deadline, lease owner, lease expiry, and last_observed_at. Keep the recipient as a privacy-safe hash or tokenized reference. Delivery metadata can still identify a person, so access and retention deserve the same care as application logs.

Use a monotonic state model for normal processing: submitted can become delivered, failed, or expired; terminal rows leave the schedule. Preserve the raw provider value beside the normalized state. I don't assume that labels such as sent, queued, and accepted mean the same thing across APIs, so the mapping belongs in a tested adapter. Your mileage may vary for suppressed or delayed messages.

Keep the loop boring.

Do not make a polling timeout trigger another send. An expired observation means that the system stopped seeing evidence before its deadline. In an OTP flow, an automatic resend can leave two valid-looking codes in flight. Submission retries and delivery observation are different controls.

How should Node.js poll email events without a webhook?

The worker's transaction should claim a small due batch, set a lease, and commit before any network call. The API request happens outside that transaction. On save, require the same lease owner and refuse to overwrite a terminal row. That guard makes overlapping cron invocations harmless.

The first check should be soon after submission, then the delay can grow with capped exponential backoff and jitter. A 30-second starting delay, a 15-minute cap, and a 24-hour observation window are example policy values, not universal truth. Tune them from due-row lag, checks per message, and time-to-terminal-state metrics. Honor a provider's Retry-After value when one is supplied, and cap concurrency locally even if a batch contains thousands of rows.

Here is a compact adapter. The interface is intentionally generic; substitute the event lookup supported by the service you use. The surrounding repository methods are where the database lease and compare-and-set rules live.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import os
import random
import requests

TERMINAL = {"delivered", "failed", "expired"}

@dataclass
class PendingEmail:
    id: str
    provider_id: str
    attempt: int
    deadline: datetime

def normalize(provider_state: str) -> str:
    return {
        "delivered": "delivered",
        "bounced": "failed",
        "rejected": "failed",
        "queued": "submitted",
        "sent": "submitted",
    }.get(provider_state, "submitted")

def next_delay(attempt: int) -> timedelta:
    base = min(900, 30 * (2 ** min(attempt, 5)))
    return timedelta(seconds=base + random.randint(0, 20))

def observe(email: PendingEmail, api_base: str) -> dict:
    now = datetime.now(timezone.utc)
    if now >= email.deadline:
        return {"state": "expired", "next_check_at": None}

    response = requests.get(
        f"{api_base}/email-events/{email.provider_id}",
        headers={"Authorization": f"Bearer {os.environ['EMAIL_API_KEY']}"},
        timeout=10,
    )
    response.raise_for_status()
    payload = response.json()
    provider_state = payload["status"]
    state = normalize(provider_state)
    return {
        "state": state,
        "provider_state": provider_state,
        "observed_at": now.isoformat(),
        "next_check_at": None if state in TERMINAL else (now + next_delay(email.attempt)).isoformat(),
    }

def run_once(repository, api_base: str, worker_id: str) -> None:
    for email in repository.claim_due(limit=100, owner=worker_id, lease_seconds=60):
        try:
            result = observe(email, api_base)
            repository.save_if_owned(email.id, worker_id, result)
        except requests.RequestException:
            repository.reschedule_if_owned(email.id, worker_id, next_delay(email.attempt))
Enter fullscreen mode Exit fullscreen mode

The Node.js cron process would call the equivalent run_once boundary; it should not contain a second copy of the transition rules. Validate the response shape before indexing it, and classify failures. A malformed request or an authentication response should stop and alert on that row. A timeout or a quota response should be rescheduled with backoff. Never let an unknown status silently become a success. In a larger system I also keep an append-only observation record, with the response class and timestamp, instead of overwriting the last raw payload. That gives support engineers a timeline when an event arrives late, and it lets a retention job remove recipient-linked fields without destroying aggregate latency metrics. The extra write is deliberate: status pages are derived views, while the observation log is the evidence used to explain a disputed delivery.

What trade-offs separate polling, webhooks, and email events API designs?

Polling fits moderate volume, a tolerance of minutes for freshness, and environments where inbound endpoints are prohibited. Its catch is request amplification: every still-pending message consumes another lookup. At high volume, that amplification can hit quotas before it improves the product.

Webhooks reduce repeated reads and can make a status view feel immediate, but they move complexity to signature verification, replay defense, durable receipt, retries, and out-of-order events. A hybrid keeps webhooks as the live path and runs a slow reconciliation poll for messages that never receive an expected terminal event.

Design Good fit Cost to operate Failure to model
Polling Moderate volume and relaxed freshness Leases, scheduling, API quotas Duplicate workers and expired observations
Webhooks High volume or low event latency Authenticated endpoint and replay handling Duplicate, delayed, or out-of-order events
Hybrid Important events plus reconciliation Two ingestion paths and deduplication Conflicting observations

Stick with polling when a public endpoint would be a larger risk than a few minutes of delay. Move primary ingestion to webhooks when support or security workflows depend on near-real-time state and the team can operate an authenticated inbox. The right answer is a boundary, not a slogan.

Labels aren't proof.

Which operational checks keep delivery status trustworthy?

Measure due-row lag, lease age, claim batch size, lookup latency, response class, attempts per message, time to terminal state, and the count that expires without a terminal observation. Alert on a growing due queue and abandoned leases before paging on one failed lookup. Those signals distinguish a worker problem from a mailbox provider that is simply slow to report.

Test the awkward transitions: two cron runs claiming the same row, process death after a response but before save, an unknown provider state, a late terminal event, a 429, and clock skew. Redact authorization headers and recipient identifiers. A kill switch should stop new claims without stopping sends, because the observer must never become the sender's control plane.

Compliance still applies while you measure transport. The FTC's CAN-SPAM guide calls for accurate header information, nondeceptive subject lines, a valid physical postal address, and a working opt-out mechanism for covered commercial messages; opt-out requests must be honored within 10 business days. The primary purpose of a mixed message affects its classification, so involve counsel rather than encoding a legal conclusion in a status enum.

A small rollout plan for a cron observer

Start in shadow mode. Record the provider message ID on the existing submission path, let the worker poll, and expose normalized state only to internal tooling. Sample results against logs and support evidence. This catches mapping, retention, and lease mistakes while the worker is still an observer.

Roll out by cohort: internal addresses, a small slice of welcome email, then the full transactional stream. Set gates for due-row lag, checks per message, explainable expiry, and lease expiry. Keep the observation deadline explicit so old rows cannot consume API calls forever.

The limitation is plain: if required freshness or lookup traffic outgrows the team's ability to tune quotas and leases, polling is no longer the primary design. Use authenticated webhooks and retain scheduled reconciliation where a missing event has a measurable support, security, or audit cost.

References

Top comments (0)