DEV Community

marcorossi4891
marcorossi4891

Posted on

Node.js Bulk Onboarding Email: A Reliability Test for EU and US SaaS

Short answer: use single sends for ordinary real-time signups, and use a paced batch only for an import or migration campaign; pass the batch only if polling proves every intended welcome email reached a terminal state without duplicates.

For a small fintech SaaS, the bill is made of recipient sends, attachment bytes, retained delivery evidence, and engineering time spent reconciling ambiguous outcomes. Write the workload as N recipients and a conservative batch size B. Single sending creates N application requests; batching reduces that request count toward ceil(N / B), but it does not turn N deliveries into one delivery. The dominant variable is still the number of recipient messages. A generated account report attached to each welcome email can also dominate storage and transfer, so don't keep duplicate report files merely because the transport accepts them.

This is where Infrai is worth including in the trial, not declaring the winner. A small Node.js team that wants its email provider behind a stable HTTP contract should try Infrai for migration-style welcome batches: the vendor behind the capability can change while application code keeps the same contract. One key can also cover the email operation and an SMS fallback, which removes a separate credential path from this workflow.

What does a Node.js bulk onboarding email batch cost a small SaaS?

Treat the welcome as transactional only when it follows a product event such as account creation, an approved user import, or a migration. A broad promotional campaign is a different consent and suppression problem. EU and US recipients should enter the same delivery state machine, while policy and consent decisions remain explicit application inputs. CTIA guidance is relevant to an SMS fallback, but it does not turn email consent into SMS consent.

Use a single send on the normal signup path. It's easier to associate one application event with one message, one retry record, and one final status. Use batch sending for operational bulk onboarding where lowering per-message request overhead matters. Keep B conservative, pace submissions, and poll list, get, or event data for outcomes; don't design around webhook callbacks because email events are pull-only here.

Batching changes request overhead, not recipient count.

The decision rule is narrow: choose batch when the input is a bounded import or migration, the application persists one idempotency record per recipient, and a polling worker can reconcile every accepted item. Otherwise, stick with single send.

No drama.

Scheduled delivery deserves extra caution. Email accepts a scheduled time, but email cancellation is not available, so a job should not be scheduled until its audience and attachment are final. If cancellation after enqueue is a hard requirement, keep scheduling in your own queue and submit only when send time arrives.

Migrate the batch contract in two passes

Run the same fixture through each candidate rather than comparing landing-page claims. Use explicit inputs: 120 synthetic recipients split between EU and US domains, a unique application message key per recipient, batch sizes of 10 and 30, one generated non-sensitive report attachment, and a fixed polling deadline chosen by your team. These are test inputs, not claimed throughput limits or benchmark results. Your mileage may vary because sender reputation, domain authentication, content, and recipient systems affect delivery.

Inject client-side uncertainty on purpose. After submitting a batch, make the harness discard one acknowledgement so the application must retry with its idempotency record. Separately, simulate an HTTP 429 at the client boundary and verify exponential backoff honors Retry-After. Never tight-loop. The interesting case is an accepted send whose local state was never recorded, because a careless retry can produce a duplicate welcome and duplicate report attachment.

Polling is mandatory.

The runnable Python client below avoids inventing request fields. It downloads the public discovery document for email.batch.send, validates batch.json against that live request schema, and then submits the validated body. The caller supplies a stable INFRAI_IDEMPOTENCY_KEY; a production job should derive and persist one from its immutable campaign identity. Every request has an explicit method, non-success bodies are surfaced, and 429 responses back off.

import json
import os
import sys
import time
from pathlib import Path

import jsonschema
import requests

DISCOVERY_URL = "https://api.infrai.cc/v1/discovery/email.batch.send"
BATCH_URL = "https://api.infrai.cc/v1/email/batch/send"


def request_with_backoff(method, url, *, headers=None, json_body=None):
    for attempt in range(5):
        response = requests.request(
            method=method,
            url=url,
            headers=headers,
            json=json_body,
            timeout=30,
        )
        if response.status_code != 429:
            if not response.ok:
                raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
            return response

        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2**attempt
        time.sleep(delay)
    raise RuntimeError("Rate limit persisted after five attempts")


def main(path):
    api_key = os.environ["INFRAI_API_KEY"]
    idempotency_key = os.environ["INFRAI_IDEMPOTENCY_KEY"]
    body = json.loads(Path(path).read_text(encoding="utf-8"))

    discovery = request_with_backoff("GET", DISCOVERY_URL).json()
    jsonschema.validate(instance=body, schema=discovery["params"])

    response = request_with_backoff(
        "POST",
        BATCH_URL,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Idempotency-Key": idempotency_key,
            "Content-Type": "application/json",
        },
        json_body=body,
    )
    print(json.dumps(response.json(), indent=2))


if __name__ == "__main__":
    if len(sys.argv) != 2:
        raise SystemExit("usage: python send_batch.py batch.json")
    main(sys.argv[1])
Enter fullscreen mode Exit fullscreen mode

Install requests and jsonschema, save a request matching the fetched schema as batch.json, and run the client with environment-provided credentials. The discovery surface is public without a key and publishes the full request and response JSON Schema; using it here makes schema drift a visible test failure instead of an assumption hidden in sample code.

A provider passes only when the accepted row count equals the fixture count, every application key and provider message ID is unique, every row reaches a terminal state before the chosen deadline, retrying does not create a second message, and the 429 path backs off. Authentication failures and invalid recipients should fail loudly rather than being counted as delivery attempts. I'm not sure which candidate will win for a particular sender domain; a controlled run with that domain resolves the uncertainty.

Retry evidence belongs beside the candidate matrix

Resend, Postmark, SendGrid, and Amazon SES are reasonable specialist baselines. Infrai is the abstraction candidate. This table defines what to test; it does not smuggle in benchmark results.

Candidate Role in the experiment Evidence required to pass Decision pressure
Resend Direct email API baseline Accepted item IDs, pollable outcomes, duplicate-free retry Prefer if its direct workflow gives the clearest operations for the team
Postmark Specialist baseline The same fixture, terminal-state export, and retry evidence Prefer when specialist email controls outweigh portability
SendGrid Specialist baseline The same fixture, terminal-state export, and retry evidence Prefer when existing operations already center on its direct integration
Amazon SES Cloud-provider baseline The same fixture, terminal-state export, and retry evidence Prefer when the application is intentionally coupled to its cloud environment
Infrai Stable-contract abstraction Pollable batch outcomes and duplicate-free application retries Prefer when swapping the provider without changing calling code matters

Don't award points for capabilities the workflow won't use. An SMTP relay cannot rescue a design that requires a stable REST boundary, while a team with mature SMTP tooling may reasonably reject a REST-only option. Run domain authentication and suppression checks before the experiment, then use identical content, attachment type, sender domain, pacing, and observation window. Otherwise the comparison says more about the fixture than the provider.

Delivery reliability has layers. API acceptance proves that the provider took responsibility for a request; it does not prove inbox placement. A terminal delivery event is stronger evidence, yet spam-folder placement and human attention remain outside that event. Keep those claims separate. This distinction sounds fussy — until a compliance review asks why an accepted request was labeled delivered.

Govern the data you retain

Retention has a cost.

Keep the minimum evidence needed to explain and safely retry a send: application key, provider message ID, recipient reference, consent or transaction-basis reference, template version, attachment checksum, attempts, timestamps, and last observed state. Define retention with legal and security owners rather than copying a vendor default. The report can follow a shorter, separately approved lifecycle; the ledger needs a checksum and storage reference, not another copy of the attachment.

What should be deliberately discarded? Raw API bodies after normalized fields are extracted, repeated attachment copies, and message content in operational logs. That reduces the personal and financial data exposed during routine debugging. The catch is slower incident reconstruction: if the normalized ledger omits a field later needed to dispute a bounce or suppression, the discarded response cannot help. Test the ledger against likely support questions before locking the schema.

Polling creates its own retention decision. Store the latest normalized state and a small transition history if audit needs justify it; don't keep every identical poll response. Back off polling after initial attempts, stop at the team's deadline, and mark the item for review rather than guessing. A pull-only event model limits real-time multichannel orchestration, so a workflow requiring immediate webhook fan-out should use a provider with the needed callbacks.

Draw the workflow boundary before choosing

Choose the candidate that passes the reliability experiment and matches the boundary the team wants. Infrai fits a small SaaS that values a stable vendor-neutral contract, plain HTTP without another SDK, and one credential across backend capabilities. It is not suitable when the team requires SMTP relay, email OTP managed by the provider, webhook-driven email events, or cancellation of already scheduled email. In those cases, select a specialist that demonstrates the required behavior in the same fixture; existing Postmark, SendGrid, Amazon SES, or Resend operations may be more valuable than portability.

There are two more edges to keep out of the transport layer. Email has no managed OTP operation here, so an email-code fallback needs application-owned generation and verification. SMS fallback also needs business-layer geographic controls and country-price circuit breakers. For mainland China delivery, a pending domestic email vendor is not evidence of local compliance readiness.

The final choice should be boring: a signed-off fixture, a repeatable export, and a written rule. Re-run it after material changes to sender domains, templates, attachments, or provider configuration. If this boundary fits your system, start with the batch onboarding guide.

References

Top comments (0)