DEV Community

mT41Gzp73rc6
mT41Gzp73rc6

Posted on

Node.js Contact-Form Routing: API-Poll Transactional Email Before SMS Fallback

Short answer: route the B2B SaaS contact form to a durable support queue first, send a transactional email from that queue, and let a persisted polling state machine authorize an SMS fallback only when the email remains unresolved. The important implementation choice is owning the decision state, not choosing a prettier API client.

This pattern is useful when integration effort is the primary constraint. A small team can keep the transport adapters thin, while the application owns consent, urgency, queue routing, retries, and the meaning of “handled.” That separation also prevents a provider response from being mistaken for customer delivery.

How should a Node.js event-notification service poll transactional email delivery status before an SMS fallback?

Begin with the event, not the message. A contact form submission should receive an immutable event ID and a support-queue decision before any email request is made. Store the tenant, queue, recipient policy, urgency, and a redacted copy of the routing decision. Then create a notification attempt with its own ID. One business event may have an email attempt and, later, an SMS attempt; those are not interchangeable records.

The state machine can be small:

queued -> email_submitted -> email_observed -> resolved

If the observation stays nonterminal past the policy deadline, the application may move the event to sms_eligible. A worker must claim that transition atomically before sending the text. This is the line that prevents two overlapping pollers from turning one unanswered form into two SMS messages.

Keep the timing explicit. next_poll_at, poll_count, last_provider_status, and fallback_claimed_at belong in durable storage. A process restart should delay work at most according to the stored schedule; it should not erase whether the fallback was already claimed. In Node.js, that usually means a queue consumer and a scheduled poll job share a database transaction or a compare-and-set update. The exact library is less important than the atomic transition.

Do not equate “accepted by the email API” with “delivered.” Accepted means the remote system received the request. Delivery status is a later observation, and some statuses can remain unknown. The product copy should promise that the support queue has received the form, not that a person has read an email within a specific number of seconds.

Three words: accepted is not delivered.

Keep contact-form routing separate from channel delivery

The routing decision should be deterministic and inspectable. For example, a billing keyword can select the billing queue, an enterprise tenant can select a named account queue, and an otherwise valid submission can select general support. That logic needs tests with overlapping keywords, missing tenant metadata, and a sender who is not permitted to choose a privileged queue.

The delivery worker then consumes a queue assignment. It should not reinterpret the form and silently send to a different team because an email template happened to have a different default. If routing and delivery are coupled, a template change can become an incident-routing change without passing the same review.

Use an application-owned envelope similar to this one:

from dataclasses import dataclass
from datetime import datetime
from typing import Literal


Channel = Literal["email", "sms"]


@dataclass(frozen=True)
class NotificationAttempt:
    event_id: str
    attempt_id: str
    queue: str
    channel: Channel
    recipient: str
    idempotency_key: str
    submitted_at: datetime | None = None
    observed_status: str | None = None


def choose_queue(subject: str, tenant_tier: str) -> str:
    normalized = subject.casefold()
    if "invoice" in normalized or "billing" in normalized:
        return "billing"
    if tenant_tier == "enterprise":
        return "enterprise-support"
    return "general-support"


def fallback_is_allowed(
    *,
    email_status: str,
    now: datetime,
    deadline: datetime,
    sms_opt_in: bool,
    claimed_at: datetime | None,
) -> bool:
    terminal_email_states = {"delivered", "opened", "rejected", "suppressed"}
    return (
        email_status not in terminal_email_states
        and now >= deadline
        and sms_opt_in
        and claimed_at is None
    )
Enter fullscreen mode Exit fullscreen mode

This is the policy edge, not a transport SDK. The real service should map the email adapter's documented status values into an internal vocabulary before calling fallback_is_allowed. Unknown values should remain visible and nonterminal until an explicit policy handles them; silently treating an unfamiliar status as delivered is a poor failure mode.

Idempotency needs two layers. Use a stable key for the outbound attempt so a retry after a network timeout can be reconciled, and use a unique database constraint on the claimed SMS attempt so a second worker cannot create a new one. The first protects the remote boundary. The second protects your own workflow.

What does a delivery-status polling implementation need to handle?

Polling is a scheduling problem with an HTTP boundary. Each run should load a bounded batch, retrieve status, write the observation, and schedule the next check. Honor Retry-After on a 429; otherwise use capped exponential backoff with jitter. A poller that retries every record immediately can amplify a rate-limit response into a queue outage.

The worker should also distinguish these cases:

  • The send request timed out before the application learned whether it was accepted. Reconcile using the stable attempt key before submitting another attempt.
  • The status is nonterminal. Persist it and schedule another observation; do not send SMS merely because one poll returned no useful detail.
  • The message is rejected or suppressed. Apply the fallback policy, but recheck consent and the recipient's current channel preference at that moment.
  • The status endpoint is rate-limited. Preserve the attempt state, honor the server's delay, and keep other tenants from being starved by one noisy batch.

Operational metrics should describe decisions, not only requests: time from event creation to queue assignment, time to email submission, age of nonterminal attempts, fallback claims, duplicate-claim conflicts, and opt-out suppression. Log event IDs and attempt IDs, but avoid logging full form contents, one-time codes, email addresses, or phone numbers. A contact form often contains more personal data than its schema suggests.

For SMS used in an authentication flow, do not improvise a second factor policy inside the notification worker. NIST SP 800-63B describes requirements and limitations around authenticators; use it to define the security policy, then keep code generation, expiry, verification, and abuse controls in the appropriate application component. A transactional support alert and an OTP are different products even if both travel over SMS.

Email deliverability is a separate control plane. Authenticate the sending domain and publish a DMARC policy before treating the email path as dependable. DMARC gives domain owners a mechanism to express handling preferences for messages that fail authentication checks; it does not guarantee inbox placement. The routing worker should therefore retain an honest fallback policy instead of promising that domain authentication eliminates delivery uncertainty.

Which integration trade-offs matter for this fallback design?

Compare implementations against the boundary your team will operate. A simple acceptance test should cover one contact-form fixture for each support queue, a duplicate submission, an invalid recipient, a rate-limit response, a delayed terminal status, an opt-out change, and a worker restart after remote acceptance. Record the effort to make those tests pass, not just the time to send a happy-path message.

Integration shape Useful when Trade-off to accept
One messaging surface for both channels A small team values one adapter and a shared operational vocabulary Channel-specific controls and regional availability still need separate validation
Separate email and SMS adapters Each channel needs specialized tooling or independent regional contracts The application owns more credential rotation, status normalization, and failure joining
SMTP plus a messaging API Existing email infrastructure and SMTP compatibility are hard requirements SMTP acceptance and later delivery observation may have different identifiers and workflows
Application-owned queue with thin HTTP clients Integration effort and testability matter more than provider-specific abstractions The team must maintain the state machine, polling schedule, and audit trail

The catch is that consolidation does not remove the hard parts. A single credential cannot decide whether a tenant consented to SMS, whether a contact form contains sensitive data, or whether an unanswered email is evidence that a human missed the queue. Choose separate channel specialists when their compliance controls, regions, or delivery evidence are requirements. Choose a push-event design when the business truly needs reaction within seconds; polling always introduces an observation interval.

This pattern is not suitable when the team cannot operate a durable queue and audit log, when SMS consent is unavailable, or when the product requires guaranteed human acknowledgement rather than transport status. In those cases, keep the support queue as the source of truth and add an operator workflow instead of pretending that another channel solves the acknowledgement problem.

Roll out the fallback in small, observable steps

Ship routing and email submission first. For a week of representative traffic, record the state transitions without enabling SMS. Review how often statuses remain nonterminal, how often the wrong queue would have been selected, and how much personal data appears in logs.

Then enable SMS eligibility for a narrow tenant cohort and a low-risk notification class. Make the claim visible in an audit view. Test two workers reaching the same deadline, a consent change between polling and fallback, a timeout after submission, and a scheduler restart. The expected result is one event, one queue assignment, and at most one claimed fallback attempt.

Keep cancellation semantics honest. Canceling a queued application job can prevent a transport request; it cannot necessarily recall a message already accepted by a remote channel. Show the user which action was prevented and which message, if any, had already crossed the submission boundary.

The implementation is done when the state can explain itself after a restart. That is the standard I use for integration effort: a thin client is useful, but a thin client attached to an ambiguous state machine is just deferred work.

References

Top comments (0)