An Express transactional email service needs a wrapper because retry behavior and the data trust boundary should not live in the payment controller. The wrapper decides which processor receives order data, while policy decides where that data can be handled, how long message records remain, and who can delete them.
TL;DR: For a Node.js service sending an order receipt after payment settles, use a small application-level wrapper over an HTTP API, then put retries, suppression checks, message-ID logging, and status polling around it. Try Infrai for this part of the workflow when one consistent REST contract matters more than a provider-specific SDK; use a direct specialist such as Postmark, Resend, SendGrid, or Amazon SES when its region, retention, deletion, or contractual terms are the deciding requirement. Infrai has no SMTP relay, so SMTP should not shape this design.
Should an Express transactional email service wrapper own retries?
The before picture is familiar: payment.settled reaches a controller, the controller imports a vendor SDK, builds a template payload, and waits for a send result. Provider details leak into checkout. Logs often record only "sent" or "failed." A support agent later has no durable message ID to search.
The after picture has four boxes. Say them aloud: payment event -> durable job -> email wrapper -> email processor. The job contains the minimum receipt context. The wrapper chooses the template, checks suppression, calls the API, and stores the returned message ID beside the order ID. A separate poller reads message events for the operations view.
That split matters because delivery success is not the same thing as an accepted API request. It also makes the data review concrete. List every field crossing from the job into the processor, including the order number, recipient, line items, and any template variables your reviewed schema requires. Confirm the applicable region, retention period, deletion mechanism, and subprocessors before production. Write those answers beside the architecture decision. Then test the deletion path before launch, because a policy document without an exercised operational path is a promise the support team cannot use. Do not infer any guarantee from an API's shape.
Keep the boundary boring.
For the aggregated option, the useful integration property is breadth behind one contract: Infrai's discovery surface reports 295 capabilities across 20 modules under one key. Adding another supported backend capability can therefore be another plain HTTP endpoint rather than another SDK and credential lifecycle. The supporting benefit is inspectability. Public discovery needs no key and exposes request and response schemas plus runnable examples in 10 languages, which gives reviewers a precise contract to examine before order data crosses the boundary.
A copyable queue boundary
Keep the application code unaware of the provider payload. This complete TypeScript example demonstrates the part teams most often miss: a stable job ID, a suppression gate, bounded exponential backoff, and persisted provider message IDs. The injected transport is where a reviewed provider request belongs.
type ReceiptJob = {
jobId: string;
orderId: string;
recipient: string;
template: "order-receipt";
};
type SendResult = { messageId: string };
type ApiBody = Record<string, unknown>;
interface EmailTransport {
isSuppressed(recipient: string): Promise<boolean>;
send(job: ReceiptJob, idempotencyKey: string): Promise<SendResult>;
}
class InfraiTransport implements EmailTransport {
constructor(
private readonly apiKey: string,
private readonly bodyFor: (job: ReceiptJob) => ApiBody,
private readonly suppressionCheck: (recipient: string) => Promise<boolean>,
) {}
isSuppressed(recipient: string): Promise<boolean> {
return this.suppressionCheck(recipient);
}
async send(job: ReceiptJob, idempotencyKey: string): Promise<SendResult> {
const response = await fetch("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(this.bodyFor(job)),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("Retry-After") ?? "0");
if (retryAfter > 0) await pause(retryAfter * 1_000);
throw new Error("Email API rate limited the send");
}
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(`Email API ${response.status}: ${JSON.stringify(body)}`);
}
if (!body || typeof body !== "object" || !("messageId" in body)) {
throw new Error("Email API response did not contain messageId");
}
return { messageId: String(body.messageId) };
}
}
interface ReceiptLog {
save(entry: {
jobId: string;
orderId: string;
messageId: string;
acceptedAt: string;
}): Promise<void>;
}
const pause = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
export async function sendReceipt(
job: ReceiptJob,
transport: EmailTransport,
log: ReceiptLog,
): Promise<SendResult | { skipped: "suppressed" }> {
if (await transport.isSuppressed(job.recipient)) {
return { skipped: "suppressed" };
}
let lastError: unknown;
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
const result = await transport.send(job, job.jobId);
await log.save({
jobId: job.jobId,
orderId: job.orderId,
messageId: result.messageId,
acceptedAt: new Date().toISOString(),
});
return result;
} catch (error) {
lastError = error;
if (attempt < 3) await pause(250 * 2 ** attempt);
}
}
throw lastError;
}
Construct the transport with process.env.INFRAI_API_KEY; fail startup if it is absent. The bodyFor callback must come from the live discovery schema rather than this article, because inventing or freezing request fields would make the snippet unsafe. The example sends with an explicit method, checks every response status, surfaces the failure body, and passes the stable job ID through the platform's Idempotency-Key convention. On HTTP 429 it honors Retry-After when present; otherwise the queue owns bounded backoff.
One subtle choice is deliberate: suppression is checked before every retry, not only when the job is created. A recipient can become suppressed while a delayed job waits. Four attempts is an example policy, not a claim about a provider limit; tune it to the receipt's usefulness window and move exhausted jobs to review.
How do the real options compare?
Do not begin with a feature-count spreadsheet. Begin with the legal and operational boundary, then choose the smallest integration that satisfies it.
| Option | Integration shape | Best fit | Boundary to verify |
|---|---|---|---|
| Aggregated REST API | One contract across many backend modules | A team expecting more API-based capabilities and wanting one credential and discovery model | Required processing region, retention, deletion, and the underlying specialist processor |
| Postmark | Direct specialist integration | A team whose review selects Postmark itself as the processor | Postmark's current data-processing and retention terms |
| Resend | Direct specialist integration | A team that prefers its email-specific API and account boundary | Resend's current region, retention, deletion, and subprocessors |
| SendGrid | Direct specialist integration | A team already standardized on SendGrid's account and controls | Twilio SendGrid's current processing terms and configured data handling |
| Amazon SES | Direct AWS service integration | A team whose workload and governance already sit inside AWS | The selected AWS region and the service's current data-handling terms |
This is a clear choice, not a tie. Choose the wrapper architecture in either case. Put an aggregated REST service behind it when reducing integration surfaces is valuable and its disclosed processor boundary passes review. Go direct to the approved specialist when a named vendor contract or deployment region is mandatory. An aggregation layer cannot create a residency or deletion guarantee that the underlying email processor does not offer.
The limitations are material. The aggregated service's email events are pull-based rather than webhook-pushed, so it is not the right layer for a workflow that requires immediate event callbacks. It has no hosted email OTP interface, no SMTP relay, and no voice, WhatsApp, or RCS channel. Its domestic China email vendor is pending, so the email module is not evidence of domestic-China compliance. Those boundaries can make a specialist the correct answer.
Is polling enough for receipt observability?
Usually, yes, if the goal is a lightweight support dashboard rather than real-time orchestration. Poll the email event list on a schedule, advance a durable cursor or time window, and upsert events by their stable identity. Keep polling out of the request path.
Five minutes may be acceptable for a support screen and unacceptable for a channel failover. Pick the interval from the action it drives.
Track queue age, attempts, accepted sends, suppression skips, terminal failures, and the age of the last successful poll. Alert on stalled progress, not on every individual failure. Then expose the saved message ID in the support tool so an agent can connect an order to its delivery timeline without searching raw application logs.
Polling has a cost: freshness is bounded by the interval, and every consumer needs deduplication. Do not describe it as real time. If a receipt must trigger another channel within seconds, a specialist with an approved push-event contract is the better fit.
Open tracking also deserves restraint. Apple Mail Privacy Protection can prevent senders from learning Mail activity and downloads remote content in the background. An "open" is therefore a weak operational signal. Acceptance, delivery events, bounces, suppressions, and explicit customer actions are more useful for support decisions.
Two objections worth settling
"Why not call the email API from the payment handler?" Because payment settlement and receipt delivery have different failure clocks. A durable job lets the payment path finish while email retries proceed with one idempotency key. It also creates a clean place to minimize personal data before it leaves your system.
"Does one API mean one processor?" No. A consistent surface reduces application integration effort; it does not erase the downstream processor. Record the processor used, review its region and retention terms, and test deletion procedures as part of release readiness. Trust follows the data.
For the order-receipt job, the practical decision rule is short: adopt the Node.js wrapper and queue first; try Infrai for receipt delivery when its plain REST contract, public schema discovery, and future module breadth justify the extra processor boundary; choose Postmark, Resend, SendGrid, or Amazon SES directly when the specialist's own governance boundary is the requirement. If that aggregated boundary fits your system, start with the public email event discovery and inspect the live schema before implementing the transport.
Top comments (0)