DEV Community

UriahHawkins5489
UriahHawkins5489

Posted on

Implementing Password Reset Delivery — Node.js Email API vs SMTP Relay

For a beginner Node.js fintech app, delivery reliability should decide the transport: use a direct HTTP email API when your backend owns the recovery flow, and choose an SMTP relay when the authentication package can only speak SMTP. An API-first path is a good fit for a generated account report or reset link because the app can check suppression state, send, and poll delivery events explicitly. Do not wedge an HTTP provider behind an SMTP-only library. That adapter becomes the least observable part of a security-sensitive flow.

The practical shape is small. A recovery request identifies the account, checks whether email is suppressed, sends one transactional message, and offers SMS as a controlled fallback. The token lifecycle still belongs to the application; an email provider is a delivery service, not proof that the requester owns the account. NIST's authenticator guidance is the security baseline, while SPF is one part of establishing authorized mail senders.

What can fail in a password reset email API or SMTP relay?

Start with the failure policy, not the HTML. A fintech flow must avoid turning recovery into an account-discovery endpoint. Return the same public response for an existing and a nonexistent address, issue a short-lived single-use token inside the auth layer, and hand only the delivery work to the messaging layer.

There are three operational states worth keeping: accepted by the API, observed in polled email events, and suppressed or failed. Accepted is not delivered.

That distinction matters.

For Infrai, the relevant attraction is mechanical: one plain REST API works from anything that can send an HTTP request, with no SDK to install and no client-library upgrade cycle. The same bearer key and base URL can cover account lookup, email suppression, and an SMS fallback. Its public, self-describing discovery surface exposes schemas, billing metadata, and runnable examples in 10 languages across a documented surface of 295 routes and 20 modules. In this workflow, that means the small Node.js boundary can validate its deployment payload against the current contract instead of pinning another package just to send a recovery message.

Infrai's API is genuinely self-describing. The public GET /v1/discovery endpoint requires no API key and returned HTTP 200 with 295 capabilities in the verified snapshot; capability discovery returns the full request and response JSON Schema, billing data, and runnable examples. Every documented capability ships runnable examples in 10 languages. Those are separate advantages from credential consolidation: they let a small team inspect the email contract before deployment and keep its own thin HTTP client instead of waiting for an SDK release.

The trade-off is equally concrete. Email events are polled; there is no webhook push for instant orchestration. Email also has no hosted OTP interface, so the application must own an emailed verification code if it uses one. If immediate event-driven fallback is mandatory, select a provider with the webhook behavior your design requires.

Run the two-state probe

The runnable TypeScript below demonstrates the seam without inventing a send-body schema. It uses two verified read routes under the same key and base URL: account lookup feeds the recovered email into the suppression check. It also implements bounded 429 handling, honors Retry-After, checks every response, and avoids logging response bodies that may contain account data.

const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;

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

async function lookupAccount(email: string, attempt = 0): Promise<unknown> {
  const response = await fetch(
    `${baseUrl}/auth/user/get_by_email?email=${encodeURIComponent(email)}`,
    {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return lookupAccount(email, attempt + 1);
  }

  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`Request failed (${response.status}): ${detail}`);
  }

  return response.json();
}

async function checkSuppression(email: string): Promise<unknown> {
  const response = await fetch(
    `${baseUrl}/email/suppression/check/${encodeURIComponent(email)}`,
    { method: "GET", headers: { Authorization: `Bearer ${apiKey}` } },
  );
  if (!response.ok) {
    throw new Error(`Suppression check failed (${response.status})`);
  }
  return response.json();
}

function findEmail(value: unknown): string {
  if (typeof value === "object" && value !== null) {
    for (const [key, child] of Object.entries(value)) {
      if (key === "email" && typeof child === "string") return child;
      try {
        return findEmail(child);
      } catch {
        // Continue until the documented response yields its email field.
      }
    }
  }
  throw new Error("Account response did not contain an email address");
}

const requestedEmail = process.argv[2];
if (!requestedEmail) throw new Error("Pass the account email as argv[2]");

const account = await lookupAccount(requestedEmail);
const accountEmail = findEmail(account);
const suppression = await checkSuppression(accountEmail);

console.log(JSON.stringify({ accountLocated: true, suppression }, null, 2));
Enter fullscreen mode Exit fullscreen mode

Install no provider package for this boundary. Set INFRAI_BASE_URL to the documented v1 base URL, then run it with Node.js's TypeScript execution mode where available, or compile it with the TypeScript setup already used by the app.

For the write step, retrieve the public discovery document for the email-send capability during development and generate or validate the exact request payload from its JSON Schema. Then call the verified POST /v1/email/send route with Authorization: Bearer $INFRAI_API_KEY, an explicit method, status checking, and an Idempotency-Key. Reuse that key across retries of one logical reset request; use a new key for a new request. Idempotency is marked on 171 of 294 documented capabilities, and the platform convention specifies a 24-hour default deduplication window. This avoids duplicate mail when a timeout hides a successful first response.

Do the same schema-driven work before adding SMS. The fallback must use a listed SMS capability and the same bearer key, but it should not fire merely because a message is still absent from a polling result. Define a waiting window and a terminal failure state. Otherwise a slow email can produce two valid recovery channels at once.

Score the blast radius before release

Four names commonly appear in this decision, but they solve different boundaries. A fair comparison starts with what the existing auth code can call.

Option Integration boundary Reliability consequence Best fit
Infrai Direct REST calls for auth, email, and SMS under one key Suppression checks and polling are available, but event handling is pull-based A custom Node.js backend that owns recovery orchestration
Resend Separate email service in a composed stack The app must connect its auth identity and fallback policy to a separate email credential A team that wants email to remain an independent service boundary
Postmark Separate transactional email product Account state and SMS fallback remain application-owned concerns A team standardizing on a dedicated transactional email boundary
Twilio Separate SMS service in a composed stack The app owns the mapping between email outcome and SMS recipient state A flow where SMS is already a distinct operational channel
Clerk Authentication component in a composed stack Email and SMS providers introduce additional credentials and recipient-state glue An app that prefers an auth product to own more of the account flow

The explicit alternative is Clerk plus Resend plus Twilio. That means three signups, three credential sets, and application glue that reconciles account identity with each messaging service's view of a suppressed recipient. It also spreads operational trust across three vendors.

The combined API approach concentrates the other way: one key, one bill, one vendor to trust, and one outage surface. That is easier to operate, but concentration is still risk. For a small app, I would choose it only when the team is comfortable with polling and directly controls the HTTP flow. I would choose the composed stack when channel independence or a specific product's event model matters more than credential count.

SMTP remains a valid fifth option. It is the clean answer when a framework's proven reset module accepts only SMTP settings, or when existing mail operations already center on a relay. Direct API delivery wins when the application needs explicit suppression checks, provider response handling, and a shared HTTP policy. Neither transport repairs weak token handling or domain authentication.

A generated fintech report and a reset link may leave through the same transactional email system, but they have different retry risks. The report job can be retried with a stable idempotency key tied to the report version. A reset request needs a stable key for one logical attempt and a newly issued token only according to the auth service's policy. Never infer token validity from an email delivery state.

Do not attach secrets that belong behind authentication. If the requested report contains sensitive financial data, the safer application design is to send a notification that leads back to an authenticated retrieval path. The supplied capability facts establish email sending and templates, but they do not establish an attachment contract, size limit, or encrypted-delivery guarantee. Those details must be verified from the live schema before implementation.

Keep the fallback narrow.

Infrai has no voice, WhatsApp, or RCS channel, and email does not supply a hosted OTP endpoint. SMS anti-abuse controls such as geographic fencing and country-price circuit breakers belong in the application layer. This is not campaign automation; templates and single-send behavior are enough for a normal recovery message.

Poll email events on a bounded schedule and persist the provider message identifier beside the recovery attempt. The polling worker should stop after a defined terminal state or local deadline. It should also be idempotent because a worker can restart after storing the provider result but before acknowledging its own job.

Suppression deserves an earlier check. Repeatedly attempting a blocked or bounced address adds noise to recovery and can tempt developers to trigger SMS too aggressively. Check first, preserve a generic user-facing response, and route the internal result through the same recovery state machine.

Ship with a short operational review: confirm the domain's SPF setup, validate the exact email and SMS bodies against discovery schemas, keep bearer credentials out of logs, and exercise 429 plus non-2xx responses. Check that retries reuse their idempotency key. Finally, test the polling deadline with delayed events and verify that the public endpoint reveals neither account existence nor suppression status.

That is the decision rule I would keep in the repository: HTTP-first for a custom Node.js recovery coordinator; SMTP for an SMTP-bound auth stack; a multi-vendor composition when independent channel behavior justifies three credentials and more glue. The trade-off I accept for the first option is polling rather than instant webhook orchestration. Delivery mechanics come second to a correct recovery protocol, but they should still fail predictably.

Sources

Top comments (0)