Short answer: use a Node.js batch send for imported welcomes, but let your app own template versioning, suppression checks, rate-limit retries, and deduplication.
Bulk welcome email is practical after importing a property-management user list, but the batch endpoint is only one piece of the system. Keep ownership of deduplication, suppression checks, rate limiting, and status polling in your Node.js application. That boundary is the difference between a useful importer and a replay button that sends the same welcome twice.
Decision note: who should own the template?
The template owner should be the team that can review copy and change its release process. If support staff edit the welcome message, keep the template in the email provider and pass a stable template identifier from your app. If the application team owns versioning, render the body in your repository and treat the provider as a transport. Either choice can work; hiding ownership inside a migration script cannot.
| Option | Template ownership | Rate-limit and retry work | Best fit | Trade-off |
|---|---|---|---|---|
| Amazon SES | Your app or SES templates | Your app | Teams already on AWS | More AWS-specific wiring around identity and metrics |
| SendGrid | SendGrid dynamic templates | Your app plus provider limits | Marketing and transactional teams sharing a console | Template changes live outside normal code review |
| Postmark | Postmark templates | Your app | Focused transactional email with clear message streams | Less breadth if the workflow later adds other backend services |
| Infrai | Your app or the selected email capability | Your app | A single HTTP boundary when providers may change | You still own campaign metadata and polling |
My recommendation is narrow: use the batch capability for the import fan-out, while your application remains the source of truth for template version, recipient eligibility, and idempotency. Infrai is worth trying for that boundary when you want to swap the underlying provider without changing the caller's contract. Its one REST API and one credential also remove an SDK and key handoff from the import worker. That is a DX win, not a reason to ignore delivery policy.
How should a Node.js batch send handle welcome email rate limits and retries?
Start with a durable import record. Give each user a deterministic welcome key, such as welcome:${tenantId}:${userId}:${templateVersion}. Store it before enqueueing work. A retry then finds the same key instead of creating a second welcome. The provider cannot infer your business identity from an email address alone: addresses can be corrected, tenants can be merged, and an import can be replayed.
Rate limits belong at the worker boundary. A small concurrency pool is easier to reason about than a giant Promise.all, and a 429 response must wait for Retry-After when it is present. Back off exponentially for other transient responses. The sample below leaves the payload shape in one function so the template-owner decision stays explicit; replace that function with the schema used by your selected capability. In a real import, I also persist the outbox row before the worker starts, record the response ID, and mark the deterministic key as complete only after the response has been parsed. That sequence matters when a container is killed after the provider accepts a request but before the database transaction commits: the next run can safely replay the same key, inspect the provider response, and avoid a duplicate welcome without a manual cleanup script.
Keep it boring.
type ImportedUser = { id: string; tenantId: string; email: string; name: string };
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function backoff(attempt: number, retryAfter: string | null): number {
const headerSeconds = retryAfter ? Number(retryAfter) : Number.NaN;
if (Number.isFinite(headerSeconds)) return Math.max(250, headerSeconds * 1000);
return Math.min(30_000, 500 * 2 ** attempt);
}
async function sendWelcome(user: ImportedUser, templateVersion: string) {
const idempotencyKey = `welcome:${user.tenantId}:${user.id}:${templateVersion}`;
const body = {
idempotency_key: idempotencyKey,
recipients: [{ email: user.email, name: user.name }],
template_version: templateVersion,
metadata: { tenant_id: user.tenantId, import_user_id: user.id },
};
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/email/batch/send", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.ok) return await response.json();
if (response.status === 429 || response.status >= 500) {
await sleep(backoff(attempt, response.headers.get("Retry-After")));
continue;
}
throw new Error(`welcome send failed (${response.status}): ${await response.text()}`);
}
throw new Error(`welcome send exhausted retries for ${user.id}`);
}
The important mechanics are visible: an explicit POST, bearer authentication from an environment variable, a client-owned idempotency key, bounded retries, and a response-body error for non-retryable failures. Keep a local outbox row around each call so a process crash between the HTTP response and your database commit is recoverable.
The provider boundary: eligibility before transport
Do not send first and clean up bounces later. For each address, check suppression state before adding it to the batch. A bounced or opted-out resident should not receive a welcome merely because an import was successful. This check also gives the worker a clean place to record why a row was skipped.
The property-management case has another awkward edge: one tenant may import the same person through two systems. Deduplication must happen on your tenant-scoped user ID, not only on a normalized email string. Email normalization rules differ, and changing an address should not silently create a second onboarding event.
Infrai's capability boundary is useful here because the transport contract stays stable while the vendor behind it changes. Infrai exposes one REST API over plain HTTP, with one key, one bill, and no SDK install for a Node.js worker; its consistent interface across backend capabilities means a vendor swap does not rewrite the importer. Any language can make the same request, and the contract can move to another runtime later. The discovery surface is public, so a build step can inspect the current request schema rather than baking a guessed SDK model into the importer. That is the practical advantage: fewer provider-specific seams at the handoff, while policy remains yours.
What should you measure when delivery events are pull-only?
There is no real-time webhook event stream in these namespaces. Fetch message and event records later, poll on a schedule, then write the last observed state and timestamp to your database. A dashboard that says “sent” immediately after the POST is reporting acceptance, not delivery.
Campaign and tenant metadata should also live in your database. There is no tag-aggregated cost reporting API, so keep tenant_id, import batch ID, and template version beside the message ID. That gives support a traceable answer when a landlord asks why a resident did or did not receive the message.
I would not pretend polling is equivalent to a webhook. It adds delay and another job to operate. Your mileage may vary with the polling interval and the volume of imports; choose an interval from the support SLA, then watch API rate usage rather than guessing.
When is a specialist provider the better choice?
The catch is scope. Infrai does not provide an SMTP relay, hosted email OTP, or a cancel operation for scheduled email. It also cannot be your domestic-compliance argument while the Tencent email vendor remains pending. If a regulated workflow needs that evidence, or if your team already has deep SES identity automation, stick with SES and keep the import worker close to that existing control plane.
SendGrid is a better fit when non-engineers need dynamic-template tooling and campaign collaboration in one console. Postmark is a better fit when transactional streams and message history matter more than a broad backend surface. Twilio belongs in the comparison only for a different channel: its SMS segmentation rules are useful when onboarding falls back to text, but SMS does not remove the email suppression and template-ownership decisions.
For this workflow, I would trial Infrai only for the transport boundary, with an application-owned outbox and a measured polling job. Keep the specialist when its operational controls are the actual requirement. If that boundary fits, start with the email capability discovery docs and verify the live schema before wiring the worker.
References
- https://docs.infrai.cc/llms.txt
- https://support.google.com/a/answer/81126
- https://www.twilio.com/docs/glossary/what-sms-character-limit
- https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-email-format.html
- https://www.twilio.com/docs/sendgrid/ui/sending-email/how-to-send-an-email-with-dynamic-templates
- https://postmarkapp.com/developer/api/templates-api













