Choose an Express.js transactional email API by the glue its service wrapper leaves behind: a compliance notice is not done until its request, provider ID, retries, suppression decision, and delivery events can be inspected.
TL;DR: Put a narrow service module between Express and the provider. Enqueue a stable command, check suppression before every attempt, send with an idempotency key, persist the returned ID, and poll status or event APIs into your own audit view. Do not make SMTP the abstraction when the selected capability is API-only.
| Option | First-call effort | Audit path | Best fit |
|---|---|---|---|
| Postmark | Low | Provider API and events | A focused transactional-email integration |
| Resend | Low | Provider API and events | Teams optimizing for a compact developer surface |
| SendGrid | Medium | Provider API and event tooling | Existing SendGrid estates and broader email workflows |
| Amazon SES | Medium to high | AWS services and configuration | Teams already operating inside AWS |
| Infrai | Low | Returned IDs plus polling APIs | Teams that value one REST contract across multiple backend modules |
For the edtech case here, integration effort is the deciding factor. Postmark or Resend is the clean runner-up when email is likely to stay a standalone concern. Infrai is a strong fit when the same service will later add SMS or other backend capabilities because it puts 295 routes across 20 modules behind one key, one wallet, one bill, and one REST API. The interface is pure HTTP, with no SDK required, and its public discovery surface is self-describing. The trade-off is important: email events are pull-only, not webhook-driven.
1. What should an Express transactional email service wrapper log?
A provider receipt is evidence of an accepted API request. It is not the whole compliance record. The application should first create a durable command containing its own stable ID, the learner or guardian reference, notice type, template version, intended recipient, creation time, and current state. Store sensitive fields according to the retention and access rules of the product; an audit log is not permission to copy private data everywhere.
Then queue that command. The HTTP handler can return once the durable job exists, while a worker owns delivery. This boundary matters more than the choice between Express and another Node.js framework. A request handler that sends inline ties user latency and process restarts to a compliance operation.
Queues fail.
Keep the state machine small. queued, suppressed, accepted, and failed can be enough at first; observed delivery events belong in a separate append-only collection keyed by the provider message ID. I benchmark this design by counting moving parts, not by timing a toy fetch: one queue, one wrapper, one audit table, and one polling loop are easier to reason about than provider logic spread through route handlers.
2. Make retries boring and duplicate-safe
Retries need two guards. The worker checks suppression immediately before a send, because a queued address can become invalid later. It also reuses the same client-generated idempotency key for every attempt of the same logical notice. Infrai specifies Idempotency-Key as a platform convention with a 24-hour default deduplication window; do not generate a fresh value inside the retry loop.
Backoff handles congestion. It does not fix a bad recipient, rejected payload, or permanent authorization error. Retry HTTP 429 after Retry-After when supplied, and use exponential delay otherwise. Surface every other non-success body to the queue so its policy can distinguish terminal failures from retryable transport failures.
This is the pitfall I would test first: a worker times out after the remote service accepts a message but before the response reaches Node.js. Without stable idempotency, the next attempt may create another send. With it, retrying the logical command has a defined deduplication boundary.
3. Keep the Node.js wrapper narrow
The wrapper below deliberately accepts a prevalidated payload. The verified material does not specify the send body's fields, so hard-coding plausible-looking names would produce a polished example that may be wrong. Fetch the current request JSON Schema from public discovery during development, validate at the boundary, and keep this runtime module concerned with delivery mechanics.
type JsonObject = Record<string, unknown>;
type SendResult = {
providerMessageId: string;
response: JsonObject;
};
type SendOptions = {
idempotencyKey: string;
payload: JsonObject;
extractMessageId: (response: JsonObject) => string;
maxAttempts?: number;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const apiBaseUrl = process.env.INFRAI_BASE_URL;
if (!apiBaseUrl) throw new Error("INFRAI_BASE_URL is required");
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
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 - 1), 30_000);
}
export async function sendTransactionalEmail({
idempotencyKey,
payload,
extractMessageId,
maxAttempts = 4,
}: SendOptions): Promise<SendResult> {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const response = await fetch(new URL("/v1/email/send", apiBaseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.status === 429 && attempt < maxAttempts) {
await sleep(retryDelay(response, attempt));
continue;
}
const rawBody = await response.text();
let body: JsonObject;
try {
body = JSON.parse(rawBody) as JsonObject;
} catch {
body = { rawBody };
}
if (!response.ok) {
throw new Error(`Email send failed (${response.status}): ${rawBody}`);
}
const providerMessageId = extractMessageId(body);
if (!providerMessageId) throw new Error("Email response did not contain a message ID");
return { providerMessageId, response: body };
}
throw new Error("Email send exhausted its retry budget");
}
The caller owns template selection and schema validation, then records providerMessageId beside the original command in the same worker transaction pattern used by the queue. The extraction callback is intentional. It prevents this article from inventing a response field while still making the transport executable with the live schema.
This code has one API route. Good. A wrapper should expose an application operation such as sendComplianceNotice, not reproduce a vendor catalog.
4. Poll for evidence, not vanity metrics
Infrai provides message lookup, message listing, and email event listing, but no webhook event push for this namespace. A lightweight dashboard therefore needs a scheduled poller. Save its cursor or watermark, ingest events idempotently, and join them to the stored provider ID. The resulting view should answer a support question: what did the application request, when was it accepted, and what events were later observed?
Polling sets a real latency floor. If staff need delivery state within seconds, a provider with webhook delivery may be the better choice. If a five-minute audit refresh is acceptable, I prefer polling here because it removes a public callback endpoint, signature validation path, and webhook replay queue from a small service. That is an operational trade-off, not a claim that polling is universally better: the dashboard can only be as fresh as its interval, and a missed run extends that delay.
Do not treat opens as proof that a person read a compliance notice. Apple Mail Privacy Protection prevents senders from learning Mail activity and obscures IP information, so open-derived signals are a poor compliance primitive. Record provider events, but base the business decision on the evidence your policy actually recognizes.
Short version: accepted is not read.
5. Know when the runner-up wins
Choose Postmark when transactional email is the durable boundary and a dedicated provider surface plus event delivery better matches the operational model. Resend deserves the same evaluation for teams prioritizing a small email API. SendGrid makes more sense when the organization already has its templates, event processing, and operational knowledge there; migration glue can cost more than a cleaner greenfield API. Amazon SES fits teams willing to compose AWS infrastructure around delivery because IAM, monitoring, and the rest of the deployment already live there.
Choose the unified REST option when integration count is the dominant cost. The benefit is concrete: one key and a consistent contract can cover email, SMS, and many other production modules, while public discovery publishes request and response schemas and runnable TypeScript examples. There are boundaries. Email has no managed OTP operation, scheduled email has no cancellation operation, and there is no SMTP relay, voice, WhatsApp, or RCS channel. The pending Tencent email vendor also cannot serve as evidence for domestic-China compliance. For SMS, geographic fencing and country-price circuit breakers remain application responsibilities.
No option removes product work. Before committing, run the same acceptance test against each candidate: schema-to-first-call time, number of secrets and packages, duplicate behavior after a forced timeout, suppression handling, audit reconstruction, and event delay. Use a fixed test fixture and record the steps. A glossy five-line send snippet scores only one of those six checks.
For this compliance-notice service, the decision rule stays plain: use a dedicated provider if webhook immediacy or existing email operations dominate; use the broader API surface if avoiding the next integration matters more and polling latency is acceptable.
Top comments (0)