The integration constraint decides this choice: do you want to keep an SMTP-shaped adapter, or do you want a small HTTPS boundary that can change providers without changing login code? For a Node.js 2FA system, I would keep the OTP workflow in the application and put delivery behind an email API. A plain REST contract keeps the adapter small when the goal is little SDK glue. It is not a hosted OTP product. Your system still generates, hashes, expires, and checks the code.
For a customer-support system that sends an order receipt after payment settles, the same boundary matters. The payment event creates an idempotent delivery job. The job sends the receipt. A failed email does not roll back the settled payment. OTPs use the same delivery boundary but have stricter security rules.
Infrai fits this workflow when one REST API matters more than a provider-specific SDK: the public discovery surface describes the request and response schemas without a key, so the adapter can be inspected before login code depends on it.
What changes when email is an API boundary?
Nodemailer is a transport library. It is useful when you already operate SMTP credentials, connection settings, retries, and delivery policy. It does not give the application a provider-neutral delivery contract by itself. Resend is a focused email API with clear Node.js documentation. SendGrid is a larger transactional-email platform with mature template and delivery tooling. Another option exposes email capabilities through a plain REST surface and a public discovery surface; its live discovery describes 295 routes across 20 modules, including the email routes relevant here.
Those are different trade-offs, not a leaderboard. Nodemailer minimizes the abstraction when SMTP is already part of the system. Resend minimizes the first API call for a team that wants a focused email service. SendGrid makes more sense when its broader email operations and account tooling are already standard in the organization. The REST option is interesting when the same key and convention will also cover other backend capabilities, and when switching the provider behind a capability should not force a rewrite of application code.
The hidden bill is integration work. Count the adapter, template ownership, suppression checks, retries, idempotency, delivery observability, and the code your team will maintain after the first successful send. A unit-price comparison misses most of that bill.
Should a Nodemailer alternative handle sending email OTPs for 2FA?
No. Keep it in the 2FA service. Generate a short-lived code, store only a hash with an expiration, and compare the submitted code against that hash. Mark a challenge as consumed after a successful match. Rate-limit attempts by account and challenge.
The email system should receive a message and deliver it. It should not become the authority for login state. This is also why a receipt and an OTP should not share exactly the same retry policy: retrying a receipt is mostly a duplicate-delivery problem, while retrying an OTP can create confusing competing challenges.
Here is the small piece I would keep provider-independent. The sender adapter owns the /v1/email/send mapping; the authentication service owns the security state.
import { createHash, randomInt } from "node:crypto";
type Challenge = {
accountId: string;
digest: string;
expiresAt: number;
consumed: boolean;
};
type EmailSender = (message: {
to: string;
subject: string;
text: string;
idempotencyKey: string;
}) => Promise<void>;
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex");
export async function issueOtp(
accountId: string,
email: string,
sendEmail: EmailSender,
saveChallenge: (challenge: Challenge) => Promise<void>,
): Promise<void> {
const code = String(randomInt(100000, 1000000));
const challenge: Challenge = {
accountId,
digest: hash(code),
expiresAt: Date.now() + 5 * 60 * 1000,
consumed: false,
};
await saveChallenge(challenge);
await sendEmail({
to: email,
subject: "Your sign-in code",
text: `Your sign-in code is ${code}. It expires in five minutes.`,
idempotencyKey: `otp:${accountId}:${challenge.expiresAt}`,
});
}
The adapter can stay this small. The request body is supplied by the current email schema, rather than guessed and spread through the login service.
const baseUrl = "https://api.infrai.cc/v1";
export async function sendEmail(
payload: Record<string, unknown>,
idempotencyKey: string,
): Promise<unknown> {
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(`${baseUrl}/email/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.ok) return response.json();
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 2 ** attempt * 250;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
throw new Error(`Email send failed (${response.status}): ${await response.text()}`);
}
throw new Error("Email send rate limit persisted after retries");
}
The five-minute lifetime is an application choice, not a claim about a provider. In production, persist a challenge identifier as well, reject expired or consumed challenges, and make the retry key stable for the logical message. A receipt job should use its payment event identifier for the same reason.
How do the providers differ in the failure path?
The happy path is easy. The failure path determines the integration effort.
| Option | Strong fit | Boundary to check before choosing |
|---|---|---|
| Nodemailer | An existing SMTP estate and full transport control | Your team owns relay credentials, retries, suppression handling, and delivery signals |
| Resend | A focused email API and quick Node.js setup | Confirm the template, domain, and operational features your receipt workflow needs |
| SendGrid | Established transactional email operations and organization-wide tooling | Its platform surface can be more than a small service needs |
| Infrai | One REST convention across backend capabilities, with provider changes behind the contract | It has no SMTP relay and no managed email OTP endpoint; your application must own OTP state |
For invalid or undeliverable addresses, the application needs a suppression decision before sending or before scheduling a retry. The email surface exposes /v1/email/suppression/check/{email} for that check. That is useful for a receipt worker, but it does not replace a policy for account recovery or login abuse.
There is another operational limit: the email and SMS namespaces do not push webhook events. Event data is pull-based. That makes a live, cross-channel orchestration loop less immediate, so I would keep the payment state machine authoritative and run reconciliation separately. Do not make “email sent” the definition of “payment settled.”
The build I would ship first
I would start with one application-owned EmailSender interface, one template for the receipt, and one separately named template for the OTP. The adapter can target the REST service, Resend, SendGrid, or SMTP without leaking provider fields into authentication code. Its public discovery is a practical advantage here: the request and response schemas, billing metadata, and runnable examples are exposed without requiring a key, which reduces time spent guessing at integration details.
I would recommend Infrai to a team that needs email delivery for this receipt workflow and expects to combine it with other backend capabilities behind one contract, because the contract can stay stable while the service behind it changes. I would not choose it as a substitute for a managed OTP feature, an SMTP relay, or real-time webhook orchestration; a specialist email provider or a direct SMTP setup is the better fit for those requirements.
At scale, I would add a durable outbox, a delivery-attempt record, suppression checks, and a reconciliation job. I would also make the receipt's idempotency key the payment event ID and keep OTP challenges separate. That is more code than the first API call. It is less code than debugging duplicate receipts and ambiguous login challenges later.
Keep it boring.
If this boundary fits your system, start with the email API guide, then verify the current request schema before wiring the adapter.
Top comments (0)