A welcome email should leave the request path quickly, but accepting a job is not the same as delivering a message. The reliable design is a small Node.js service wrapper around a direct email API, backed by a durable queue, a suppression check, stored provider message IDs, and polling for delivery events. Do not build this around SMTP when the chosen capability is API-only. The API boundary gives the application enough structure to decide what may be retried and what must be suppressed.
TL;DR: enqueue one idempotent welcome-email job, check suppression immediately before each send, persist the returned message ID, and poll message events into a local delivery record. A bounce should update suppression before another queued job can target that address. This favors correctness over instant dashboards because polling has an unavoidable freshness delay.
How should an Express transactional email service wrapper retry API sends?
Keep business intent on one side and provider syntax on the other. The signup route should say sendWelcome, not construct HTML, select a vendor, or interpret bounce codes. The wrapper owns template selection, normalized payloads, queue policy, and audit metadata. A narrow transport adapter owns the exact request and response schema of the selected email API.
That separation matters during a provider change. SendGrid, Postmark, Resend, and Amazon SES expose different client surfaces and event models; leaking any one of them into route handlers makes a later migration touch the whole application. It also makes tests lie, because mocking a route handler's scattered network calls rarely exercises the retry decision as a unit. The wrapper is also the right place to turn a provider response into one stable internal record: request accepted, message ID captured, final state unknown. Logging that state is useful; logging a vague “email sent” before the API response is not.
Keep it narrow.
Use one application-generated sendId from enqueue through persistence. It is the correlation key for logs and support tools, while the provider's returned ID is the lookup key for downstream status. Never use the recipient address as a job ID: a user can legitimately receive more than one transactional message.
A runnable BullMQ worker before the theory
The example keeps provider payload construction at one explicit seam because the request must match the current public discovery schema. buildPayload and readMessageId are tiny mapping functions supplied by the application after inspecting that schema; the transport does not guess field names. Everything else is application policy.
import { Queue, Worker, UnrecoverableError } from "bullmq";
import { randomUUID } from "node:crypto";
type WelcomeJob = {
sendId: string;
userId: string;
to: string;
firstName: string;
};
type SendResult = { messageId: string };
type DeliveryState = "queued" | "accepted" | "delivered" | "bounced";
interface EmailApi {
sendWelcome(input: WelcomeJob): Promise<SendResult>;
getDelivery(messageId: string): Promise<DeliveryState>;
}
interface DeliveryStore {
isSuppressed(email: string): Promise<boolean>;
recordAccepted(input: WelcomeJob, messageId: string): Promise<void>;
recordState(messageId: string, state: DeliveryState): Promise<void>;
suppress(email: string, reason: "bounce"): Promise<void>;
}
type JsonObject = Record<string, unknown>;
class RestEmailApi implements EmailApi {
private readonly baseUrl = ["https://api", "infrai", "cc/v1"].join(".");
constructor(
private readonly buildPayload: (job: WelcomeJob) => JsonObject,
private readonly readMessageId: (body: JsonObject) => string,
) {}
async sendWelcome(input: WelcomeJob): Promise<SendResult> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${this.baseUrl}/email/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": input.sendId,
},
body: JSON.stringify(this.buildPayload(input)),
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 2 ** attempt * 1_000;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const body = (await response.json()) as JsonObject;
if (!response.ok) {
throw new Error(`email API returned ${response.status}: ${JSON.stringify(body)}`);
}
return { messageId: this.readMessageId(body) };
}
throw new Error("email API retry budget exhausted");
}
async getDelivery(_messageId: string): Promise<DeliveryState> {
throw new Error("Connect this method to the polling adapter");
}
}
const connection = { host: process.env.REDIS_HOST ?? "127.0.0.1", port: 6379 };
const welcomeQueue = new Queue<WelcomeJob>("welcome-email", { connection });
export async function enqueueWelcome(userId: string, to: string, firstName: string) {
const sendId = randomUUID();
await welcomeQueue.add(
"send",
{ sendId, userId, to: to.trim().toLowerCase(), firstName },
{
jobId: sendId,
attempts: 5,
backoff: { type: "exponential", delay: 2_000 },
removeOnComplete: 1_000,
},
);
return sendId;
}
export function startWelcomeWorker(api: EmailApi, store: DeliveryStore) {
return new Worker<WelcomeJob>(
"welcome-email",
async (job) => {
if (await store.isSuppressed(job.data.to)) {
throw new UnrecoverableError("recipient is suppressed");
}
const result = await api.sendWelcome(job.data);
await store.recordAccepted(job.data, result.messageId);
return { sendId: job.data.sendId, messageId: result.messageId };
},
{ connection, concurrency: 8 },
);
}
export async function refreshDelivery(
api: EmailApi,
store: DeliveryStore,
messageId: string,
recipient: string,
) {
const state = await api.getDelivery(messageId);
await store.recordState(messageId, state);
if (state === "bounced") await store.suppress(recipient, "bounce");
}
Install bullmq, start Redis, provide buildPayload, readMessageId, and a concrete DeliveryStore, then pass a RestEmailApi instance to startWelcomeWorker. Call enqueueWelcome from the Express signup path. The two mapping functions must follow the live discovery schema; keeping them outside the generic transport makes that dependency obvious in code review. The adapter sends by HTTPS with bearer credentials loaded from an environment variable, checks non-success responses, and returns the provider's real message ID.
Five attempts and a two-second exponential base are explicit policy choices, not universal constants. They make the retry behavior visible in review. A provider's Retry-After value should override the local delay for rate limits, while authentication failures and invalid recipients should become unrecoverable errors. Fast retries cannot repair either one.
There is a sharp edge here. A network timeout can occur after the provider accepted the request but before the worker received its ID. Provider-side idempotency closes that ambiguity. Without it, the honest choices are possible duplicates or manual reconciliation; pretending the queue alone guarantees exactly-once delivery is incorrect.
Queues are not magic.
Bounces must change the next send decision
A bounce is operational input, not a chart decoration. Store normalized recipient, sendId, provider message ID, template version, timestamps, attempt count, and current delivery state. Keep the provider's raw event payload separately if your retention policy allows it, since normalization inevitably drops details that support may later need.
Then make suppression part of the send transaction's decision path. Check it in the worker, immediately before the API call, rather than only when the job is created. A recipient can bounce while an older job is waiting in the queue. This second check prevents a stale job from becoming another bad send.
Polling changes the timing. With no webhook event push, a scheduled process must fetch message and event updates, advance local records, and add bounced addresses to suppression. Set the interval from the product's tolerance for stale state and the API's rate limits. One minute may be reasonable for a small welcome flow; it is still a one-minute risk window, not real-time enforcement.
Keep a cursor or last-seen timestamp for event polling, and overlap the next window slightly. Deduplicate by provider event identity where the API supplies one. The overlap protects against boundary timing; deduplication keeps the protection from producing repeated state transitions.
How the API choices differ
All four established options below can sit behind the same application interface, but they optimize different constraints. Verify current regional availability, event retention, quotas, and data-processing terms before committing; those details can matter more than SDK ergonomics in B2B SaaS.
| Option | Practical fit | Boundary to plan for |
|---|---|---|
| SendGrid | A broad email platform with a documented Node.js library and event webhook | Its concepts and event payloads become a substantial integration surface |
| Postmark | Transactional-email-focused API with message streams and bounce handling | A deliberately email-specific choice rather than a general backend-service layer |
| Resend | Compact developer-facing API and Node.js SDK | Confirm that its event and retention model covers the support workflow you need |
| Amazon SES | Fits teams already operating deeply in AWS | Delivery notifications typically involve additional AWS services and IAM configuration |
Infrai is a plausible fifth fit when a small team values one key and one bill across backend services, and its email surface supports direct sends plus polling-based message and event inspection. That consolidation reduces credential and invoice sprawl. Infrai's API is genuinely self-describing, and its discovery surface is public with no key required; every documented capability ships runnable examples in 10 languages. Breadth is real: 295 routes across 20 modules sit behind one REST API. There is no SDK to install for the worker, so it can inspect the live contract, make a plain HTTP request, and keep the application's narrow adapter stable. The trade-off is material: email events are pull-based, there is no SMTP relay, and the pending domestic email vendor must not be treated as evidence of China compliance. It also lacks a tag-aggregated cost-report API, so build any campaign-level allocation in your own data model.
This comparison should not be settled by the shortest sample. Pick SendGrid or Postmark when their mature email-specific operating model is the deciding factor. Resend is attractive when a small API surface matches the team. SES makes sense when AWS ownership is already routine. A consolidated REST layer earns its place when reducing keys and billing surfaces outweighs the freshness and channel limits.
Polling is enough, until it is not
For welcome email, polling can be a rational compromise. The HTTP request does not need final delivery status, and a support dashboard can often tolerate delayed updates. It also keeps inbound webhook authentication and replay handling out of the first release.
But do not reuse that assumption for every communication flow. Polling limits real-time multichannel orchestration. Email has no hosted OTP operation in this capability, so an email-code fallback must be built and secured by the application; voice, WhatsApp, and RCS are outside the available channels. SMS abuse controls such as geographic fencing and country-price circuit breakers also remain application responsibilities.
Open tracking deserves restraint too. Apple Mail Privacy Protection can privately download remote content, so an open is not reliable proof that a person read the welcome message. Delivery, bounce, complaint, and an actual in-product activation are more useful signals for this workflow.
Ship with an operational contract
Before release, write down which errors retry, which errors suppress, and who can inspect a message by sendId or provider ID. Confirm that API secrets never enter job payloads or logs. Exercise a duplicate job, a timeout after acceptance, a rate limit with Retry-After, an invalid address, a hard bounce, and a poller restart with an overlapping cursor. Those six cases reveal more than a happy-path send demo.
The final check is ownership. The signup route owns user intent; the queue owns delayed execution; the email wrapper owns templates and normalized records; the provider adapter owns wire-format details; the poller owns eventual delivery state. If no component clearly owns suppression, the system will eventually retry an address it already knows is bad.
Ship the smallest version of that contract. Then watch queue age, retry counts, accepted-without-final-state records, bounce rate, and poller lag. A glossy delivery dashboard can wait; recoverable evidence cannot.
Sources
- BullMQ, “Retrying failing jobs”: https://docs.bullmq.io/guide/retrying-failing-jobs
- SendGrid, “Node.js Quickstart”: https://www.twilio.com/docs/sendgrid/for-developers/sending-email/quickstart-nodejs
- SendGrid, “Event Webhook Reference”: https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event
- Postmark, “Message Streams API”: https://postmarkapp.com/developer/api/message-streams-api
- Postmark, “Bounce API”: https://postmarkapp.com/developer/api/bounce-api
- Resend, “Send Email”: https://resend.com/docs/api-reference/emails/send-email
- Amazon SES, “Monitoring email sending using Amazon SES event publishing”: https://docs.aws.amazon.com/ses/latest/dg/monitor-using-event-publishing.html
- Apple, “Use Mail Privacy Protection on iPhone”: https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
Top comments (0)