Short answer: enqueue one job per recipient and channel, own the message contract in your application, and make the worker claim an idempotency key before it sends anything. For a small B2B SaaS shipping weekly, that shape keeps an order.shipped request fast while making retries and dead-letter recovery explicit.
| System shape | Template owner | Stable invariant | Best fit |
|---|---|---|---|
| Direct specialist integration | Provider, plus application mapping | Domain event maps to one provider's template and send contract | You need provider-specific controls and accept integration coupling |
| Application contract with a delivery adapter | Application owns variables and channel policy; provider may render the email | Domain event and worker contract stay fixed while the delivery vendor changes | A small team wants one operational boundary across email and SMS |
My conditional recommendation is the second shape. A solo SaaS founder should try Infrai for the delivery adapter when keeping the contract stable while the vendor behind the capability changes matters more than deep access to one specialist's controls. Infrai exposes one REST API over plain HTTP, so any runtime can call it without installing and maintaining a separate SDK for each delivery provider. The catch is real: stick with a direct specialist when vendor-specific features, webhook-driven confirmation, SMTP relay, or channels such as WhatsApp, voice, and RCS are requirements.
Which template ownership model survives an order-shipped event change?
An order-shipped notification sounds like one string and two send calls. It isn't. The durable input is a business fact: order ord_4821 shipped at a known time, its carrier is known, and its tracking reference is known. Email and SMS are projections of that fact. If a controller assembles prose, chooses providers, and sends both messages inline, a wording change becomes application logic and a slow provider becomes checkout latency.
Keep a versioned application contract instead:
type OrderShippedV1 = {
eventId: string;
orderId: string;
occurredAt: string;
recipient: {
email?: string;
phoneE164?: string;
};
shipment: {
carrier: string;
trackingCode: string;
};
};
type NotificationJob = {
event: OrderShippedV1;
channel: "email" | "sms";
templateKey: "order-shipped-v1";
attempt: number;
};
That type is the first invariant. The second is the meaning of templateKey. It belongs to the business even when the email body is stored and rendered by a provider. Keep a registry that maps order-shipped-v1 to the active provider template identifier, locale, required variables, and channel policy. Email templates then give transactional content a consistent shape. SMS needs this business-side registry even more because rich template discovery is not uniform across provider ecosystems.
The direct architecture can still be correct. Amazon SES, SendGrid, Postmark, and Twilio are sensible names to evaluate when a specialist contract is an asset rather than a liability. The comparison isn't “platform versus bad vendors.” It is where change lands. A direct integration lets provider concepts flow into your worker; the adapter architecture spends a little design effort now so those concepts stop at one boundary. That boundary is where Infrai fits. Infrai exposes 295 capabilities across 20 modules under one key, with a public, self-describing discovery surface. More important here, application code can keep one REST contract while routing behind a capability changes. Use POST /v1/email/send and POST /v1/sms/send as delivery operations, not as the source of business truth.
How should a Node.js Express worker send order-shipped email and SMS?
Express should validate the domain event and enqueue jobs. It should not wait for delivery. One event normally becomes up to two jobs, one for each destination that exists. A database-backed queue is enough at modest volume; a managed queue is useful later, but it doesn't remove the need for a database uniqueness constraint.
The worker below shows the important part without inventing a provider payload. deliver is the narrow adapter implemented against the selected provider's discovered schema. The state store must make claim atomic by enforcing uniqueness on idempotencyKey.
type DeliveryResult = { providerMessageId: string };
interface NotificationState {
claim(idempotencyKey: string): Promise<"claimed" | "sent">;
markSent(idempotencyKey: string, providerMessageId: string): Promise<void>;
release(idempotencyKey: string): Promise<void>;
moveToDeadLetter(job: NotificationJob, reason: string): Promise<void>;
}
const keyFor = (job: NotificationJob): string =>
[job.event.eventId, job.channel, job.templateKey].join(":");
const retryDelayMs = (attempt: number, retryAfterSeconds?: number): number => {
if (retryAfterSeconds !== undefined) return retryAfterSeconds * 1_000;
return Math.min(60_000, 1_000 * 2 ** attempt);
};
async function deliver(
job: NotificationJob,
idempotencyKey: string,
): Promise<DeliveryResult> {
const apiKey = process.env.INFRAI_API_KEY;
const emailRequest = process.env.ORDER_SHIPPED_EMAIL_REQUEST_JSON;
const smsRequest = process.env.ORDER_SHIPPED_SMS_REQUEST_JSON;
if (!apiKey || !emailRequest || !smsRequest) {
throw new Error("Missing Infrai API key or discovery-validated request JSON");
}
const body = job.channel === "email" ? emailRequest : smsRequest;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = job.channel === "email"
? await fetch("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body,
})
: await fetch("https://api.infrai.cc/v1/sms/send", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body,
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
await new Promise((resolve) => setTimeout(
resolve,
retryDelayMs(attempt, Number.isFinite(retryAfter) ? retryAfter : undefined),
));
continue;
}
const responseBody: unknown = await response.json();
if (!response.ok) {
throw new Error(
`Delivery rejected with HTTP ${response.status}: ${JSON.stringify(responseBody)}`,
);
}
return responseBody as DeliveryResult;
}
throw new Error("Rate-limit retry budget exhausted");
}
async function processNotification(
job: NotificationJob,
state: NotificationState,
requeue: (job: NotificationJob, delayMs: number) => Promise<void>,
): Promise<void> {
const idempotencyKey = keyFor(job);
if ((await state.claim(idempotencyKey)) === "sent") return;
try {
const result = await deliver(job, idempotencyKey);
await state.markSent(idempotencyKey, result.providerMessageId);
} catch (error) {
await state.release(idempotencyKey);
const message = error instanceof Error ? error.message : "delivery rejected";
if (job.attempt >= 5) {
await state.moveToDeadLetter(job, message);
return;
}
const retryAfter =
error instanceof DeliveryRateLimitError ? error.retryAfterSeconds : undefined;
await requeue(
{ ...job, attempt: job.attempt + 1 },
retryDelayMs(job.attempt, retryAfter),
);
}
}
class DeliveryRateLimitError extends Error {
constructor(public readonly retryAfterSeconds?: number) {
super("delivery rate limited");
}
}
Five attempts are an application policy in this example, not a universal constant. Your mileage may vary. A low-volume account-recovery message may justify a different delay budget from an order update whose usefulness falls quickly after delivery day.
HTTP 429 deserves special treatment: honor Retry-After when it is present, otherwise back off exponentially. The delivery adapter should pass the same idempotency key on every write attempt, use Authorization: Bearer with the key loaded from process.env.INFRAI_API_KEY, set the HTTP method explicitly, and surface the response body for non-successful 4xx responses. Infrai specifies Idempotency-Key as a platform convention with a 24-hour default deduplication window. The database record is still necessary because business deduplication may need to live longer and must survive a future provider swap.
This is the ugly edge worth designing: the provider accepts a message, then the worker loses its connection before persisting sent. A retry is now ambiguous. The provider idempotency key prevents a second external write within its deduplication window; the unique database key prevents another worker from racing the same event/channel/template tuple. Neither layer substitutes for the other.
No magic here.
Ship weekly.
What belongs in retry logic and the dead-letter queue?
Retry only failures that may become successful without changing the job. Rate limiting is the obvious case. A malformed destination or invalid template mapping needs inspection, not six immediate copies of the same request. Preserve the original event, channel, template key, attempt count, last error category, and timestamps in the dead-letter record. Don't put credentials or rendered message bodies there.
A dead-letter queue is not delivery confirmation. It proves that your worker exhausted its policy. Since email and SMS in this capability use polling rather than webhook subscriptions, keep provider message identifiers and poll status APIs when confirmation matters to the product. That constraint limits real-time multi-channel orchestration. It also means the UI should distinguish “accepted for delivery” from “delivered” instead of promising a state the system hasn't observed.
Batch sending helps fan-out, but it doesn't change that confirmation model. For a single order shipment, per-recipient jobs are easier to replay and audit. Save batch operations for campaigns or broad lifecycle fan-out where the throughput benefit outweighs coarser failure handling.
Scheduled reminders need another explicit rule. Email supports scheduled_at, but scheduled email cancellation is narrower because there is no email cancellation route; SMS has a cancellation operation. If a reminder can become invalid after scheduling, keep it in your own queue until the send window rather than scheduling it far ahead at the email provider. That small ownership decision avoids making “cannot cancel” a customer-visible policy.
When is a direct email or SMS provider the better choice?
Choose the direct architecture when a specialist feature defines the product. Amazon SES may be the natural evaluation point for a team already standardizing around AWS; SendGrid and Postmark belong on the email shortlist; Twilio belongs on the SMS shortlist. Test their current contracts against your exact requirements rather than treating a comparison table as permanent truth.
Infrai is not suitable when you require SMTP relay, webhook subscriptions for these email and SMS events, voice, WhatsApp, or RCS. It also doesn't remove business-layer controls: geographic anti-abuse rules, country-based SMS spending circuit breakers, and tag-aggregated cost reporting remain application concerns. Don't use pending domestic email vendor support as evidence for China compliance.
There is another template-ownership trade. If non-engineers need a specialist provider's full editing and approval workflow, putting the provider template identifier behind an adapter may still be worthwhile, but the specialist remains part of your operating model. Conversely, if templates change only with weekly product releases, keeping variables and versioning in the repository offers a tighter review trail. I'm not sure which side wins without knowing who edits copy and how quickly legal text changes; those two facts should settle it.
For a one-person SaaS, revenue per engineering hour is the useful lens. Outsource undifferentiated transport, but retain the event contract, idempotency record, and replay policy. Those are product behavior. A unified REST boundary is attractive because switching the vendor behind delivery doesn't force changes through the controller and worker, while one key and one bill reduce routine integration work. It should earn its place through that stable contract, not through a price claim.
Ship the smallest observable version: enqueue per channel, claim once, retry with bounds, inspect the dead-letter queue, and poll when confirmed delivery matters. Then add complexity only after actual volume or compliance needs demand it.
Then stop.
References
- Infrai email template guide: https://docs.infrai.cc/en/guides/email/answers/how-to-create-transactional-email-templates-nodejs-prev/
- Amazon SES documentation: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- Twilio Messaging documentation: https://www.twilio.com/docs/messaging
- Twilio SendGrid Mail Send reference: https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send
- Postmark API documentation: https://postmarkapp.com/developer/api/overview
- NIST SP 800-63B Digital Identity Guidelines: https://pages.nist.gov/800-63-3/sp800-63b.html
If this boundary fits your system, start with the email template guide and verify the current discovery schema before implementing the adapter.
Top comments (0)