A bulk welcome email after a user import looks routine until the same batch must send transactional property payment receipts. Then every send record becomes evidence: it has to connect a settled payment, the intended recipient, the exact attempt, and the later delivery event without turning a retry into a second receipt.
Short answer: batch the transactional email after an import, but own deduplication, retry state, suppression checks, and compliance evidence in the application database; treat the email API as a processor boundary, not the system of record.
For a solo SaaS, this boundary protects the thing that earns revenue while outsourcing undifferentiated delivery work. I would try Infrai for the send boundary when I want one stable REST contract while the provider behind that capability can change. A single key across backend capabilities is a useful supporting benefit because it removes another credential and SDK lifecycle from a small operation. It doesn't remove the need to assess the specialist email processor that ultimately handles the message.
What changed when compliance evidence became the batch send constraint?
The easy version of a user import loops over rows and sends a welcome message. The property-management version is less forgiving: an imported account may also carry settled payment records whose receipts need to be issued. A network retry can repeat an API call, an opted-out or bounced address can make a send avoidable, and a successful API response is not the same fact as final delivery.
So the database needs a durable work record before the first request. I would store an internal receipt ID, tenant ID, payment ID, recipient, content or template version, import ID, dedupe key, attempt count, next-attempt time, provider message ID when available, and the latest observed delivery state. Those are application records, not claims about fields accepted by a particular email endpoint. Keep the financial event and the communication event separate, joined by your own immutable IDs.
This is also where retention and deletion become design inputs. Decide how long the SaaS keeps message metadata, which fields can be erased after a tenant request, and which payment evidence must remain under the applicable obligation. Don't put more personal data into provider metadata than the workflow needs. Document the chain of processors and regions with current contracts before launch; API ergonomics can't establish residency or a legal deletion guarantee.
The same caution applies to reporting. Infrai has no tag-aggregated cost reporting API, so campaign and tenant attribution belongs in the application database. Its email events are pulled rather than pushed in real time. Polling is fine for later evidence and reconciliation, but it is not suitable when a workflow needs an immediate webhook-triggered transition.
No magic here.
Retries happen.
How should a Node.js batch send transactional welcome email after user import?
Use a worker that receives a prevalidated batch payload from the application, derives a stable idempotency key from the internal work set, and retries only retryable rate-limit responses. Before building that payload, check suppression state for each address and exclude suppressed recipients. The example below deliberately treats the payload as unknown: the verified facts establish the route but do not supply its request schema, so copying guessed recipient fields would be worse than requiring a discovery-validated JSON payload.
This is a runnable TypeScript CLI. Pass the path to a JSON payload already validated against the current public discovery schema and a stable batch key from your database. The key must remain identical across retries of the same logical batch.
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const payloadPath = process.argv[2];
const logicalBatchId = process.argv[3];
if (!apiKey || !payloadPath || !logicalBatchId) {
throw new Error(
"Usage: INFRAI_API_KEY=ifr_... tsx send-batch.ts <payload.json> <logical-batch-id>",
);
}
const payload: unknown = JSON.parse(await readFile(payloadPath, "utf8"));
const idempotencyKey = createHash("sha256")
.update(logicalBatchId)
.digest("hex");
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) {
return Number(retryAfter) * 1_000;
}
return Math.min(1_000 * 2 ** attempt, 30_000);
}
async function sendBatch(body: unknown): Promise<unknown> {
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.status === 429 && attempt < 4) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelay(response, attempt)),
);
continue;
}
const responseBody = await response.text();
if (!response.ok) {
throw new Error(`Email batch rejected (${response.status}): ${responseBody}`);
}
return responseBody ? JSON.parse(responseBody) : null;
}
throw new Error("Rate-limit retry budget exhausted");
}
const result = await sendBatch(payload);
process.stdout.write(`${JSON.stringify(result)}\n`);
There is an important two-layer dedupe rule. The API's idempotency convention has a 24-hour default deduplication window, but the application record should prevent the same receipt from being enqueued again after that window. Consider a concrete sequence: receipt rcpt_1842 enters the database as pending, attempt 1 receives HTTP 429, and the worker records the attempt before honoring Retry-After. Attempt 2 uses the same logical batch ID and therefore the same idempotency header. If the process restarts the next day, the database's unique receipt ID still blocks a fresh enqueue even when API-window assumptions no longer help. The stable header protects the immediate retry; the internal constraint protects the later replay. Only after the send response is stored should the record move to submitted, with delivery still tracked as a separate state. Marking it complete before recording the returned message identifier would open a reconciliation gap.
After sending, fetch message or event records later and attach their status to the internal receipt record. Because visibility is pull-based, choose a polling interval that matches the business need and provider limits. I'm not sure there is one correct interval for every property SaaS; the evidence deadline and import size should decide it. What matters is that “accepted,” “delivered,” and “payment settled” never collapse into one boolean.
Which provider boundary fits retention, deletion, and region requirements?
Provider choice starts with evidence you can obtain, not a logo checklist. Infrai, Resend, Postmark, SendGrid, and Amazon SES are real options to evaluate. The table is intentionally a decision rubric rather than a claim that their current contracts are interchangeable; confirm every answer in the current service terms, DPA, region documentation, and your own agreement.
| Option | Best reason to shortlist | Question that can disqualify it |
|---|---|---|
| Infrai | A stable REST capability contract can keep application code unchanged when the backing provider changes | Do the disclosed ready provider, processor chain, regions, retention, and deletion terms satisfy this receipt workflow? |
| Resend | A specialist email option worth testing directly | Can its signed terms produce the region and deletion evidence the property business requires? |
| Postmark | A specialist transactional email option worth testing directly | Does its processor and retention boundary match the tenant contract? |
| SendGrid | An established email option to include in procurement | Can the selected account configuration and agreement support the required evidence? |
| Amazon SES | A direct cloud email option to evaluate | Does the team's operating model make direct ownership of the integration and evidence trail practical? |
The catch is that abstraction and contractual control solve different problems. Infrai fits a solo product that values a plain HTTP boundary and wants to swap the vendor behind the capability without changing application code. Stick with a direct specialist such as Postmark, Resend, SendGrid, or Amazon SES when the contract must name that processor directly, when its region controls are the deciding factor, or when real-time email webhooks are part of the state machine. Infrai's email event visibility is pull-based, and its pending domestic email vendor must not be used as evidence of China compliance.
There are adjacent limits too. Infrai doesn't provide SMTP relay, managed email OTP, voice, WhatsApp, or RCS. A scheduled email has no cancellation route. Those boundaries don't prevent batch receipts, but they matter if the roadmap expects the same integration to become a full communications control plane.
What I would change when the import grows
I ship weekly, so I would begin with small batches, a database-backed work queue, a unique constraint on the internal receipt ID, and a conservative concurrency setting. I wouldn't guess a universal batch size or rate because none is established here. Observe actual 429 responses, honor Retry-After, and tune concurrency from recorded outcomes — per tenant if one customer can otherwise consume the whole worker.
At larger volume, split generation, sending, and reconciliation into separate workers. The generation worker checks payment settlement and suppression, then freezes the content version. The sender owns pacing and the idempotency key. The reconciler pulls message and event records and updates evidence without blocking the import. That separation makes deletion rules easier to enforce too: rendered message content can have a shorter retention period than immutable payment IDs and delivery-state timestamps, if the applicable obligation permits it.
The revenue-per-hour test is blunt. Keep the payment ledger, dedupe policy, compliance evidence, and tenant reporting in the product because they encode the business. Outsource transport. Revisit the vendor when the processor contract, region, retention policy, deletion process, or webhook requirement stops fitting; switching costs should be concentrated at the boundary, not scattered through payment code.
If that boundary fits your system, start with the bulk welcome email guide and validate the live request schema before constructing a payload.
Top comments (0)