DEV Community

LunarBreeze4173085
LunarBreeze4173085

Posted on

Node.js App HTML vs Provider API — Own 2-Region Password Reset Email Deliverability

Short answer: for a B2B SaaS signup flow spanning the US and EU, keep verification-link templates and their versions in the application, while delegating DKIM signing, suppression enforcement, bounce collection, and final delivery to an email API provider; choose provider-owned templates only when non-engineers must publish copy without an application release.

That split makes the awkward boundary visible. The application owns what the message means, which tenant and locale it belongs to, and which verification policy produced the link. The delivery layer owns mailbox-facing mechanics and recipient history. Combining those concerns in a hosted template editor looks tidy until a deployment, a regional failover, and a copy edit produce three different answers to the question, "what did we send?"

For this workload, deliverability isn't a single score. It is a chain of evidence: authenticated domain, stable message construction, enforceable suppression, observable delivery events, and a reset flow that remains secure when an email arrives late or twice.

Start with the signup constraint, not the sending feature

A verification message is transactional, but that label doesn't make it privileged. The receiving system still evaluates authentication and sender behavior, and Google explicitly requires sender authentication; its published guidance also distinguishes requirements for all senders from the additional requirements applied to bulk senders. A custom domain therefore belongs in the initial design, not in a post-launch branding ticket. The exact DNS records and policy depend on the sending arrangement, but the acceptance test is plain: a message sent through each production path must authenticate as the domain the recipient sees.

There are two ownership records to preserve. First, the application needs an immutable template version tied to the signup event. Second, the delivery system needs the recipient's current eligibility to receive mail. Those records have different consistency needs. Template content changes through review and deployment; a hard-bounce or complaint decision must affect the next send, even when that next send comes from another region. Treating both as mutable fields in one provider dashboard makes audit and failover harder because the application can no longer reconstruct the decision from its own event log.

The link itself should carry a single-use, expiring credential rather than account state, and the database should decide whether redemption is valid. Email can be delayed, duplicated, or forwarded. That is normal transport behavior, so correctness can't depend on exactly-once delivery. Store a request identifier, tenant identifier, template version, locale, credential expiry, and delivery correlation identifier; never put a reusable secret in logs or provider metadata. One boundary matters more than it first appears: accepting a signup and accepting an email-send request are separate events. Put an outbox record in the same database transaction as the signup state, then let a worker claim and send it. If the API call times out, the worker can retry with the same idempotency identity or reconcile by correlation identifier, according to the provider's documented contract. Don't generate a new verification credential merely because transport status is uncertain.

Small distinction. Large consequence.

How should a custom domain handle DKIM, suppression, bounce events, and an API provider?

Use one authenticated sending identity per operational boundary that you genuinely intend to isolate, then route every send through a policy check before the API call. "Operational boundary" might mean production versus staging, or a separately operated region; it should not automatically mean one domain per tenant. More domains create more DNS, monitoring, rotation, and reputation work. Fewer domains enlarge the blast radius. The right number follows ownership and incident response, not a dashboard's ability to create identities.

DKIM proves that selected headers and the body were signed for a domain; it does not prove that your application chose the right recipient, template, or tenant. SPF and DMARC belong in the same authentication review because mailbox providers evaluate the whole sending identity. Google recommends SPF or DKIM for all senders to Gmail accounts and calls for SPF, DKIM, and DMARC for bulk senders. Read the current sender guidance before setting policy because requirements can change, and verify the actual message headers rather than treating a DNS control panel's green badge as end-to-end evidence.

Suppression should be a pre-send decision with a durable reason, source event, and timestamp. A recipient suppressed after a permanent bounce or complaint must remain suppressed across worker restarts and regional routing changes. Keep that state in a shared policy store or consume provider events into a replicated internal projection; either way, define which side is authoritative and how quickly the other side converges. If a request races with a new suppression event, the conservative rule is to avoid another automated send until the state is reconciled.

Bounce handling needs classification, not a counter named failed. Preserve the provider's original event category and correlation identifier, then map it into a small internal vocabulary such as delivered, transient failure, permanent failure, complaint, or unknown. Retry only categories the provider documents as temporary, apply bounded backoff, and stop at the credential's useful lifetime. A verification email delivered after its link expires is operationally successful and functionally useless — both facts should appear in telemetry.

The catch is that a shared suppression projection adds data ownership and regional replication work. It is not suitable when a team can't operate that state or interpret asynchronous events; in that case, keep provider-native suppression as the authority and pin a sender identity to one delivery account until the team can test a migration. Conversely, stick with an application-owned projection when multiple delivery accounts or providers must enforce the same recipient decision. That flexibility isn't free.

Template ownership changes the failure modes

The comparison is less about editor preference than release semantics.

Decision Application-owned HTML Provider-owned template
Version tied to signup event Natural: store a source revision or content digest Requires storing and resolving the provider template version
Copy release Follows application review and deployment Can be independent of application deployment
Regional or provider move Rendered payload can stay stable Template assets and identifiers must be synchronized or migrated
Non-engineer editing Usually needs a repository workflow or CMS Often the reason to choose this model
Failure boundary Rendering can block before submission Missing or mismatched remote versions can block submission

For a security-sensitive verification link, application-owned HTML is the cleaner default because the code that creates the credential also selects and records the content version. It doesn't eliminate delivery-provider work. The provider still has to sign mail correctly, enforce its suppression state, accept events, and expose enough correlation data to connect a submission with its outcome. It also doesn't excuse poor rendering tests: produce fixtures for every locale, check the plain-text alternative, cap user-controlled fields, and inspect a real delivered message through each production sending identity.

Provider-owned templates are the better choice when copy operators need an independent publishing path and the organization can impose version discipline on that path. "Latest" is not a versioning strategy. The send record should resolve to an immutable published version, and promotion between US and EU delivery accounts should verify that the same logical version exists before traffic moves. If the platform can't provide immutable versions, an application-side content snapshot or digest can preserve audit evidence, but it won't make rollback atomic across two remote accounts.

The code boundary can stay boring. This Python sketch deliberately leaves transport and storage behind interfaces; the important part is the order of decisions and the stable identifiers, not an invented endpoint:

from dataclasses import dataclass
from datetime import datetime


@dataclass(frozen=True)
class VerificationEmail:
    request_id: str
    tenant_id: str
    recipient: str
    template_version: str
    locale: str
    expires_at: datetime


def submit_verification(message, suppression_store, renderer, transport):
    suppression = suppression_store.lookup(message.recipient)
    if suppression is not None:
        return {"status": "suppressed", "reason": suppression.reason}

    rendered = renderer.render(
        version=message.template_version,
        locale=message.locale,
        tenant_id=message.tenant_id,
    )
    return transport.send(
        idempotency_key=message.request_id,
        recipient=message.recipient,
        subject=rendered.subject,
        html=rendered.html,
        text=rendered.text,
        expires_at=message.expires_at,
    )
Enter fullscreen mode Exit fullscreen mode

The return value should be persisted beside the outbox record, while webhook or event-stream updates append later observations rather than overwriting history. A delivered event does not mean the account was verified, and a verified account does not prove which delivery event caused it. Keep those state machines separate. It saves ugly reasoning during an abuse review.

Roll out the boundary with evidence

Start with one domain and one low-risk internal tenant. Publish the required authentication records, send through the real production path, and inspect authentication results at the recipient. Then exercise duplicate submissions, an API timeout with an uncertain result, an already-suppressed address, a transient failure that outlives the verification credential, and an event delivered out of order. The rollout gate should compare state transitions and correlation coverage, not just inbox placement from a few test accounts.

For two-region operation, shadow the template catalog and suppression projection before moving live traffic. Compare content digests, verify event deduplication, and confirm that a suppression learned in one region prevents a later request in the other. Move a small, defined cohort, retain a rollback route, and don't rotate domain or template ownership during the same change. Too many simultaneous variables make a deliverability regression almost impossible to attribute.

SMS can be a separate recovery channel, but it should not silently inherit email assumptions. SMS length and segment count depend on character encoding; Twilio's overview documents the GSM-7 and UCS-2 distinction and the lower per-segment limit for concatenated messages. That makes localization and link length operational inputs, even though the account-verification policy should remain channel-independent. Use SMS only after defining its own consent, abuse, expiry, and observability rules.

No provider choice removes these obligations. Select an API provider only after a trial demonstrates authenticated custom-domain mail, deterministic template version selection, suppression behavior you can test, documented bounce categories, durable event correlation, and a workable US/EU data path. Then rehearse migration: export the state you own, recreate sending identities, warm the new path cautiously, and keep the application contract stable. The choice is defensible when another engineer can reconstruct why a recipient was eligible, what content version was sent, how the domain authenticated, and what happened next.

Sources

Top comments (1)

Collapse
 
xin_tian_a0a3d6e12aff92d4 profile image
Xin Tian

I’ve run into a similar problem while building a browser-based flipbook application. PDF conversion and page rendering are often asynchronous, so treating “job submitted” and “content successfully generated” as the same state can make retries and failures surprisingly hard to reason about. I’ve found that keeping a stable job ID and recording each state transition makes debugging and recovery much easier, especially when a user refreshes or submits the same document twice.

How do you decide which state should be owned by the application versus the external provider when a job can be retried or processed in multiple regions?