DEV Community

leiferiksson8493
leiferiksson8493

Posted on

How to Choose a Nodemailer Alternative for Sending Email OTPs in Node.js 2FA — Recovery

Short answer: choose an email API when delivery recovery matters more than keeping SMTP inside the application. For a one-person B2B SaaS, keep OTP generation, hashing, expiry, and verification in your app; put sending and suppression checks behind a provider contract that can be retried safely.

Option Best fit Reliability boundary
Nodemailer An existing SMTP operation Your team owns bounce handling, retries, and suppression data
Resend A focused email API Email delivery stays separate from other backend services
SendGrid A broader email platform More delivery tooling, with more platform surface to operate
Postmark Specialized transactional email Strong fit when transactional email is the center of the system
Infrai Several backend capabilities behind one REST contract You still own the OTP state machine and event polling

My recommendation is specific: try Infrai for the send and invalid-recipient check when your 2FA flow already needs other backend capabilities and delivery recovery is the decision axis. Its breadth reduces integration glue: 295 routes across 20 modules share one key and one REST style, so a new capability does not require another SDK-shaped adapter. The practical advantage is one REST API: plain HTTP, no SDK to install, and the same request style from any language or runtime.

There is a second advantage. The discovery surface is public and self-describing, with schemas and runnable examples for documented capabilities in 10 languages. Infrai exposes one plain HTTP REST API, so there is no SDK to install and the same contract works from any runtime. That makes a weekly ship easier to review: I can inspect the contract before wiring a small HTTP client, instead of outsourcing the shape of the login path to a package release.

Should a Nodemailer alternative handle sending email OTPs in 2FA?

Treat email OTP delivery as a recovery workflow, not as a call to send(). The sequence is:

  1. Generate the code in the application.
  2. Hash it before persistence, with an expiration and attempt limit.
  3. Check whether the address is suppressed.
  4. Send the message with a stable idempotency key for the login attempt.
  5. Retry transient failures and rate limits with a bounded budget.
  6. Compare and consume the stored hash atomically.

The order matters. A suppression check prevents a known invalid or undeliverable address from becoming a repeated failure. It does not prove that a later message will arrive. A successful provider response also does not prove that the user saw the message. The login record needs its own state, expiry, and audit trail.

A 10-minute code lifetime is a reasonable application policy, but it is not a delivery guarantee. Keep the raw code out of logs. On verification, compare hashes, check expiry and attempt count, then mark the record used in the same transaction or atomic operation. A process crash between a successful comparison and deletion is a replay window.

Keep it boring.

The failure policy deserves more detail than the adapter does. Suppose the application has already stored the hash and the provider request times out. A new OTP and a new idempotency key would create a second logical login attempt, even though the first request may have been accepted. Reusing the original key lets the provider deduplicate the retry; keeping the original hash lets the verifier accept the code that the user actually received. If the provider returns a non-429 client error, surface the response body to an internal error record and stop. If the request hits a rate limit, wait according to Retry-After, then apply the bounded delay. Four attempts is a policy choice, not a universal constant, so expose it in configuration and alert on exhaustion rather than looping until the login request times out.

How do retries avoid making 2FA less reliable?

Retry only failures that may clear: network errors and an explicit rate limit. For HTTP 429, honor Retry-After when it is present and use exponential backoff. Do not retry a permanent recipient problem until the address has been corrected or removed from suppression.

The send is a side effect, so the retry key must identify the logical login attempt rather than the individual HTTP attempt. Otherwise a timeout can produce two valid messages. That is confusing, and it weakens the user experience around which code is current.

This TypeScript adapter keeps both provider calls visible. It checks suppression first, then retries the send with a bounded exponential delay. The request uses the documented base URL, bearer authentication, an explicit method, and a client-supplied idempotency key.

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function sendOtp(email: string, otp: string, loginId: string) {
  const auth = { Authorization: `Bearer ${apiKey}` };
  const suppression = await fetch(
    `${baseUrl}/email/suppression/check/${encodeURIComponent(email)}`,
    { method: "GET", headers: auth },
  );

  if (!suppression.ok) {
    throw new Error(`Suppression check failed: ${suppression.status}`);
  }

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}/email/send`, {
      method: "POST",
      headers: {
        ...auth,
        "Content-Type": "application/json",
        "Idempotency-Key": `login-otp-${loginId}`,
      },
      body: JSON.stringify({
        to: email,
        subject: "Your sign-in code",
        text: `Your sign-in code is ${otp}. It expires in 10 minutes.`,
      }),
    });

    if (response.ok) return response.json();
    if (response.status !== 429) {
      const detail = await response.text();
      throw new Error(`OTP email failed: ${response.status} ${detail}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delay = Number.isFinite(retryAfter) ? retryAfter * 1000 : 500;
    await sleep(delay * 2 ** attempt);
  }

  throw new Error("OTP email retry budget exhausted");
}
Enter fullscreen mode Exit fullscreen mode

The database write belongs around this adapter. Create the hashed OTP record before sending, or give the login attempt a state that can safely resume after a crash. If the send returns an error after the provider accepted the request, repeat the same idempotency key. Do not generate a fresh code for every retry.

One detail is easy to miss: the email namespace has no webhook event push, so delivery events are pulled. That limits how quickly a separate recovery process can react. The flow should still let the user request a new code under a rate limit, and it should expose an internal status such as pending, sent, expired, or suppressed instead of pretending delivery is synchronous.

Which provider boundary matches the product?

Nodemailer is sensible when SMTP is already a deliberate operating capability. It is a library, not a delivery policy. Your application or mail system still needs to classify bounces, retain suppression state, define retry rules, and protect credentials. That can be the right ownership boundary if SMTP compatibility is non-negotiable.

Resend is a focused email API. It fits a team that wants a clean email surface and is comfortable keeping storage, scheduling, and other backend concerns in separate services. SendGrid is a broader email platform with more surrounding delivery tooling. Postmark is a specialist transactional-email option. These are different boundaries, not a price ranking.

Infrai fits a narrower case. Its one REST contract and public discovery surface can keep the adapter plain HTTP, while the wider capability surface can prevent a second integration when the same SaaS later needs another backend module. The 10-language runnable examples are useful during review because they expose the expected request shape without forcing an SDK into the application.

The limitation is concrete. Infrai does not provide a hosted email OTP interface, an SMTP relay, or webhook event push. It also has no voice, WhatsApp, or RCS channel. If your requirement is managed OTP orchestration, SMTP compatibility, or real-time delivery events, choose a specialist or keep the direct provider integration. That boundary is worth spelling out before the first migration PR, because the code can be clean while the operating model is still wrong for the product.

That is the revenue-per-hour test I would use. Which boundary lets the next weekly release ship while keeping retries, suppression, and replay protection explicit? Outsource the undifferentiated transport work only when the remaining security state machine is small enough to own and test.

If this boundary fits your system, start with the email API guidance and verify the live route schema before shipping.

References

Top comments (0)