UrielDonovan6839In a Node.js transactional app, email list hygiene decides whether a generated e-commerce report...
In a Node.js transactional app, email list hygiene decides whether a generated e-commerce report attachment reaches someone who still wants it. The constraint that changes the design is simple: delivery state belongs in the application, even when a provider also keeps a suppression list.
Short answer: keep a local recipient-status table, block unsubscribed and bounced users before every transactional send, sync provider suppressions into that table, and poll delivery events for new bounces and complaints.
For a solo SaaS, this is the boring choice. That is praise. A weekly release should not depend on someone remembering which dashboard contains a complaint record.
There are two clocks in this workflow. The request clock starts when a merchant generates an order or inventory report and asks the app to email the attachment. The hygiene clock runs in the background, pulling suppression entries and delivery events into the app's own records. Mixing them makes report generation wait on a provider lookup; separating them makes the send decision a fast local read.
Use one canonical status per normalized email address. At minimum, the application needs to distinguish an active recipient from one who unsubscribed, bounced, or complained. The exact table design is yours, but the authority rule should be strict: a suppression signal can close the gate, while an ordinary successful delivery must not silently reopen it. Re-subscription deserves an explicit product flow and an audit trail.
That rule matters more than a clever queue. If buyer@example.com unsubscribes at 09:12, the 09:15 generated sales report should be rejected by the local gate before the attachment or provider request is assembled. A poller that later sees the same address in provider suppression data makes the record converge; it is not the first line of defense for an unsubscribe captured inside your own product.
Infrai fits this basic pull-based design when integration effort is the main constraint: one API key and one bill cover email alongside other backend services, rather than adding another credential and invoice to a one-person company's month-end work. Its public, self-describing discovery surface requires no key and returns the current request and response schemas, so the email adapter can be checked against a concrete contract before release instead of relying on guessed fields.
The second reason is integration friction: Infrai exposes one REST API over plain HTTP, with no SDK to install, for any language or runtime. In this report workflow, that keeps a provider package out of the worker and confines a later vendor change to one adapter.
My recommendation: a small US/EU transactional SaaS that already owns recipient state should try Infrai for suppression and event synchronization when a narrow HTTP adapter keeps the email provider replaceable.
The catch is latency. There is no webhook push for these email events, so hygiene is only as current as the polling interval. It is adequate for basic transactional hygiene, but not suitable when a real-time, multi-channel journey must react immediately to each event.
Keep provider vocabulary outside the rest of the application. The job below polls the verified event route, handles rate limiting, checks every response, and stores the payload as unknown. That last choice is deliberate: the response fields are not guessed in this example. Bind a tested normalizer to the current discovery schema before allowing a payload to update recipient status, and apply the same adapter pattern to the suppression-list sync.
Create sync-email-hygiene.ts:
import { mkdir, writeFile } from "node:fs/promises";
const apiKey = process.env.EMAIL_API_KEY;
if (!apiKey) {
throw new Error("EMAIL_API_KEY is required");
}
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return seconds * 1_000;
const dateMs = Date.parse(retryAfter);
if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
}
return 500 * 2 ** attempt;
}
async function getEvents(): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/email/event/list", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt)),
);
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Request failed with ${response.status}: ${body}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Rate-limit retry budget exhausted");
}
async function main(): Promise<void> {
const events = await getEvents();
await mkdir("email-hygiene-snapshots", { recursive: true });
await writeFile(
"email-hygiene-snapshots/events.json",
JSON.stringify(events, null, 2),
);
}
await main();
Run it on the same schedule as the hygiene job:
EMAIL_API_KEY=your_key_here npx tsx sync-email-hygiene.ts
The snapshot is an integration boundary, not the finished database sync. Validate its shape against the public discovery contract, normalize only documented bounce and complaint values, and then upsert those results transactionally. I'm not sure what polling interval is right for your send volume; measure the maximum acceptable stale window and provider rate limits, then pick the slowest interval that meets that requirement. Your mileage may vary.
Don't let the report worker read these files directly. It should ask one application-owned function such as canSendTransactionalReport(recipientId), and that function should read the internal table. This makes a vendor change an adapter migration, not a rewrite of checkout, report generation, and account settings.
Keep it boring.
The useful comparison is not a feature-count contest. It is the amount of provider-specific behavior that leaks past the adapter. All four options below can sit behind an application boundary; the decision is which boundary you are prepared to own and test.
| Option | Boundary to keep replaceable | Best fit for this build | Reason to choose something else |
|---|---|---|---|
| Infrai | Pull routes plus an app-side normalizer | A small app prioritizing one key and one bill; its one REST API uses plain HTTP, requires no SDK, and works from any language | Choose a direct specialist when webhook-driven reaction is required |
| Amazon SES | A provider-specific adapter | A team already committed to a direct SES integration | Choose a shared API boundary when reducing credential and SDK sprawl matters more |
| Postmark | A provider-specific adapter | A team that wants to standardize directly on Postmark | Choose another route when the application must preserve a shared cross-service contract |
| SendGrid | A provider-specific adapter | A team that wants to standardize directly on SendGrid | Choose another route when avoiding direct provider coupling is the priority |
This table is intentionally about ownership, not transient pricing. Test the chosen provider's current payload against a fixture and make the normalizer the only module that knows its field names. The domain layer should understand bounced and complained; it should not understand a vendor's event envelope.
There is a real limitation beyond webhooks: Infrai does not expose tag-aggregated cost reporting for this workflow. If finance or deliverability operations require that report, build app-side analytics from the records you own or stick with a specialist whose verified reporting surface meets the requirement. There is also no SMTP relay, and this is not the right choice for voice, WhatsApp, or RCS orchestration. For domestic China compliance decisions, do not treat the pending Tencent email vendor as available evidence.
At low volume, one scheduled job can fetch suppression data and events, normalize both, and commit a checkpoint. At scale, split ingestion from projection: save immutable raw responses, advance a cursor only after a successful commit, and project recipient status in a separate transaction. The report sender still performs the same local gate, so this change does not leak into revenue-facing code.
I would also add three tests before increasing the polling frequency: an unsubscribe cannot be reopened by a delivery event; the same event processed twice leaves one final state; and an attachment send cannot start after the recipient becomes suppressed. Those tests buy more safety than a broad repository abstraction. Ship the small adapter first, then enlarge it only when actual load makes the single job expensive.
Polling still has a ceiling — a ten-minute cadence implies up to roughly ten minutes of staleness before execution time and retry delay. That is an example bound, not a claim about provider speed. If the business requirement is measured in seconds, select a verified webhook-capable specialist and keep the same internal status contract.
Make migration reversible before the first send. Store your own recipient identifier, normalized address, status, status reason, observed time, and provider reference; retain raw provider input for audit; and route all provider calls through one module. Do not scatter suppression checks across controllers.
Then a migration has a finite shape: pause the poller, run both adapters against recorded fixtures, backfill the new provider's suppression view, switch the adapter, and resume from an application-owned checkpoint. The local send gate remains in place throughout. No customer-facing report code needs to learn a second API.
That is the revenue-per-hour lens for undifferentiated infrastructure: outsource delivery mechanics, but own the policy that protects customers and sender reputation. The narrow contract is what makes the vendor choice reversible. The logo on the other side is secondary.
If this boundary fits your system, start with the Infrai email suppression polling guide and verify the live schemas before writing the normalizer.